Files
Profeto/src/api/routes/auth.py
T
Profeto Agent 6c24672e87 安全加固 + 数据管线修复 + 质量改进(第2批)
安全加固:
- /api/v1/predict 限流改用 get_client_ip 防 X-Forwarded-For 伪造
- 新增 TRUST_PROXY_HEADERS/REQUIRE_ADMIN_AUTH 配置,默认 fail-closed
- 生产环境管理接口未配置鉴权时拒绝(503),不再放行

数据管线修复:
- injuries IntegrityError 改用 begin_nested(SAVEPOINT)隔离批次
- injuries 空名单 vs 未配置语义区分(has_data 精确标记)
- bzzoiro 统计字段映射(shots/possession/cards) + 入库条件放宽
- available_at 语义收紧(回测防泄漏)
- team_names NFKD 去变音双重查找 + 补齐变体键

预测系统改进:
- multi-agent 全专家失败时 status=degraded 跳过终裁
- agent_weights 独立持久化到 predictions 表

新增迁移:
- 0014_predictions_agent_weights.py

新增测试(8个文件):
- test_ip_spoofing.py: 限流防伪造
- test_require_admin_fail_closed.py: 生产 fail-closed
- test_injuries_integrity_rollback.py: SAVEPOINT 隔离
- test_injuries_slice_semantic.py: 空名单 vs 未配置
- test_available_at.py: 回测防泄漏
- test_bzzoirot_stats.py: 统计字段映射
- test_team_names_normalize.py: NFKD 变体
- test_multi_agent_degraded.py: 全失败 degraded
- test_agent_weights_persist.py: 权重持久化
2026-09-19 08:02:57 +00:00

139 lines
5.0 KiB
Python

"""管理后台认证路由:密码登录 → HttpOnly Cookie 会话;支持在线修改密码。
管理员密码以 scrypt 哈希存于数据库(.env 明文仅作初始值,启动时自动迁移为哈希)。
修改密码会改变会话签名密钥,所有已登录会话随之失效,需重新登录。
"""
from __future__ import annotations
import logging
import secrets
import time
from collections import defaultdict, deque
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from src.api.deps import (
SESSION_COOKIE,
auth_configured,
create_session_token,
get_session_secret,
require_admin,
verify_session_token,
)
from src.core import crypto
from src.core.config import settings
from src.core.runtime_config import (
get_admin_password_hash,
get_setting_origin,
set_admin_password_hash,
verify_admin_password,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
# 简易防爆破:10 分钟窗口内同一 IP 连续失败 5 次即锁定 10 分钟(内存态,重启清零)
_MAX_FAILS = 5
_WINDOW_SECONDS = 600
_fail_times: dict[str, deque[float]] = defaultdict(deque)
# 新密码强度要求
_MIN_PASSWORD_LEN = 8
_MAX_PASSWORD_LEN = 128
class LoginIn(BaseModel):
password: str
class PasswordChangeIn(BaseModel):
current_password: str
new_password: str
def _client_ip(request: Request) -> str:
"""获取客户端 IP,与限流共用同一套逻辑(防伪造)。"""
from src.api.deps import get_client_ip
return get_client_ip(request)
def _is_locked(ip: str) -> bool:
dq = _fail_times.get(ip)
if not dq:
return False
now = time.time()
while dq and now - dq[0] > _WINDOW_SECONDS:
dq.popleft()
return len(dq) >= _MAX_FAILS
@router.post("/login")
async def login(body: LoginIn, request: Request, response: Response):
ip = _client_ip(request)
if not (await get_admin_password_hash() or settings.ADMIN_PASSWORD):
raise HTTPException(status_code=503, detail="服务器未配置管理员密码,登录不可用")
if _is_locked(ip):
logger.warning("管理员登录尝试过于频繁 (ip=%s)", ip)
raise HTTPException(status_code=429, detail="失败次数过多,请 10 分钟后再试")
if not await verify_admin_password(body.password):
_fail_times[ip].append(time.time())
logger.warning("管理员登录失败 (ip=%s)", ip)
raise HTTPException(status_code=401, detail="密码错误")
_fail_times.pop(ip, None)
response.set_cookie(
key=SESSION_COOKIE,
value=create_session_token(await get_session_secret()),
max_age=settings.ADMIN_SESSION_TTL_HOURS * 3600,
httponly=True,
samesite="lax",
path="/",
)
logger.info("管理员登录成功 (ip=%s)", ip)
return {"ok": True, "expires_in_hours": settings.ADMIN_SESSION_TTL_HOURS}
@router.post("/logout")
async def logout(response: Response):
response.delete_cookie(key=SESSION_COOKIE, path="/")
return {"ok": True}
@router.get("/me")
async def me(request: Request):
"""前端登录门禁探测。未启用鉴权时视为已登录(本地开发模式)。"""
token = request.cookies.get(SESSION_COOKIE)
authenticated = not await auth_configured() or bool(
token and verify_session_token(token, await get_session_secret())
)
has_hash = bool(await get_admin_password_hash())
return {
"authenticated": authenticated,
"enabled": await auth_configured(),
"password_origin": "db" if has_hash else ("env" if settings.ADMIN_PASSWORD else "none"),
}
@router.post("/change-password", dependencies=[Depends(require_admin)])
async def change_password(body: PasswordChangeIn, request: Request, response: Response):
"""修改管理员密码:验证当前密码 → 写运行时覆盖 → 清除会话(全端登出)。"""
if not await auth_configured():
raise HTTPException(status_code=503, detail="服务器未配置管理员密码,无法修改")
if not await verify_admin_password(body.current_password):
logger.warning("修改密码失败:当前密码错误 (ip=%s)", _client_ip(request))
raise HTTPException(status_code=401, detail="当前密码错误")
new = body.new_password
if not (_MIN_PASSWORD_LEN <= len(new) <= _MAX_PASSWORD_LEN):
raise HTTPException(status_code=400, detail=f"新密码长度需在 {_MIN_PASSWORD_LEN}-{_MAX_PASSWORD_LEN} 位之间")
if await verify_admin_password(new):
raise HTTPException(status_code=400, detail="新密码不能与当前密码相同")
await set_admin_password_hash(crypto.hash_password(new))
# 密码即会话签名密钥,修改后所有旧会话失效;主动清除当前 Cookie 要求重新登录
response.delete_cookie(key=SESSION_COOKIE, path="/")
logger.info("管理员密码已修改 (ip=%s),所有会话已失效", _client_ip(request))
return {"ok": True, "message": "密码已修改,请用新密码重新登录"}