From 85387f421e5b70353856792879a4a73a809bd9b2 Mon Sep 17 00:00:00 2001 From: ljh Date: Wed, 1 Apr 2026 10:22:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E4=BF=9D=E6=8A=A4=E4=B8=8E=E5=8F=8C=E5=9B=A0?= =?UTF-8?q?=E7=B4=A0=E8=AE=A4=E8=AF=81(2FA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/auth.py | 260 ++++++++++++++++++++++++++++++++ frontend/src/App.tsx | 66 +++++++- frontend/src/lib/utils.ts | 35 ++++- frontend/src/pages/Login.tsx | 161 ++++++++++++++++++++ frontend/src/pages/Settings.tsx | 255 ++++++++++++++++++++++++++++++- main.py | 27 +++- 6 files changed, 796 insertions(+), 8 deletions(-) create mode 100644 api/auth.py create mode 100644 frontend/src/pages/Login.tsx 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 && ( + + )} + ) } @@ -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' ? ( + ) : (
{activeTab === 'captcha' ? : null} diff --git a/main.py b/main.py index 2c31e06..f710543 100644 --- a/main.py +++ b/main.py @@ -2,10 +2,11 @@ import os import sys from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi import HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, JSONResponse from core.db import init_db from core.registry import load_all from api.accounts import router as accounts_router @@ -15,6 +16,7 @@ from api.proxies import router as proxies_router from api.config import router as config_router from api.actions import router as actions_router from api.integrations import router as integrations_router +from api.auth import router as auth_router EXPECTED_CONDA_ENV = os.getenv("APP_CONDA_ENV", "any-auto-register") @@ -71,6 +73,26 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Account Manager", version="1.0.0", lifespan=lifespan) + +@app.middleware("http") +async def auth_middleware(request: Request, call_next): + path = request.url.path + if path.startswith("/api/auth/") or not path.startswith("/api/"): + return await call_next(request) + from core.config_store import config_store as _cs + if not _cs.get("auth_password_hash", ""): + return await call_next(request) + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return JSONResponse({"detail": "未认证,请先登录"}, status_code=401) + try: + from api.auth import verify_token + verify_token(auth_header[7:]) + except HTTPException as e: + return JSONResponse({"detail": e.detail}, status_code=e.status_code) + return await call_next(request) + + app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -85,6 +107,7 @@ app.include_router(proxies_router, prefix="/api") app.include_router(config_router, prefix="/api") app.include_router(actions_router, prefix="/api") app.include_router(integrations_router, prefix="/api") +app.include_router(auth_router, prefix="/api") @app.get("/api/solver/status")