mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
feat: 新增访问密码认证(首次本机设密码 + 公网防护)
- 后端: PBKDF2 密码哈希 + 会话 token(auth.json 存储, 零数据库) - 中间件: 未设密码时本机可设/公网拒绝(防裸奔+防抢占), 已设密码后 401 拦截 - 5个 API: status/setup/login/logout/change-password, 含本机限制+登录限流 - 前端: 登录页/设密码页 + 全局 401/403 拦截跳转 - 安全: HttpOnly cookie + 5次失败锁5分钟 + 改密码清会话
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
"""访问认证 API。
|
||||
|
||||
端点:
|
||||
GET /api/auth/status — 是否已设密码、当前会话是否有效
|
||||
POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占)
|
||||
POST /api/auth/login — 登录(密码 → 会话 token, 含限流)
|
||||
POST /api/auth/logout — 注销当前会话
|
||||
POST /api/auth/change-password — 改密码(需已登录)
|
||||
|
||||
安全:
|
||||
- setup 端点只接受本机/内网请求(request.client.host), 公网请求 403。
|
||||
否则黑客可比用户更早扫到域名, 抢先设密码, 反客为主。
|
||||
- login 限流: 同一来源 IP 连续失败 5 次, 锁 5 分钟(内存计数)。
|
||||
- 会话 token 通过 HttpOnly cookie 下发, 前端无需手动管理。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from threading import Lock
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.services import auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
COOKIE_NAME = "tf_session"
|
||||
_COOKIE_MAX_AGE = 30 * 24 * 3600 # 与 SESSION_TTL 一致
|
||||
|
||||
# 限流: { ip: (fail_count, lock_until_ts) }
|
||||
_fail_counter: dict[str, tuple[int, float]] = defaultdict(lambda: (0, 0.0))
|
||||
_fail_lock = Lock()
|
||||
_MAX_FAILS = 5
|
||||
_LOCK_SECONDS = 300
|
||||
|
||||
|
||||
def _is_local_network(host: str | None) -> bool:
|
||||
"""是否本机或内网请求。
|
||||
|
||||
反向代理(Nginx)场景下 request.client.host 是代理本身(127.0.0.1),
|
||||
需信任 X-Forwarded-For 的最左(原始客户端)。本项目部署若经反代,
|
||||
请在反代配置正确的 X-Forwarded-For(标准做法)。
|
||||
"""
|
||||
if not host:
|
||||
return False
|
||||
if host in ("127.0.0.1", "::1", "localhost"):
|
||||
return True
|
||||
# 内网网段: 10.x / 172.16-31.x / 192.168.x
|
||||
if host.startswith("10.") or host.startswith("192.168."):
|
||||
return True
|
||||
if host.startswith("172."):
|
||||
try:
|
||||
second = int(host.split(".")[1])
|
||||
if 16 <= second <= 31:
|
||||
return True
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""取真实客户端 IP(信任反代 X-Forwarded-For)。"""
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _check_login_rate_limit(ip: str) -> None:
|
||||
"""登录失败限流检查, 触发则抛 429。"""
|
||||
with _fail_lock:
|
||||
count, until = _fail_counter.get(ip, (0, 0.0))
|
||||
now = time.time()
|
||||
if until > now:
|
||||
wait = int(until - now)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"登录失败次数过多, 请 {wait} 秒后重试",
|
||||
)
|
||||
|
||||
|
||||
def _record_login_fail(ip: str) -> None:
|
||||
"""记录一次登录失败, 达阈值则锁定。"""
|
||||
with _fail_lock:
|
||||
count, until = _fail_counter.get(ip, (0, 0.0))
|
||||
count += 1
|
||||
if count >= _MAX_FAILS:
|
||||
until = time.time() + _LOCK_SECONDS
|
||||
logger.warning("auth login locked for %s after %d fails", ip, count)
|
||||
_fail_counter[ip] = (count, until)
|
||||
|
||||
|
||||
def _clear_login_fails(ip: str) -> None:
|
||||
"""登录成功后清除该 IP 的失败计数。"""
|
||||
with _fail_lock:
|
||||
_fail_counter.pop(ip, None)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 端点
|
||||
# ================================================================
|
||||
|
||||
class PasswordIn(BaseModel):
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class ChangePasswordIn(BaseModel):
|
||||
old_password: str = Field(min_length=1, max_length=128)
|
||||
new_password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def auth_status(request: Request) -> dict:
|
||||
"""认证状态: 是否已设密码 + 当前请求是否已登录。"""
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
return {
|
||||
"configured": auth.is_configured(),
|
||||
"authenticated": bool(token and auth.is_valid_session(token)),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/setup")
|
||||
def setup_password(req: PasswordIn, request: Request) -> dict:
|
||||
"""首次设置访问密码。仅限本机/内网请求(防公网抢占)。
|
||||
|
||||
若已设置过密码, 返回 409(改密码走 /change-password)。
|
||||
"""
|
||||
# 关键: 限制只有服务器主人(本机/内网)能设密码
|
||||
client_ip = _client_ip(request)
|
||||
if not _is_local_network(client_ip):
|
||||
logger.warning("setup rejected from non-local ip: %s", client_ip)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="首次设置密码仅允许本机或内网访问,请通过 SSH/本地浏览器操作",
|
||||
)
|
||||
|
||||
if auth.is_configured():
|
||||
raise HTTPException(status_code=409, detail="密码已设置,如需修改请登录后使用改密码功能")
|
||||
|
||||
auth.set_password(req.password)
|
||||
logger.info("access password set up from %s", client_ip)
|
||||
return {"ok": True, "configured": True}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(req: LoginIn, request: Request, response: Response) -> dict:
|
||||
"""登录: 密码 → 会话 token(写 HttpOnly cookie)。含失败限流。"""
|
||||
ip = _client_ip(request)
|
||||
_check_login_rate_limit(ip)
|
||||
|
||||
if not auth.is_configured():
|
||||
raise HTTPException(status_code=409, detail="尚未设置访问密码")
|
||||
|
||||
token = auth.verify_and_create_session(req.password)
|
||||
if not token:
|
||||
_record_login_fail(ip)
|
||||
raise HTTPException(status_code=401, detail="密码错误")
|
||||
|
||||
_clear_login_fails(ip)
|
||||
# HttpOnly: 防 XSS 窃取; SameSite=Lax: 防 CSRF; Path=/: 全站生效
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=_COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
secure=False, # 自托管可能无 HTTPS, 不强制 secure(建议反代加 HTTPS)
|
||||
)
|
||||
return {"ok": True, "authenticated": True}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request, response: Response) -> dict:
|
||||
"""注销当前会话。"""
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if token:
|
||||
auth.revoke_session(token)
|
||||
response.delete_cookie(key=COOKIE_NAME, path="/")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
def change_password(req: ChangePasswordIn, request: Request) -> dict:
|
||||
"""修改密码: 需验证旧密码, 成功后所有会话失效(含当前, 需重新登录)。"""
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not (token and auth.is_valid_session(token)):
|
||||
raise HTTPException(status_code=401, detail="请先登录")
|
||||
|
||||
if not auth.is_configured():
|
||||
raise HTTPException(status_code=409, detail="尚未设置访问密码")
|
||||
|
||||
# 验证旧密码
|
||||
new_token = auth.verify_and_create_session(req.old_password)
|
||||
if not new_token:
|
||||
ip = _client_ip(request)
|
||||
_record_login_fail(ip)
|
||||
raise HTTPException(status_code=401, detail="旧密码错误")
|
||||
# 临时 token 用完即弃
|
||||
auth.revoke_session(new_token)
|
||||
|
||||
# 改密码(set_password 会清空所有会话)
|
||||
auth.set_password(req.new_password)
|
||||
return {"ok": True, "message": "密码已修改, 请重新登录"}
|
||||
+50
-3
@@ -5,13 +5,13 @@ import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app import __version__
|
||||
from app.api import analysis, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist
|
||||
from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist
|
||||
from app.api.routes import router as core_router
|
||||
from app.config import settings
|
||||
from app.jobs import daily_pipeline
|
||||
@@ -186,8 +186,55 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 访问认证中间件
|
||||
# ================================================================
|
||||
# 拦截所有 /api/ 请求, 三种状态:
|
||||
# 1. 未设密码 + 本机/内网 → 放行(让本机用户访问面板 + 调 /api/auth/setup 设密码)
|
||||
# 2. 未设密码 + 公网 → 拒绝(403, 防裸奔也防抢占; 引导本机设密码)
|
||||
# 3. 已设密码 → 检查 session, 无效则 401(前端跳登录)
|
||||
# 白名单: /api/auth/* (设密码/登录本身)、/health 等探活。
|
||||
_AUTH_WHITELIST_PREFIX = ("/api/auth/",)
|
||||
_AUTH_WHITELIST_EXACT = ("/health", "/api/health", "/openapi.json", "/docs", "/redoc")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth_middleware(request: Request, call_next):
|
||||
path = request.url.path
|
||||
# 仅 /api/ 走认证; 静态资源(前端页面/assets)放行, 由前端处理跳转
|
||||
if not path.startswith("/api/"):
|
||||
return await call_next(request)
|
||||
# 白名单放行(设密码/登录/探活本身不拦)
|
||||
if path.startswith(_AUTH_WHITELIST_PREFIX) or path in _AUTH_WHITELIST_EXACT:
|
||||
return await call_next(request)
|
||||
|
||||
from app.services import auth as auth_service
|
||||
# 情况 1+2: 未设密码
|
||||
if not auth_service.is_configured():
|
||||
# 本机/内网 → 放行(服务器主人可访问, 并去 /login 设密码)
|
||||
if auth_api._is_local_network(auth_api._client_ip(request)):
|
||||
return await call_next(request)
|
||||
# 公网 → 拒绝。不裸奔, 也不给公网设密码的机会(防抢占)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"detail": "面板尚未初始化访问密码,请通过 SSH/本机浏览器访问以设置密码",
|
||||
"code": "NOT_INITIALIZED",
|
||||
},
|
||||
)
|
||||
|
||||
# 情况 3: 已设密码, 检查会话
|
||||
token = request.cookies.get(auth_api.COOKIE_NAME)
|
||||
if token and auth_service.is_valid_session(token):
|
||||
return await call_next(request)
|
||||
# 未登录: 401(前端跳登录页)
|
||||
return JSONResponse(status_code=401, content={"detail": "未登录或会话已过期"})
|
||||
|
||||
|
||||
# 路由
|
||||
app.include_router(core_router)
|
||||
app.include_router(auth_api.router)
|
||||
app.include_router(kline.router)
|
||||
app.include_router(watchlist.router)
|
||||
app.include_router(screener.router)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""访问密码认证 — 单用户, 自托管场景。
|
||||
|
||||
设计:
|
||||
- 密码用 PBKDF2-HMAC-SHA256 哈希(标准库 hashlib, 无新依赖), 加随机 salt。
|
||||
即使 auth.json 泄露, 也无法逆向出明文密码。
|
||||
- 会话用随机 token(token_urlsafe), 内存 + 文件双存(支持多进程/重启不丢失)。
|
||||
- 存储: data/user_data/auth.json (chmod 0600), 仿 secrets_store 模式。
|
||||
|
||||
安全要点:
|
||||
- 设密码接口必须限制本机/内网(见 auth router), 防黑客抢占域名抢先设密码。
|
||||
- 登录限流: 错5次锁5分钟(见 auth router 内存计数)。
|
||||
- 单密码, 不做多用户(避免重构全项目数据层)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets as _secrets
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# PBKDF2 参数(NIST 推荐, 单次校验 ~100ms, 兼顾安全与响应)
|
||||
_PBKDF2_ITER = 200_000
|
||||
_SALT_LEN = 16
|
||||
_TOKEN_BYTES = 32
|
||||
|
||||
# 会话有效期: 30 天(自托管单用户, 长一点减少重登频率)
|
||||
SESSION_TTL = 30 * 24 * 3600
|
||||
|
||||
_lock = threading.Lock()
|
||||
# 内存中的有效会话: { token: expire_ts }。进程重启后从磁盘恢复。
|
||||
_sessions: dict[str, float] = {}
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
from app.config import settings
|
||||
p = settings.data_dir / "user_data" / "auth.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
p = _path()
|
||||
if p.exists():
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("auth.json malformed: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
p = _path()
|
||||
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
try:
|
||||
os.chmod(p, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
|
||||
"""返回 (salt_hex, hash_hex)。salt 为 None 时生成新 salt。"""
|
||||
if salt is None:
|
||||
salt = os.urandom(_SALT_LEN)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITER)
|
||||
return salt.hex(), dk.hex()
|
||||
|
||||
|
||||
def _verify_password(password: str, salt_hex: str, hash_hex: str) -> bool:
|
||||
"""恒定时间比较, 防时序攻击。"""
|
||||
try:
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
expected = bytes.fromhex(hash_hex)
|
||||
except ValueError:
|
||||
return False
|
||||
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITER)
|
||||
return _secrets.compare_digest(actual, expected)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 密码管理
|
||||
# ================================================================
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""是否已设置访问密码。"""
|
||||
d = _load()
|
||||
return bool(d.get("password_hash"))
|
||||
|
||||
|
||||
def set_password(password: str) -> None:
|
||||
"""设置/修改访问密码。清空所有现有会话(强制重新登录)。"""
|
||||
if len(password) < 6:
|
||||
raise ValueError("密码至少 6 位")
|
||||
salt_hex, hash_hex = _hash_password(password)
|
||||
with _lock:
|
||||
_sessions.clear() # 改密码 = 旧会话全部失效
|
||||
_save({
|
||||
"password_hash": hash_hex,
|
||||
"password_salt": salt_hex,
|
||||
"updated_at": int(time.time()),
|
||||
"sessions": {}, # 清空持久化会话
|
||||
})
|
||||
logger.info("access password set")
|
||||
|
||||
|
||||
def verify_and_create_session(password: str) -> str | None:
|
||||
"""验证密码, 成功则创建会话并返回 token, 失败返回 None。"""
|
||||
d = _load()
|
||||
if not d.get("password_hash"):
|
||||
return None
|
||||
if not _verify_password(password, d.get("password_salt", ""), d["password_hash"]):
|
||||
return None
|
||||
token = _secrets.token_urlsafe(_TOKEN_BYTES)
|
||||
expire = time.time() + SESSION_TTL
|
||||
with _lock:
|
||||
_sessions[token] = expire
|
||||
_persist_sessions_locked()
|
||||
return token
|
||||
|
||||
|
||||
def revoke_session(token: str) -> None:
|
||||
"""注销会话(登出)。"""
|
||||
with _lock:
|
||||
_sessions.pop(token, None)
|
||||
_persist_sessions_locked()
|
||||
|
||||
|
||||
def is_valid_session(token: str) -> bool:
|
||||
"""检查会话是否有效(存在且未过期)。过期则清理。"""
|
||||
if not token:
|
||||
return False
|
||||
with _lock:
|
||||
expire = _sessions.get(token)
|
||||
if expire is None:
|
||||
return False
|
||||
if time.time() > expire:
|
||||
_sessions.pop(token, None)
|
||||
_persist_sessions_locked()
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _persist_sessions_locked() -> None:
|
||||
"""把当前内存会话写回 auth.json(需持锁调用)。"""
|
||||
d = _load()
|
||||
d["sessions"] = {t: exp for t, exp in _sessions.items()}
|
||||
_save(d)
|
||||
|
||||
|
||||
def _restore_sessions() -> None:
|
||||
"""启动时从 auth.json 恢复未过期会话(支持进程重启不丢登录态)。"""
|
||||
with _lock:
|
||||
d = _load()
|
||||
now = time.time()
|
||||
saved = d.get("sessions") or {}
|
||||
for token, expire in saved.items():
|
||||
if isinstance(expire, (int, float)) and expire > now:
|
||||
_sessions[token] = expire
|
||||
if len(_sessions) != len(saved):
|
||||
# 有过期会话被清理, 落盘一次
|
||||
_persist_sessions_locked()
|
||||
|
||||
|
||||
# 模块加载时恢复会话
|
||||
try:
|
||||
_restore_sessions()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("restore sessions failed: %s", e)
|
||||
Generated
+1
-1
@@ -2491,7 +2491,7 @@ all = [
|
||||
|
||||
[[package]]
|
||||
name = "tickflow-stock-panel-backend"
|
||||
version = "0.1.62"
|
||||
version = "0.1.63"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
|
||||
+23
-1
@@ -16,7 +16,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
let detail = ''
|
||||
try { const j = JSON.parse(await res.text()); detail = j.detail ?? j.message ?? '' } catch { /* ignore */ }
|
||||
const msg = detail || `${res.status} ${res.statusText}`
|
||||
toast(msg, 'error')
|
||||
// 401 (未登录/会话过期) 不弹 toast — 由全局认证拦截器统一跳登录页, 避免刷屏
|
||||
if (res.status !== 401) toast(msg, 'error')
|
||||
throw new Error(msg)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
@@ -704,6 +705,27 @@ export interface StrategyAlertEvent {
|
||||
export const api = {
|
||||
health: () => request<{ status: string; version: string; mode: string }>('/health'),
|
||||
|
||||
// ===== Auth (访问认证) =====
|
||||
authStatus: () =>
|
||||
request<{ configured: boolean; authenticated: boolean }>('/api/auth/status'),
|
||||
authSetup: (password: string) =>
|
||||
request<{ ok: boolean }>('/api/auth/setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
authLogin: (password: string) =>
|
||||
request<{ ok: boolean }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
authLogout: () =>
|
||||
request<{ ok: boolean }>('/api/auth/logout', { method: 'POST' }),
|
||||
authChangePassword: (oldPassword: string, newPassword: string) =>
|
||||
request<{ ok: boolean }>('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
|
||||
}),
|
||||
|
||||
settings: () => request<SettingsState>('/api/settings'),
|
||||
saveTickflowKey: (api_key: string) =>
|
||||
request<SaveTickflowKeyResult>('/api/settings/tickflow-key', {
|
||||
|
||||
+29
-1
@@ -1,16 +1,44 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'
|
||||
import { router } from './router'
|
||||
import './index.css'
|
||||
|
||||
// 全局认证拦截: 任何 query/mutation 收到 401 (未登录/会话过期) → 跳登录页。
|
||||
// api.ts 的 request() 已对 401 静默 (不弹 toast), 这里统一负责跳转。
|
||||
// 排除 /login 自身的请求, 避免登录页请求失败又跳登录形成死循环。
|
||||
const _redirectToLogin = (() => {
|
||||
let redirecting = false
|
||||
return (err: unknown) => {
|
||||
if (redirecting) return
|
||||
if (!(err instanceof Error)) return
|
||||
const msg = err.message || ''
|
||||
// 401 (未登录/会话过期) → 跳登录页
|
||||
// 403 未初始化 (面板未设密码, 公网访问) → 也跳登录页(显示设密码提示)
|
||||
const is401 = msg.includes('未登录') || msg.includes('会话已过期') || msg.includes('401')
|
||||
const isNotInit = msg.includes('尚未初始化访问密码') || msg.includes('NOT_INITIALIZED')
|
||||
if (!is401 && !isNotInit) return
|
||||
// 已在登录页则不跳(避免死循环)
|
||||
if (window.location.pathname === '/login') return
|
||||
redirecting = true
|
||||
const redirect = encodeURIComponent(window.location.pathname + window.location.search)
|
||||
window.location.href = `/login?redirect=${redirect}`
|
||||
}
|
||||
})()
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (err) => _redirectToLogin(err),
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5_000, // 5s 内复用,与 §4.2 Repository 不变量一致
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: {
|
||||
onError: (err) => _redirectToLogin(err),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 访问认证页 — 复用同一组件处理「首次设密码」和「登录」两种状态。
|
||||
*
|
||||
* 根据后端 /api/auth/status 的 configured 字段决定显示:
|
||||
* - configured=false → 显示「设置访问密码」(首次)
|
||||
* - configured=true → 显示「登录」
|
||||
*
|
||||
* 安全:
|
||||
* - 设密码接口后端限本机/内网; 公网用户设密码会被 403 拒绝, 页面据此提示。
|
||||
* - 登录失败由后端限流(5次锁5分钟), 429 时前端显示等待提示。
|
||||
*/
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Eye, EyeOff, Loader2, Lock, ShieldCheck, ShieldAlert, Sparkles } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { Logo } from '@/components/Logo'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export function Auth() {
|
||||
const navigate = useNavigate()
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('') // 仅设密码时用
|
||||
const [showPwd, setShowPwd] = useState(false)
|
||||
const [localError, setLocalError] = useState('')
|
||||
|
||||
// 取认证状态(是否已设密码)
|
||||
const [status, setStatus] = useState<{ configured: boolean } | null>(null)
|
||||
useEffect(() => {
|
||||
api.authStatus().then(s => {
|
||||
setStatus(s)
|
||||
// 已登录的话直接进面板(避免登录页死循环)
|
||||
if (s.authenticated) navigate('/', { replace: true })
|
||||
}).catch(() => setStatus({ configured: false }))
|
||||
}, [navigate])
|
||||
|
||||
const isSetup = !status?.configured // configured=false → 设密码模式
|
||||
|
||||
// 登录 / 设密码 共用一个 mutation(按 isSetup 调不同接口)
|
||||
const submitMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (isSetup) {
|
||||
return api.authSetup(password)
|
||||
}
|
||||
return api.authLogin(password)
|
||||
},
|
||||
onSuccess: () => {
|
||||
// 成功: 跳回原页面(或首页)
|
||||
const redirect = new URLSearchParams(window.location.search).get('redirect') || '/'
|
||||
navigate(redirect, { replace: true })
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const msg = err?.message || (isSetup ? '设置失败' : '登录失败')
|
||||
// 设密码/登录失败必须显示: 401(密码错)/403(公网设密码被拒)/429(限流) 都要提示
|
||||
setLocalError(msg)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLocalError('')
|
||||
if (isSetup) {
|
||||
if (password.length < 6) { setLocalError('密码至少 6 位'); return }
|
||||
if (password !== confirmPassword) { setLocalError('两次密码不一致'); return }
|
||||
}
|
||||
submitMut.mutate()
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-base">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-base px-4">
|
||||
{/* 背景辉光(与 Onboarding 风格一致) */}
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(139,92,246,0.15),transparent_40%),radial-gradient(circle_at_70%_80%,rgba(59,130,246,0.12),transparent_40%)]" />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative w-full max-w-sm"
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="mb-6 flex flex-col items-center gap-2">
|
||||
<Logo className="h-10 w-10" />
|
||||
<h1 className="text-lg font-semibold text-foreground">TickFlow 股票面板</h1>
|
||||
</div>
|
||||
|
||||
<div className="rounded-card border border-border bg-surface/90 p-6 shadow-2xl backdrop-blur">
|
||||
{/* 标题区: 图标 + 文案随模式切换 */}
|
||||
<div className="mb-5 flex items-center gap-2.5">
|
||||
<div className={cn(
|
||||
'grid h-9 w-9 place-items-center rounded-lg',
|
||||
isSetup ? 'bg-accent/15 text-accent' : 'bg-purple-500/15 text-purple-400',
|
||||
)}>
|
||||
{isSetup ? <ShieldCheck className="h-5 w-5" /> : <Lock className="h-5 w-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{isSetup ? '设置访问密码' : '登录访问'}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{isSetup ? '首次使用, 请为面板设置访问密码' : '请输入访问密码以继续'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
{/* 密码输入 */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPwd ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="访问密码"
|
||||
autoFocus
|
||||
className="h-10 w-full rounded-btn border border-border bg-base px-3 pr-9 text-sm text-foreground outline-none transition-colors focus:border-accent/50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPwd(s => !s)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted hover:text-foreground"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPwd ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 确认密码(仅设密码模式) */}
|
||||
{isSetup && (
|
||||
<input
|
||||
type={showPwd ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
placeholder="再次输入密码"
|
||||
className="h-10 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none transition-colors focus:border-accent/50"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{(localError || submitMut.error) && (
|
||||
<div className="flex items-start gap-1.5 rounded-btn bg-danger/10 px-3 py-2 text-[11px] text-danger">
|
||||
<ShieldAlert className="mt-px h-3.5 w-3.5 shrink-0" />
|
||||
<span>{localError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitMut.isPending || !password}
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-1.5 rounded-btn bg-accent text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
|
||||
>
|
||||
{submitMut.isPending ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />处理中…</>
|
||||
) : (
|
||||
<>{isSetup ? '设置并进入' : '登录'}</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 提示: 设密码模式告知本机限制 */}
|
||||
{isSetup && (
|
||||
<p className="mt-3 text-[10px] leading-relaxed text-muted/70">
|
||||
出于安全考虑, 首次设置密码需在服务器本机或内网访问时操作。公网环境下仅可登录。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-center gap-1.5 text-[10px] text-muted/60">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
自托管量化工作台 · 数据完全掌握在自己手里
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Screener } from './pages/Screener'
|
||||
import { Backtest } from './pages/Backtest'
|
||||
import { Financials } from './pages/Financials'
|
||||
import { Onboarding } from './pages/Onboarding'
|
||||
import { Auth } from './pages/Auth'
|
||||
import { Data } from './pages/Data'
|
||||
import { Monitor } from './pages/Monitor'
|
||||
import { Trading } from './pages/Trading'
|
||||
@@ -51,6 +52,7 @@ function OnboardingGuard({ children }: { children: React.ReactNode }) {
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: '/onboarding', element: <Onboarding /> },
|
||||
{ path: '/login', element: <Auth /> },
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
|
||||
Reference in New Issue
Block a user