diff --git a/api/auth.py b/api/auth.py
new file mode 100644
index 0000000..cb23c18
--- /dev/null
+++ b/api/auth.py
@@ -0,0 +1,260 @@
+"""Authentication API: password login, JWT session, TOTP 2FA."""
+from __future__ import annotations
+
+import base64
+import hashlib
+import hmac
+import json as _json
+import os
+import secrets
+import struct
+import time
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from pydantic import BaseModel
+
+router = APIRouter(prefix="/auth", tags=["auth"])
+
+_bearer = HTTPBearer(auto_error=False)
+
+
+# ── Config helpers ─────────────────────────────────────────────────────────────
+
+def _cfg():
+ from core.config_store import config_store
+ return config_store
+
+
+# ── JWT (HS256, stdlib only) ───────────────────────────────────────────────────
+
+def _jwt_secret() -> str:
+ env_secret = os.getenv("APP_JWT_SECRET", "")
+ if env_secret:
+ return env_secret
+ stored = _cfg().get("auth_jwt_secret", "")
+ if not stored:
+ stored = secrets.token_hex(32)
+ _cfg().set("auth_jwt_secret", stored)
+ return stored
+
+
+def _b64url_encode(data: bytes) -> str:
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
+
+
+def _b64url_decode(s: str) -> bytes:
+ pad = 4 - len(s) % 4
+ if pad != 4:
+ s += "=" * pad
+ return base64.urlsafe_b64decode(s)
+
+
+def create_token(expire_seconds: int = 86400 * 7) -> str:
+ header = _b64url_encode(_json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
+ payload = _b64url_encode(_json.dumps({
+ "sub": "admin",
+ "exp": int(time.time()) + expire_seconds,
+ "iat": int(time.time()),
+ }).encode())
+ sig = _b64url_encode(
+ hmac.new(_jwt_secret().encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest()
+ )
+ return f"{header}.{payload}.{sig}"
+
+
+def verify_token(token: str) -> dict:
+ try:
+ header, payload, sig = token.split(".")
+ except ValueError:
+ raise HTTPException(status_code=401, detail="无效的令牌")
+ expected = _b64url_encode(
+ hmac.new(_jwt_secret().encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest()
+ )
+ if not hmac.compare_digest(sig, expected):
+ raise HTTPException(status_code=401, detail="令牌签名无效")
+ try:
+ data = _json.loads(_b64url_decode(payload))
+ except Exception:
+ raise HTTPException(status_code=401, detail="令牌格式错误")
+ if data.get("exp", 0) < time.time():
+ raise HTTPException(status_code=401, detail="令牌已过期,请重新登录")
+ return data
+
+
+def require_auth(credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer)) -> None:
+ if credentials is None:
+ raise HTTPException(status_code=401, detail="未认证")
+ verify_token(credentials.credentials)
+
+
+# ── Password ───────────────────────────────────────────────────────────────────
+
+def _hash_pw(password: str) -> str:
+ return hashlib.sha256(password.encode("utf-8")).hexdigest()
+
+
+# ── TOTP (RFC 6238, stdlib only) ───────────────────────────────────────────────
+
+def generate_totp_secret() -> str:
+ return base64.b32encode(secrets.token_bytes(20)).decode()
+
+
+def totp_uri(secret: str, issuer: str = "AccountManager") -> str:
+ from urllib.parse import quote
+ return f"otpauth://totp/{quote(issuer)}?secret={secret}&issuer={quote(issuer)}"
+
+
+def _totp_at(secret: str, counter: int) -> str:
+ key = base64.b32decode(secret.upper())
+ msg = struct.pack(">Q", counter)
+ h = hmac.new(key, msg, hashlib.sha1).digest()
+ offset = h[-1] & 0x0F
+ code = struct.unpack(">I", h[offset:offset + 4])[0] & 0x7FFFFFFF
+ return str(code % 1_000_000).zfill(6)
+
+
+def verify_totp(secret: str, code: str) -> bool:
+ counter = int(time.time()) // 30
+ user_code = str(code).strip().zfill(6)
+ for delta in (-1, 0, 1):
+ if hmac.compare_digest(_totp_at(secret, counter + delta), user_code):
+ return True
+ return False
+
+
+# ── Pending 2FA sessions (in-memory) ──────────────────────────────────────────
+
+_pending_2fa: dict[str, float] = {} # temp_token -> expires_at
+
+
+# ── Schemas ────────────────────────────────────────────────────────────────────
+
+class LoginRequest(BaseModel):
+ password: str
+
+
+class TotpVerifyRequest(BaseModel):
+ temp_token: str
+ code: str
+
+
+class SetupPasswordRequest(BaseModel):
+ password: str
+
+
+class ChangePasswordRequest(BaseModel):
+ current_password: str
+ new_password: str
+
+
+class EnableTotpRequest(BaseModel):
+ secret: str
+ code: str
+
+
+# ── Routes ────────────────────────────────────────────────────────────────────
+
+@router.get("/status")
+def auth_status():
+ cfg = _cfg()
+ return {
+ "has_password": bool(cfg.get("auth_password_hash", "")),
+ "has_totp": bool(cfg.get("auth_totp_secret", "")),
+ }
+
+
+@router.post("/setup")
+def setup_password(body: SetupPasswordRequest):
+ """Set or update password from settings (no auth required, middleware handles protection)."""
+ if not body.password or len(body.password) < 6:
+ raise HTTPException(status_code=400, detail="密码至少需要 6 位")
+ _cfg().set("auth_password_hash", _hash_pw(body.password))
+ token = create_token()
+ return {"ok": True, "access_token": token, "token_type": "bearer"}
+
+
+@router.post("/disable")
+def disable_auth(credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer)):
+ """Disable password protection. Requires auth only if a password is currently set."""
+ cfg = _cfg()
+ if cfg.get("auth_password_hash", ""):
+ if credentials is None:
+ raise HTTPException(status_code=401, detail="未认证")
+ verify_token(credentials.credentials)
+ cfg.set("auth_password_hash", "")
+ cfg.set("auth_totp_secret", "")
+ return {"ok": True}
+
+
+@router.post("/login")
+def login(body: LoginRequest):
+ cfg = _cfg()
+ stored = cfg.get("auth_password_hash", "")
+ if not stored:
+ raise HTTPException(status_code=403, detail="no_password_set")
+ if not hmac.compare_digest(_hash_pw(body.password), stored):
+ raise HTTPException(status_code=401, detail="密码错误")
+ totp_secret = cfg.get("auth_totp_secret", "")
+ if totp_secret:
+ temp = secrets.token_hex(24)
+ _pending_2fa[temp] = time.time() + 300 # 5 min expiry
+ return {"requires_2fa": True, "temp_token": temp}
+ token = create_token()
+ return {"requires_2fa": False, "access_token": token, "token_type": "bearer"}
+
+
+@router.post("/verify-totp")
+def verify_totp_route(body: TotpVerifyRequest):
+ expiry = _pending_2fa.get(body.temp_token)
+ if not expiry or time.time() > expiry:
+ raise HTTPException(status_code=401, detail="临时令牌无效或已过期,请重新登录")
+ cfg = _cfg()
+ secret = cfg.get("auth_totp_secret", "")
+ if not secret:
+ raise HTTPException(status_code=400, detail="2FA 未启用")
+ if not verify_totp(secret, body.code):
+ raise HTTPException(status_code=400, detail="验证码错误")
+ _pending_2fa.pop(body.temp_token, None)
+ token = create_token()
+ return {"access_token": token, "token_type": "bearer"}
+
+
+@router.post("/logout")
+def logout():
+ return {"ok": True}
+
+
+@router.post("/change-password", dependencies=[Depends(require_auth)])
+def change_password(body: ChangePasswordRequest):
+ cfg = _cfg()
+ stored = cfg.get("auth_password_hash", "")
+ if stored and not hmac.compare_digest(_hash_pw(body.current_password), stored):
+ raise HTTPException(status_code=400, detail="当前密码错误")
+ if not body.new_password or len(body.new_password) < 6:
+ raise HTTPException(status_code=400, detail="新密码至少需要 6 位")
+ cfg.set("auth_password_hash", _hash_pw(body.new_password))
+ return {"ok": True}
+
+
+@router.get("/2fa/setup", dependencies=[Depends(require_auth)])
+def setup_2fa():
+ secret = generate_totp_secret()
+ return {"secret": secret, "uri": totp_uri(secret)}
+
+
+@router.post("/2fa/enable", dependencies=[Depends(require_auth)])
+def enable_2fa(body: EnableTotpRequest):
+ if not body.secret or len(body.secret) < 16:
+ raise HTTPException(status_code=400, detail="无效的密钥")
+ if not verify_totp(body.secret, body.code):
+ raise HTTPException(status_code=400, detail="验证码错误,请重试")
+ _cfg().set("auth_totp_secret", body.secret)
+ return {"ok": True}
+
+
+@router.post("/2fa/disable", dependencies=[Depends(require_auth)])
+def disable_2fa():
+ _cfg().set("auth_totp_secret", "")
+ return {"ok": True}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 5fd65d0..1f8d362 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,6 +1,6 @@
import { BrowserRouter, Routes, Route, useLocation, useNavigate } from 'react-router-dom'
import { useState, useEffect } from 'react'
-import { ConfigProvider, Layout, Menu, Button } from 'antd'
+import { App as AntdApp, ConfigProvider, Layout, Menu, Button, Spin } from 'antd'
import {
DashboardOutlined,
UserOutlined,
@@ -9,6 +9,7 @@ import {
SettingOutlined,
SunOutlined,
MoonOutlined,
+ LogoutOutlined,
} from '@ant-design/icons'
import zhCN from 'antd/locale/zh_CN'
import Dashboard from '@/pages/Dashboard'
@@ -17,16 +18,48 @@ import Register from '@/pages/Register'
import Proxies from '@/pages/Proxies'
import Settings from '@/pages/Settings'
import TaskHistory from '@/pages/TaskHistory'
+import Login from '@/pages/Login'
import { darkTheme, lightTheme } from './theme'
+import { clearToken, getToken } from '@/lib/utils'
const { Sider, Content } = Layout
+function ProtectedLayout() {
+ const navigate = useNavigate()
+ const [ready, setReady] = useState(false)
+
+ useEffect(() => {
+ fetch('/api/auth/status')
+ .then(r => r.json())
+ .then(s => {
+ const token = getToken()
+ if (s.has_password && !token) {
+ navigate('/login', { replace: true })
+ } else {
+ setReady(true)
+ }
+ })
+ .catch(() => setReady(true))
+ }, [])
+
+ if (!ready) {
+ return (
+
+
+
+ )
+ }
+
+ return
+}
+
function AppContent() {
const [themeMode, setThemeMode] = useState<'dark' | 'light'>(() =>
(localStorage.getItem('theme') as 'dark' | 'light') || 'dark'
)
const [collapsed, setCollapsed] = useState(false)
const [platforms, setPlatforms] = useState<{ key: string; label: string }[]>([])
+ const [hasPassword, setHasPassword] = useState(false)
const location = useLocation()
const navigate = useNavigate()
@@ -39,6 +72,10 @@ function AppContent() {
localStorage.setItem('theme', themeMode)
}, [themeMode])
+ useEffect(() => {
+ fetch('/api/auth/status').then(r => r.json()).then(s => setHasPassword(s.has_password)).catch(() => {})
+ }, [])
+
useEffect(() => {
fetch('/api/platforms')
.then(r => r.json())
@@ -94,6 +131,7 @@ function AppContent() {
return (
+
+ {hasPassword && (
+ }
+ onClick={() => { clearToken(); navigate('/login') }}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: collapsed ? 'center' : 'space-between',
+ }}
+ >
+ {!collapsed && '退出登录'}
+
+ )}
+
)
}
@@ -187,7 +244,10 @@ function AppContent() {
export default function App() {
return (
-
+
+ } />
+ } />
+
)
}
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
index d5abca8..81cade9 100644
--- a/frontend/src/lib/utils.ts
+++ b/frontend/src/lib/utils.ts
@@ -1,11 +1,42 @@
export const API = '/api'
export const API_BASE = '/api'
+export function getToken(): string {
+ return localStorage.getItem('auth_token') || ''
+}
+
+export function setToken(token: string): void {
+ localStorage.setItem('auth_token', token)
+}
+
+export function clearToken(): void {
+ localStorage.removeItem('auth_token')
+}
+
export async function apiFetch(path: string, opts?: RequestInit) {
+ const token = getToken()
+ const baseHeaders: Record = { 'Content-Type': 'application/json' }
+ if (token) baseHeaders['Authorization'] = `Bearer ${token}`
const res = await fetch(API + path, {
- headers: { 'Content-Type': 'application/json' },
...opts,
+ headers: { ...baseHeaders, ...(opts?.headers as Record || {}) },
})
- if (!res.ok) throw new Error(await res.text())
+ if (res.status === 401) {
+ clearToken()
+ if (window.location.pathname !== '/login') {
+ window.location.href = '/login'
+ }
+ throw new Error('未认证,请重新登录')
+ }
+ if (!res.ok) {
+ const text = await res.text()
+ try {
+ const json = JSON.parse(text)
+ throw new Error(json.detail || text)
+ } catch (e) {
+ if (e instanceof SyntaxError) throw new Error(text)
+ throw e
+ }
+ }
return res.json()
}
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
new file mode 100644
index 0000000..8ebb97c
--- /dev/null
+++ b/frontend/src/pages/Login.tsx
@@ -0,0 +1,161 @@
+import { useState } from 'react'
+import { App, Card, ConfigProvider, Form, Input, Button, Typography } from 'antd'
+import { LockOutlined, SafetyCertificateOutlined, UserOutlined } from '@ant-design/icons'
+import { setToken } from '@/lib/utils'
+import { darkTheme } from '@/theme'
+
+type Step = 'password' | '2fa'
+
+function LoginContent() {
+ const { message } = App.useApp()
+ const [step, setStep] = useState('password')
+ const [tempToken, setTempToken] = useState('')
+ const [loading, setLoading] = useState(false)
+
+ const handleLogin = async (values: { password: string }) => {
+ setLoading(true)
+ try {
+ const res = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ password: values.password }),
+ })
+ const data = await res.json()
+ if (!res.ok) throw new Error(data.detail || '登录失败')
+ if (data.requires_2fa) {
+ setTempToken(data.temp_token)
+ setStep('2fa')
+ } else {
+ setToken(data.access_token)
+ window.location.href = '/'
+ }
+ } catch (e: any) {
+ message.error(e.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleTotp = async (values: { code: string }) => {
+ setLoading(true)
+ try {
+ const res = await fetch('/api/auth/verify-totp', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ temp_token: tempToken, code: values.code }),
+ })
+ const data = await res.json()
+ if (!res.ok) throw new Error(data.detail || '验证失败')
+ setToken(data.access_token)
+ window.location.href = '/'
+ } catch (e: any) {
+ message.error(e.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const cardStyle: React.CSSProperties = {
+ width: 380,
+ boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
+ borderRadius: 12,
+ }
+
+ const wrapStyle: React.CSSProperties = {
+ minHeight: '100vh',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 100%)',
+ }
+
+ if (step === '2fa') {
+ return (
+
+
+
+ 双因素验证
+
+ 请输入验证器 App 中的 6 位验证码
+
+
+ }
+ >
+
+ }
+ placeholder="000000"
+ size="large"
+ maxLength={6}
+ style={{ letterSpacing: 6, textAlign: 'center' }}
+ />
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ Account Manager
+
+ 请输入密码登录
+
+
+ }
+ >
+
+ } placeholder="请输入访问密码" size="large" />
+
+
+
+
+
+
+
+ )
+}
+
+export default function Login() {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
index 59849e1..97e762c 100644
--- a/frontend/src/pages/Settings.tsx
+++ b/frontend/src/pages/Settings.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
-import { Card, Form, Input, Select, Button, message, Tabs, Space, Tag, Typography, Modal } from 'antd'
+import { App, Card, Form, Input, Select, Button, message, Tabs, Space, Tag, Typography, Modal, QRCode } from 'antd'
import {
SaveOutlined,
EyeOutlined,
@@ -11,6 +11,7 @@ import {
CloseCircleOutlined,
SyncOutlined,
PlusOutlined,
+ LockOutlined,
} from '@ant-design/icons'
import { apiFetch } from '@/lib/utils'
@@ -293,6 +294,12 @@ const TAB_ITEMS = [
icon: ,
sections: [],
},
+ {
+ key: 'security',
+ label: '安全',
+ icon: ,
+ sections: [],
+ },
]
interface FieldConfig {
@@ -764,6 +771,250 @@ function IntegrationsPanel() {
)
}
+type TotpSetupState = 'idle' | 'setup'
+
+function SecurityPanel() {
+ const { message: msg } = App.useApp()
+ const [status, setStatus] = useState<{ has_password: boolean; has_totp: boolean } | null>(null)
+ const [loading, setLoading] = useState(false)
+
+ const [enableForm] = Form.useForm()
+ const [pwForm] = Form.useForm()
+ const [codeForm] = Form.useForm()
+
+ const [totpSetupState, setTotpSetupState] = useState('idle')
+ const [totpSecret, setTotpSecret] = useState('')
+ const [totpUri, setTotpUri] = useState('')
+
+ const loadStatus = async () => {
+ try {
+ const s = await apiFetch('/auth/status')
+ setStatus(s)
+ } catch {}
+ }
+
+ useEffect(() => { loadStatus() }, [])
+
+ const handleEnable = async (values: { password: string; confirm: string }) => {
+ if (values.password !== values.confirm) {
+ msg.error('两次输入的密码不一致')
+ return
+ }
+ setLoading(true)
+ try {
+ const d = await apiFetch('/auth/setup', {
+ method: 'POST',
+ body: JSON.stringify({ password: values.password }),
+ })
+ localStorage.setItem('auth_token', d.access_token)
+ msg.success('密码保护已启用')
+ enableForm.resetFields()
+ await loadStatus()
+ } catch (e: any) {
+ msg.error(e.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleDisableAuth = async () => {
+ setLoading(true)
+ try {
+ await apiFetch('/auth/disable', { method: 'POST' })
+ localStorage.removeItem('auth_token')
+ msg.success('密码保护已关闭')
+ await loadStatus()
+ } catch (e: any) {
+ msg.error(e.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleChangePassword = async (values: { current_password: string; new_password: string; confirm: string }) => {
+ if (values.new_password !== values.confirm) {
+ msg.error('两次输入的新密码不一致')
+ return
+ }
+ setLoading(true)
+ try {
+ await apiFetch('/auth/change-password', {
+ method: 'POST',
+ body: JSON.stringify({ current_password: values.current_password, new_password: values.new_password }),
+ })
+ msg.success('密码已更新')
+ pwForm.resetFields()
+ } catch (e: any) {
+ msg.error(e.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleSetupTotp = async () => {
+ setLoading(true)
+ try {
+ const d = await apiFetch('/auth/2fa/setup')
+ setTotpSecret(d.secret)
+ setTotpUri(d.uri)
+ setTotpSetupState('setup')
+ } catch (e: any) {
+ msg.error(e.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleEnableTotp = async (values: { code: string }) => {
+ setLoading(true)
+ try {
+ await apiFetch('/auth/2fa/enable', {
+ method: 'POST',
+ body: JSON.stringify({ secret: totpSecret, code: values.code }),
+ })
+ msg.success('双因素认证已启用')
+ setTotpSetupState('idle')
+ codeForm.resetFields()
+ await loadStatus()
+ } catch (e: any) {
+ msg.error(e.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleDisableTotp = async () => {
+ setLoading(true)
+ try {
+ await apiFetch('/auth/2fa/disable', { method: 'POST' })
+ msg.success('双因素认证已关闭')
+ await loadStatus()
+ } catch (e: any) {
+ msg.error(e.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
已启用
+ : 未启用
+ }
+ >
+ {!status?.has_password ? (
+
+
+ 启用后,访问页面需要输入密码。默认不开启,任何能访问此地址的人均可使用。
+
+
+
+
+
+
+
+
+ }>
+ 启用密码保护
+
+
+
+
+ ) : (
+
+ 当前已启用密码保护,关闭后任何人无需密码即可访问。
+
+
+ )}
+
+
+ {status?.has_password && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ }>
+ 更新密码
+
+
+
+
+
+
已启用
+ : 未启用
+ }
+ >
+ {status?.has_totp ? (
+
+
+ 登录时需输入 Google Authenticator / Authy 等 App 中的 6 位验证码。
+
+
+
+ ) : totpSetupState === 'idle' ? (
+
+
+ 启用后,登录时除密码外还需输入验证器 App 中的 6 位验证码,大幅提升安全性。
+
+ }>
+ 开启双因素认证
+
+
+ ) : (
+
+ 1. 用验证器 App 扫描下方二维码
+
+
+
+ 无法扫码?手动输入密钥:
+
+ {totpSecret}
+
+
+
+ 2. 输入 App 中显示的 6 位验证码以确认绑定
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ >
+ )}
+
+ )
+}
+
export default function Settings() {
const [form] = Form.useForm()
const [saving, setSaving] = useState(false)
@@ -847,6 +1098,8 @@ export default function Settings() {
{activeTab === 'integrations' ? (
+ ) : activeTab === 'security' ? (
+
) : (