mirror of
https://github.com/zc-zhangchen/any-auto-register.git
synced 2026-05-07 15:54:07 +08:00
feat: 添加访问密码保护与双因素认证(2FA)
This commit is contained in:
260
api/auth.py
Normal file
260
api/auth.py
Normal 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}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
161
frontend/src/pages/Login.tsx
Normal file
161
frontend/src/pages/Login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
27
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")
|
||||
|
||||
Reference in New Issue
Block a user