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 + return case 'test': - return + return case 'import': - return + return case 'vercel': - return + return default: return null } } + // 认证检查中 + if (authChecking) { + return ( +
+
+
+
+ 检查登录状态... +
+
+
+
+ ) + } + + // 未登录 + if (!token) { + return ( +
+ {message && ( +
+ {message.text} +
+ )} + +
+ ) + } + + // 已登录 return (
-

DS2API Admin

-

账号管理 · API 测试 · Vercel 部署

+
+
+

DS2API Admin

+

账号管理 · API 测试 · Vercel 部署

+
+ +
{message && ( @@ -104,3 +208,4 @@ export default function App() {
) } + diff --git a/webui/src/components/AccountManager.jsx b/webui/src/components/AccountManager.jsx index 838418f..e88daea 100644 --- a/webui/src/components/AccountManager.jsx +++ b/webui/src/components/AccountManager.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' -export default function AccountManager({ config, onRefresh, onMessage }) { +export default function AccountManager({ config, onRefresh, onMessage, authFetch }) { const [showAddKey, setShowAddKey] = useState(false) const [showAddAccount, setShowAddAccount] = useState(false) const [newKey, setNewKey] = useState('') @@ -13,10 +13,13 @@ export default function AccountManager({ config, onRefresh, onMessage }) { const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] }) const [queueStatus, setQueueStatus] = useState(null) + // 使用 authFetch 或回退到普通 fetch + const apiFetch = authFetch || fetch + // 获取队列状态 const fetchQueueStatus = async () => { try { - const res = await fetch('/admin/queue/status') + const res = await apiFetch('/admin/queue/status') if (res.ok) { const data = await res.json() setQueueStatus(data) @@ -36,7 +39,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) { if (!newKey.trim()) return setLoading(true) try { - const res = await fetch('/admin/keys', { + const res = await apiFetch('/admin/keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: newKey.trim() }), @@ -60,7 +63,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) { const deleteKey = async (key) => { if (!confirm('确定删除此 API Key?')) return try { - const res = await fetch(`/admin/keys/${encodeURIComponent(key)}`, { method: 'DELETE' }) + const res = await apiFetch(`/admin/keys/${encodeURIComponent(key)}`, { method: 'DELETE' }) if (res.ok) { onMessage('success', '删除成功') onRefresh() @@ -79,7 +82,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) { } setLoading(true) try { - const res = await fetch('/admin/accounts', { + const res = await apiFetch('/admin/accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newAccount), @@ -103,7 +106,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) { const deleteAccount = async (id) => { if (!confirm('确定删除此账号?')) return try { - const res = await fetch(`/admin/accounts/${encodeURIComponent(id)}`, { method: 'DELETE' }) + const res = await apiFetch(`/admin/accounts/${encodeURIComponent(id)}`, { method: 'DELETE' }) if (res.ok) { onMessage('success', '删除成功') onRefresh() @@ -119,7 +122,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) { const validateAccount = async (identifier) => { setValidating(prev => ({ ...prev, [identifier]: true })) try { - const res = await fetch('/admin/accounts/validate', { + const res = await apiFetch('/admin/accounts/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier }), @@ -155,7 +158,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) { const id = acc.email || acc.mobile try { - const res = await fetch('/admin/accounts/validate', { + const res = await apiFetch('/admin/accounts/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: id }), @@ -179,7 +182,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) { const testAccount = async (identifier) => { setTesting(prev => ({ ...prev, [identifier]: true })) try { - const res = await fetch('/admin/accounts/test', { + const res = await apiFetch('/admin/accounts/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier }), @@ -215,7 +218,7 @@ export default function AccountManager({ config, onRefresh, onMessage }) { const id = acc.email || acc.mobile try { - const res = await fetch('/admin/accounts/test', { + const res = await apiFetch('/admin/accounts/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: id }), diff --git a/webui/src/components/ApiTester.jsx b/webui/src/components/ApiTester.jsx index 9a7b71e..0aa39d0 100644 --- a/webui/src/components/ApiTester.jsx +++ b/webui/src/components/ApiTester.jsx @@ -7,7 +7,7 @@ const MODELS = [ { id: 'deepseek-reasoner-search', name: 'deepseek-reasoner-search' }, ] -export default function ApiTester({ config, onMessage }) { +export default function ApiTester({ config, onMessage, authFetch }) { const [model, setModel] = useState('deepseek-chat') const [message, setMessage] = useState('你好,请用一句话介绍你自己。') const [apiKey, setApiKey] = useState('') @@ -15,6 +15,9 @@ export default function ApiTester({ config, onMessage }) { const [response, setResponse] = useState(null) const [loading, setLoading] = useState(false) + // 使用 authFetch 或回退到普通 fetch(admin API 用 authFetch,OpenAI 兼容 API 用普通 fetch) + const apiFetch = authFetch || fetch + // 获取账号列表 const accounts = config.accounts || [] @@ -22,7 +25,7 @@ export default function ApiTester({ config, onMessage }) { setLoading(true) setResponse(null) try { - const res = await fetch('/admin/test', { + const res = await apiFetch('/admin/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -96,7 +99,7 @@ export default function ApiTester({ config, onMessage }) { // 如果选择了指定账号,使用账号测试接口 if (selectedAccount) { try { - const res = await fetch('/admin/accounts/test', { + const res = await apiFetch('/admin/accounts/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/webui/src/components/BatchImport.jsx b/webui/src/components/BatchImport.jsx index f853cd2..d9f3723 100644 --- a/webui/src/components/BatchImport.jsx +++ b/webui/src/components/BatchImport.jsx @@ -51,11 +51,14 @@ const TEMPLATES = { } } -export default function BatchImport({ onRefresh, onMessage }) { +export default function BatchImport({ onRefresh, onMessage, authFetch }) { const [jsonInput, setJsonInput] = useState('') const [loading, setLoading] = useState(false) const [result, setResult] = useState(null) + // 使用 authFetch 或回退到普通 fetch + const apiFetch = authFetch || fetch + const handleImport = async () => { if (!jsonInput.trim()) { onMessage('error', '请输入 JSON 配置') @@ -73,7 +76,7 @@ export default function BatchImport({ onRefresh, onMessage }) { setLoading(true) setResult(null) try { - const res = await fetch('/admin/import', { + const res = await apiFetch('/admin/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), @@ -103,7 +106,7 @@ export default function BatchImport({ onRefresh, onMessage }) { const handleExport = async () => { try { - const res = await fetch('/admin/export') + const res = await apiFetch('/admin/export') if (res.ok) { const data = await res.json() setJsonInput(JSON.stringify(JSON.parse(data.json), null, 2)) @@ -116,7 +119,7 @@ export default function BatchImport({ onRefresh, onMessage }) { const copyBase64 = async () => { try { - const res = await fetch('/admin/export') + const res = await apiFetch('/admin/export') if (res.ok) { const data = await res.json() await navigator.clipboard.writeText(data.base64) diff --git a/webui/src/components/Login.jsx b/webui/src/components/Login.jsx new file mode 100644 index 0000000..bbd9d7c --- /dev/null +++ b/webui/src/components/Login.jsx @@ -0,0 +1,90 @@ +import { useState } from 'react' + +export default function Login({ onLogin, onMessage }) { + const [adminKey, setAdminKey] = useState('') + const [loading, setLoading] = useState(false) + const [remember, setRemember] = useState(true) + + const handleLogin = async (e) => { + e.preventDefault() + setLoading(true) + + try { + const res = await fetch('/admin/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ admin_key: adminKey }), + }) + + const data = await res.json() + + if (res.ok && data.success) { + // 存储 token + const storage = remember ? localStorage : sessionStorage + storage.setItem('ds2api_token', data.token) + storage.setItem('ds2api_token_expires', Date.now() + data.expires_in * 1000) + + onLogin(data.token) + if (data.message) { + onMessage('warning', data.message) + } + } else { + onMessage('error', data.detail || '登录失败') + } + } catch (e) { + onMessage('error', '网络错误: ' + e.message) + } finally { + setLoading(false) + } + } + + return ( +
+
+
+

🔐 DS2API Admin

+

请输入管理密钥登录

+
+ +
+
+ + setAdminKey(e.target.value)} + autoFocus + /> +
+ +
+ setRemember(e.target.checked)} + /> + +
+ + +
+ +
+

Session 有效期 24 小时

+
+
+
+ ) +} diff --git a/webui/src/components/VercelSync.jsx b/webui/src/components/VercelSync.jsx index 808f7e3..a95fb9f 100644 --- a/webui/src/components/VercelSync.jsx +++ b/webui/src/components/VercelSync.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' -export default function VercelSync({ onMessage }) { +export default function VercelSync({ onMessage, authFetch }) { const [vercelToken, setVercelToken] = useState('') const [projectId, setProjectId] = useState('') const [teamId, setTeamId] = useState('') @@ -8,11 +8,14 @@ export default function VercelSync({ onMessage }) { const [result, setResult] = useState(null) const [preconfig, setPreconfig] = useState(null) + // 使用 authFetch 或回退到普通 fetch + const apiFetch = authFetch || fetch + // 自动加载预配置的 Vercel 信息 useEffect(() => { const loadPreconfig = async () => { try { - const res = await fetch('/admin/vercel/config') + const res = await apiFetch('/admin/vercel/config') if (res.ok) { const data = await res.json() setPreconfig(data) @@ -42,7 +45,7 @@ export default function VercelSync({ onMessage }) { setLoading(true) setResult(null) try { - const res = await fetch('/admin/vercel/sync', { + const res = await apiFetch('/admin/vercel/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/webui/src/styles.css b/webui/src/styles.css index 5feb921..af2f865 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -505,6 +505,46 @@ textarea.form-input { color: var(--error); } +/* Login Page */ +.login-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 70vh; +} + +.login-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2.5rem; + width: 100%; + max-width: 400px; + box-shadow: var(--shadow); +} + +.login-header { + text-align: center; + margin-bottom: 2rem; +} + +.login-header h1 { + font-size: 1.75rem; + margin-bottom: 0.5rem; +} + +.login-header p { + color: var(--text-secondary); + font-size: 0.9rem; +} + +.login-footer { + margin-top: 1.5rem; + text-align: center; + color: var(--text-secondary); + font-size: 0.8rem; +} + @media (max-width: 640px) { .app { padding: 1rem;