diff --git a/core/auth.py b/core/auth.py
index e18d177..707e94d 100644
--- a/core/auth.py
+++ b/core/auth.py
@@ -48,10 +48,12 @@ def get_account_identifier(account: dict) -> str:
def get_queue_status() -> dict:
"""获取账号队列状态(用于监控)"""
with _queue_lock:
+ # total 应该是配置中的账号总数,而非队列相加(避免状态不一致导致重复计数)
+ total_accounts = len(CONFIG.get("accounts", []))
return {
"available": len(account_queue),
"in_use": len(in_use_accounts),
- "total": len(account_queue) + len(in_use_accounts),
+ "total": total_accounts,
"available_accounts": [get_account_identifier(a) for a in account_queue],
"in_use_accounts": list(in_use_accounts.keys()),
}
diff --git a/routes/admin.py b/routes/admin.py
index 296d4ee..0ae0c8b 100644
--- a/routes/admin.py
+++ b/routes/admin.py
@@ -5,6 +5,9 @@ import json
import os
import httpx
import asyncio
+import time
+import hashlib
+import hmac
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse
@@ -20,20 +23,171 @@ security = HTTPBearer(auto_error=False)
# Admin Key 验证
ADMIN_KEY = os.getenv("DS2API_ADMIN_KEY", "")
+# JWT 配置
+JWT_SECRET = os.getenv("DS2API_JWT_SECRET", ADMIN_KEY or "ds2api-default-secret")
+JWT_EXPIRE_HOURS = int(os.getenv("DS2API_JWT_EXPIRE_HOURS", "24"))
+
# Vercel 预配置(可通过环境变量设置)
VERCEL_TOKEN = os.getenv("VERCEL_TOKEN", "")
VERCEL_PROJECT_ID = os.getenv("VERCEL_PROJECT_ID", "")
VERCEL_TEAM_ID = os.getenv("VERCEL_TEAM_ID", "")
+# ----------------------------------------------------------------------
+# JWT 工具函数(轻量实现,无需额外依赖)
+# ----------------------------------------------------------------------
+def _b64_encode(data: bytes) -> str:
+ """Base64 URL 安全编码"""
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
+
+def _b64_decode(data: str) -> bytes:
+ """Base64 URL 安全解码"""
+ padding = 4 - len(data) % 4
+ if padding != 4:
+ data += "=" * padding
+ return base64.urlsafe_b64decode(data)
+
+def create_jwt_token(expire_hours: int = None) -> str:
+ """创建 JWT Token"""
+ if expire_hours is None:
+ expire_hours = JWT_EXPIRE_HOURS
+
+ now = int(time.time())
+ payload = {
+ "iat": now,
+ "exp": now + expire_hours * 3600,
+ "type": "admin"
+ }
+
+ header = {"alg": "HS256", "typ": "JWT"}
+ header_b64 = _b64_encode(json.dumps(header).encode())
+ payload_b64 = _b64_encode(json.dumps(payload).encode())
+
+ signature = hmac.new(
+ JWT_SECRET.encode(),
+ f"{header_b64}.{payload_b64}".encode(),
+ hashlib.sha256
+ ).digest()
+ signature_b64 = _b64_encode(signature)
+
+ return f"{header_b64}.{payload_b64}.{signature_b64}"
+
+def verify_jwt_token(token: str) -> dict:
+ """验证 JWT Token,返回 payload 或抛出异常"""
+ try:
+ parts = token.split(".")
+ if len(parts) != 3:
+ raise ValueError("Invalid token format")
+
+ header_b64, payload_b64, signature_b64 = parts
+
+ # 验证签名
+ expected_sig = hmac.new(
+ JWT_SECRET.encode(),
+ f"{header_b64}.{payload_b64}".encode(),
+ hashlib.sha256
+ ).digest()
+
+ actual_sig = _b64_decode(signature_b64)
+ if not hmac.compare_digest(expected_sig, actual_sig):
+ raise ValueError("Invalid signature")
+
+ # 解析 payload
+ payload = json.loads(_b64_decode(payload_b64))
+
+ # 检查过期
+ if payload.get("exp", 0) < time.time():
+ raise ValueError("Token expired")
+
+ return payload
+ except Exception as e:
+ raise ValueError(f"Token verification failed: {e}")
+
+
+# ----------------------------------------------------------------------
+# 登录端点
+# ----------------------------------------------------------------------
+@router.post("/login")
+async def admin_login(request: Request):
+ """管理员登录,返回 JWT Token"""
+ try:
+ data = await request.json()
+ except:
+ data = {}
+
+ admin_key = data.get("admin_key", "")
+
+ # 开发模式:未配置 ADMIN_KEY 时允许任意登录
+ if not ADMIN_KEY:
+ token = create_jwt_token()
+ return JSONResponse(content={
+ "success": True,
+ "token": token,
+ "expires_in": JWT_EXPIRE_HOURS * 3600,
+ "message": "开发模式:未配置 ADMIN_KEY"
+ })
+
+ # 验证 admin key
+ if admin_key != ADMIN_KEY:
+ raise HTTPException(status_code=401, detail="管理密钥错误")
+
+ token = create_jwt_token()
+ return JSONResponse(content={
+ "success": True,
+ "token": token,
+ "expires_in": JWT_EXPIRE_HOURS * 3600,
+ })
+
+
+@router.get("/verify")
+async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
+ """验证当前 Token 是否有效"""
+ if not credentials:
+ raise HTTPException(status_code=401, detail="未提供认证信息")
+
+ token = credentials.credentials
+
+ # 先尝试 JWT 验证
+ try:
+ payload = verify_jwt_token(token)
+ return JSONResponse(content={
+ "valid": True,
+ "expires_at": payload.get("exp"),
+ "remaining": payload.get("exp", 0) - int(time.time())
+ })
+ except:
+ pass
+
+ # 回退到直接 admin key 验证(兼容旧方式)
+ if ADMIN_KEY and token == ADMIN_KEY:
+ return JSONResponse(content={"valid": True, "type": "admin_key"})
+
+ raise HTTPException(status_code=401, detail="Token 无效或已过期")
+
+
def verify_admin(credentials: HTTPAuthorizationCredentials = Depends(security)):
- """验证 Admin 权限"""
+ """验证 Admin 权限(支持 JWT 和直接 admin key)"""
if not ADMIN_KEY:
# 未配置 Admin Key,允许访问(开发模式)
return True
- if not credentials or credentials.credentials != ADMIN_KEY:
- raise HTTPException(status_code=401, detail="Invalid admin key")
- return True
+
+ if not credentials:
+ raise HTTPException(status_code=401, detail="未提供认证信息")
+
+ token = credentials.credentials
+
+ # 先尝试 JWT 验证
+ try:
+ verify_jwt_token(token)
+ return True
+ except:
+ pass
+
+ # 回退到直接 admin key 验证
+ if token == ADMIN_KEY:
+ return True
+
+ raise HTTPException(status_code=401, detail="认证失败:Token 无效或已过期")
# ----------------------------------------------------------------------
diff --git a/webui/src/App.jsx b/webui/src/App.jsx
index ef94ee1..c56f265 100644
--- a/webui/src/App.jsx
+++ b/webui/src/App.jsx
@@ -3,6 +3,7 @@ import AccountManager from './components/AccountManager'
import ApiTester from './components/ApiTester'
import BatchImport from './components/BatchImport'
import VercelSync from './components/VercelSync'
+import Login from './components/Login'
const TABS = [
{ id: 'accounts', label: '🔑 账号管理' },
@@ -16,51 +17,154 @@ export default function App() {
const [config, setConfig] = useState({ keys: [], accounts: [] })
const [loading, setLoading] = useState(true)
const [message, setMessage] = useState(null)
+ const [token, setToken] = useState(null)
+ const [authChecking, setAuthChecking] = useState(true)
+
+ // 检查已存储的 Token
+ useEffect(() => {
+ const checkAuth = async () => {
+ // 检查 localStorage 或 sessionStorage
+ const storedToken = localStorage.getItem('ds2api_token') || sessionStorage.getItem('ds2api_token')
+ const expiresAt = parseInt(localStorage.getItem('ds2api_token_expires') || sessionStorage.getItem('ds2api_token_expires') || '0')
+
+ if (storedToken && expiresAt > Date.now()) {
+ // 验证 token 是否有效
+ try {
+ const res = await fetch('/admin/verify', {
+ headers: { 'Authorization': `Bearer ${storedToken}` }
+ })
+ if (res.ok) {
+ setToken(storedToken)
+ } else {
+ // Token 无效,清除
+ localStorage.removeItem('ds2api_token')
+ localStorage.removeItem('ds2api_token_expires')
+ sessionStorage.removeItem('ds2api_token')
+ sessionStorage.removeItem('ds2api_token_expires')
+ }
+ } catch {
+ // 网络错误,保留 token 重试
+ setToken(storedToken)
+ }
+ }
+ setAuthChecking(false)
+ }
+ checkAuth()
+ }, [])
+
+ // 带认证的 fetch
+ const authFetch = async (url, options = {}) => {
+ const headers = {
+ ...options.headers,
+ 'Authorization': `Bearer ${token}`
+ }
+ const res = await fetch(url, { ...options, headers })
+
+ // 401 时自动登出
+ if (res.status === 401) {
+ handleLogout()
+ throw new Error('认证已过期,请重新登录')
+ }
+ return res
+ }
const fetchConfig = async () => {
+ if (!token) return
try {
setLoading(true)
- const res = await fetch('/admin/config')
+ const res = await authFetch('/admin/config')
if (res.ok) {
const data = await res.json()
setConfig(data)
}
} catch (e) {
console.error('获取配置失败:', e)
+ showMessage('error', e.message)
} finally {
setLoading(false)
}
}
useEffect(() => {
- fetchConfig()
- }, [])
+ if (token) {
+ fetchConfig()
+ }
+ }, [token])
const showMessage = (type, text) => {
setMessage({ type, text })
setTimeout(() => setMessage(null), 5000)
}
+ const handleLogin = (newToken) => {
+ setToken(newToken)
+ }
+
+ const handleLogout = () => {
+ setToken(null)
+ localStorage.removeItem('ds2api_token')
+ localStorage.removeItem('ds2api_token_expires')
+ sessionStorage.removeItem('ds2api_token')
+ sessionStorage.removeItem('ds2api_token_expires')
+ }
+
const renderTab = () => {
switch (activeTab) {
case 'accounts':
- return
账号管理 · API 测试 · Vercel 部署
+账号管理 · API 测试 · Vercel 部署
+请输入管理密钥登录
+Session 有效期 24 小时
+