feat: 添加访问密码保护与双因素认证(2FA)

This commit is contained in:
ljh
2026-04-01 10:22:29 +08:00
parent b2646edde2
commit 85387f421e
6 changed files with 796 additions and 8 deletions

260
api/auth.py Normal file
View File

@@ -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}

View File

@@ -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 (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
</div>
)
}
return <AppContent />
}
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 (
<ConfigProvider theme={currentTheme} locale={zhCN}>
<AntdApp>
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
@@ -142,10 +180,13 @@ function AppContent() {
<div
style={{
position: 'absolute',
bottom: 16,
bottom: 56,
left: 0,
right: 0,
padding: '0 16px',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<Button
@@ -160,6 +201,21 @@ function AppContent() {
>
{!collapsed && (isLight ? '亮色模式' : '暗色模式')}
</Button>
{hasPassword && (
<Button
block
danger
icon={<LogoutOutlined />}
onClick={() => { clearToken(); navigate('/login') }}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'space-between',
}}
>
{!collapsed && '退出登录'}
</Button>
)}
</div>
</Sider>
<Content
@@ -180,6 +236,7 @@ function AppContent() {
</Routes>
</Content>
</Layout>
</AntdApp>
</ConfigProvider>
)
}
@@ -187,7 +244,10 @@ function AppContent() {
export default function App() {
return (
<BrowserRouter>
<AppContent />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/*" element={<ProtectedLayout />} />
</Routes>
</BrowserRouter>
)
}

View File

@@ -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<string, string> = { '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<string, string> || {}) },
})
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()
}

View File

@@ -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<Step>('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 (
<div style={wrapStyle}>
<Card
style={cardStyle}
title={
<div style={{ textAlign: 'center', padding: '8px 0' }}>
<SafetyCertificateOutlined style={{ fontSize: 28, color: '#6366f1', marginBottom: 8, display: 'block' }} />
<div style={{ fontSize: 18, fontWeight: 700 }}></div>
<Typography.Text type="secondary" style={{ fontSize: 13, fontWeight: 400 }}>
App 6
</Typography.Text>
</div>
}
>
<Form layout="vertical" onFinish={handleTotp} requiredMark={false}>
<Form.Item
name="code"
label="验证码"
rules={[
{ required: true, message: '请输入验证码' },
{ len: 6, message: '验证码为 6 位数字' },
]}
>
<Input
prefix={<SafetyCertificateOutlined />}
placeholder="000000"
size="large"
maxLength={6}
style={{ letterSpacing: 6, textAlign: 'center' }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button type="primary" htmlType="submit" block size="large" loading={loading}>
</Button>
</Form.Item>
<div style={{ textAlign: 'center', marginTop: 12 }}>
<Button type="link" size="small" onClick={() => setStep('password')}>
</Button>
</div>
</Form>
</Card>
</div>
)
}
return (
<div style={wrapStyle}>
<Card
style={cardStyle}
title={
<div style={{ textAlign: 'center', padding: '8px 0', background: 'transparent' }}>
<UserOutlined style={{ fontSize: 28, color: '#6366f1', marginBottom: 8, display: 'block' }} />
<div style={{ fontSize: 18, fontWeight: 700 }}>Account Manager</div>
<Typography.Text type="secondary" style={{ fontSize: 13, fontWeight: 400 }}>
</Typography.Text>
</div>
}
>
<Form layout="vertical" onFinish={handleLogin} requiredMark={false}>
<Form.Item
name="password"
label="密码"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="请输入访问密码" size="large" />
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button type="primary" htmlType="submit" block size="large" loading={loading}>
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default function Login() {
return (
<ConfigProvider theme={darkTheme}>
<App>
<LoginContent />
</App>
</ConfigProvider>
)
}

View File

@@ -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: <ApiOutlined />,
sections: [],
},
{
key: 'security',
label: '安全',
icon: <LockOutlined />,
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<TotpSetupState>('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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card
title="访问密码保护"
extra={
status?.has_password
? <Tag color="green"><CheckCircleOutlined /> </Tag>
: <Tag color="default"><CloseCircleOutlined /> </Tag>
}
>
{!status?.has_password ? (
<Space direction="vertical" style={{ width: '100%' }}>
<Typography.Text type="secondary">
访访使
</Typography.Text>
<Form form={enableForm} layout="vertical" onFinish={handleEnable} requiredMark={false} style={{ maxWidth: 360, marginTop: 8 }}>
<Form.Item name="password" label="设置访问密码" rules={[{ required: true, message: '请输入密码' }, { min: 6, message: '至少 6 位' }]}>
<Input.Password placeholder="至少 6 位" />
</Form.Item>
<Form.Item name="confirm" label="确认密码" rules={[{ required: true, message: '请再次输入' }]}>
<Input.Password placeholder="再次输入密码" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={loading} icon={<LockOutlined />}>
</Button>
</Form.Item>
</Form>
</Space>
) : (
<Space direction="vertical" style={{ width: '100%' }}>
<Typography.Text type="secondary">访</Typography.Text>
<Button danger loading={loading} onClick={handleDisableAuth}>
</Button>
</Space>
)}
</Card>
{status?.has_password && (
<>
<Card title="修改密码">
<Form form={pwForm} layout="vertical" onFinish={handleChangePassword} requiredMark={false} style={{ maxWidth: 360 }}>
<Form.Item name="current_password" label="当前密码" rules={[{ required: true, message: '请输入当前密码' }]}>
<Input.Password placeholder="当前密码" />
</Form.Item>
<Form.Item name="new_password" label="新密码" rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '至少 6 位' }]}>
<Input.Password placeholder="新密码(至少 6 位)" />
</Form.Item>
<Form.Item name="confirm" label="确认新密码" rules={[{ required: true, message: '请再次输入' }]}>
<Input.Password placeholder="再次输入新密码" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={loading} icon={<SaveOutlined />}>
</Button>
</Form.Item>
</Form>
</Card>
<Card
title="双因素认证 (2FA)"
extra={
status?.has_totp
? <Tag color="green"><CheckCircleOutlined /> </Tag>
: <Tag color="default"><CloseCircleOutlined /> </Tag>
}
>
{status?.has_totp ? (
<Space direction="vertical">
<Typography.Text type="secondary">
Google Authenticator / Authy App 6
</Typography.Text>
<Button danger loading={loading} onClick={handleDisableTotp}>
</Button>
</Space>
) : totpSetupState === 'idle' ? (
<Space direction="vertical">
<Typography.Text type="secondary">
App 6
</Typography.Text>
<Button type="primary" loading={loading} onClick={handleSetupTotp} icon={<SafetyOutlined />}>
</Button>
</Space>
) : (
<Space direction="vertical" style={{ width: '100%' }}>
<Typography.Text strong>1. App </Typography.Text>
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<QRCode value={totpUri} size={180} />
<div style={{ flex: 1 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
<Typography.Paragraph copyable style={{ fontFamily: 'monospace', fontSize: 13, marginTop: 4 }}>
{totpSecret}
</Typography.Paragraph>
</div>
</div>
<Typography.Text strong>2. App 6 </Typography.Text>
<Form form={codeForm} layout="inline" onFinish={handleEnableTotp}>
<Form.Item name="code" rules={[{ required: true, message: '请输入验证码' }, { len: 6, message: '6 位数字' }]}>
<Input placeholder="000000" maxLength={6} style={{ width: 140, letterSpacing: 4, textAlign: 'center' }} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading}></Button>
</Form.Item>
<Form.Item>
<Button onClick={() => setTotpSetupState('idle')}></Button>
</Form.Item>
</Form>
</Space>
)}
</Card>
</>
)}
</div>
)
}
export default function Settings() {
const [form] = Form.useForm()
const [saving, setSaving] = useState(false)
@@ -847,6 +1098,8 @@ export default function Settings() {
<div style={{ flex: 1 }}>
{activeTab === 'integrations' ? (
<IntegrationsPanel />
) : activeTab === 'security' ? (
<SecurityPanel />
) : (
<Form form={form} layout="vertical">
{activeTab === 'captcha' ? <SolverStatus /> : null}

27
main.py
View File

@@ -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")