High-1/2: baseline mode 违反 DB CHECK 约束
- ck_mode_enum 扩展为 ('single','multi','baseline')
- baseline 的 run_type 从 'baseline' 改为 'live'(符合现有约束)
- 新增迁移 0017_mode_baseline
High-3: 限流日志 NameError: ip 未定义
- deps.py:190 的 logger.warning 中 ip → client_ip
High-4: 生产 Cookie 缺少 Secure 标志
- auth.py 登录时根据 APP_ENV 设置 secure=True(生产)
Medium-5: 降级日志参数类型错误
- orchestrator.py:266 ok_reports(list) → len(ok_reports)(int)
附加: PredictRequest mode 字段加 pattern 校验,与 DB 约束同源
Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
200 lines
7.1 KiB
Python
200 lines
7.1 KiB
Python
"""API 依赖:鉴权等横切关注点。
|
|
|
|
策略(渐进式,不破坏本地开发):
|
|
- 管理员密码:库中 scrypt 哈希优先,回落 .env 初始值;后台可在线修改。
|
|
- 密码已配置(哈希或 .env)→ 管理后台可用密码登录,登录后颁发 HttpOnly
|
|
Cookie 会话;受保护接口接受 Cookie 会话或 X-API-Key。
|
|
- 仅配置 `ADMIN_API_KEY` → 受保护接口只接受 `X-API-Key` 请求头(机器/脚本调用)。
|
|
- 两者都未配置 → 直接放行,并打一次 warning(本地开发模式)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import logging
|
|
import secrets
|
|
import time
|
|
|
|
from fastapi import Header, HTTPException, Request
|
|
|
|
from src.core.config import settings
|
|
from src.core.runtime_config import get_admin_password_hash
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SESSION_COOKIE = "profeto_session"
|
|
|
|
|
|
def _sign(exp_ts: int, secret: bytes) -> str:
|
|
msg = f"profeto-admin:{exp_ts}".encode()
|
|
return hmac.new(secret, msg, "sha256").hexdigest()
|
|
|
|
|
|
async def get_session_secret() -> bytes:
|
|
"""会话签名密钥 = HMAC(SECRET_KEY, 管理员凭证指纹)。
|
|
|
|
指纹来自密码哈希(密码本身永不参与签名):密码变更 → 指纹变化
|
|
→ 全部旧会话失效,无需额外吊销机制。
|
|
"""
|
|
from src.core.runtime_config import get_admin_credential_fingerprint
|
|
|
|
fingerprint = await get_admin_credential_fingerprint()
|
|
key = settings.SECRET_KEY or f"fallback:{settings.DATABASE_URL}"
|
|
return hmac.new(key.encode(), b"session:" + fingerprint.encode(), "sha256").digest()
|
|
|
|
|
|
def create_session_token(secret: bytes) -> str:
|
|
exp_ts = int(time.time()) + settings.ADMIN_SESSION_TTL_HOURS * 3600
|
|
return f"{exp_ts}.{_sign(exp_ts, secret)}"
|
|
|
|
|
|
def verify_session_token(token: str, secret: bytes) -> bool:
|
|
try:
|
|
exp_raw, sig = token.split(".", 1)
|
|
exp_ts = int(exp_raw)
|
|
if exp_ts < int(time.time()):
|
|
return False
|
|
return secrets.compare_digest(sig, _sign(exp_ts, secret))
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
|
|
async def auth_configured() -> bool:
|
|
"""是否已启用鉴权(密码哈希/.env 密码/API Key 任一)。"""
|
|
return bool(
|
|
await get_admin_password_hash()
|
|
or settings.ADMIN_PASSWORD
|
|
or settings.ADMIN_API_KEY
|
|
)
|
|
|
|
|
|
async def require_admin(
|
|
request: Request,
|
|
x_api_key: str | None = Header(None, alias="X-API-Key"),
|
|
) -> None:
|
|
"""统一保护管理接口:接受 Cookie 会话(密码登录)或 X-API-Key。
|
|
|
|
用法: `@router.get("/leagues", dependencies=[Depends(require_admin)])`
|
|
"""
|
|
if not await auth_configured():
|
|
# 生产环境 fail-closed:未配置鉴权则拒绝,不放行
|
|
if settings.REQUIRE_ADMIN_AUTH or settings.APP_ENV == "production":
|
|
logger.error(
|
|
"生产环境管理接口未配置鉴权(REQUIRE_ADMIN_AUTH=True 或 APP_ENV=production),"
|
|
"拒绝访问。请设置 ADMIN_PASSWORD 或 ADMIN_API_KEY。"
|
|
)
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="服务未配置管理鉴权,请联系管理员",
|
|
)
|
|
# 开发环境 fail-open + warning
|
|
logger.warning(
|
|
"管理员密码 / ADMIN_API_KEY 均未设置,管理接口当前【无鉴权】。"
|
|
"生产环境请至少设置其中一项。"
|
|
)
|
|
return
|
|
|
|
# 1) Cookie 会话(密码登录颁发)
|
|
token = request.cookies.get(SESSION_COOKIE)
|
|
if token and verify_session_token(token, await get_session_secret()):
|
|
return
|
|
|
|
# 2) X-API-Key(机器/脚本调用;key 明文只在内存中,与第三方交互必需)
|
|
if (
|
|
settings.ADMIN_API_KEY
|
|
and x_api_key
|
|
and secrets.compare_digest(x_api_key, settings.ADMIN_API_KEY)
|
|
):
|
|
return
|
|
|
|
raise HTTPException(status_code=401, detail="未登录或凭证无效")
|
|
|
|
|
|
# ── 客户端 IP 提取(防 X-Forwarded-For 伪造) ──
|
|
|
|
def get_client_ip(request: Request) -> str:
|
|
"""获取客户端真实 IP,防 X-Forwarded-For 伪造。
|
|
|
|
规则:
|
|
- TRUST_PROXY_HEADERS=False(默认):只用 request.client.host,
|
|
忽略 X-Forwarded-For,防止客户端伪造。
|
|
- TRUST_PROXY_HEADERS=True:解析 X-Forwarded-For 第一个 IP,
|
|
适用于 Nginx 等可信反代后方。
|
|
|
|
部署建议:
|
|
- 公网必须设 TRUST_PROXY_HEADERS=True,并在 Nginx 配置:
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
- Nginx 层也建议做限流(limit_req),作为第二道防线。
|
|
"""
|
|
if settings.TRUST_PROXY_HEADERS:
|
|
# 信任反代:X-Forwarded-For 可能包含多个 IP(代理链),取第一个
|
|
forwarded = request.headers.get("X-Forwarded-For")
|
|
if forwarded:
|
|
return forwarded.split(",")[0].strip()
|
|
# 默认或无反代头:直接用连接层 IP
|
|
return request.client.host if request.client else "unknown"
|
|
|
|
|
|
# ── 简易内存限流(按 IP,无外部依赖) ──
|
|
|
|
class _RateLimiter:
|
|
"""内存式滑动窗口限流。
|
|
|
|
设计取舍:
|
|
- 单进程内有效,多 worker 各自计数(生产前置于 Nginx 做全局限流更精确)
|
|
- 滑动窗口:记录每次请求时间戳,清理过期条目
|
|
- O(n) 清理,n = 时间窗口内请求数(通常 < 100)
|
|
"""
|
|
|
|
def __init__(self, max_requests: int = 10, window_seconds: int = 60):
|
|
self.max_requests = max_requests
|
|
self.window_seconds = window_seconds
|
|
self._hits: dict[str, list[float]] = {}
|
|
|
|
def is_allowed(self, key: str) -> bool:
|
|
"""检查 key 是否允许通过。True=允许,False=拒绝。"""
|
|
now = time.time()
|
|
window_start = now - self.window_seconds
|
|
|
|
# 获取并清理该 key 的过期记录
|
|
timestamps = self._hits.get(key, [])
|
|
timestamps = [t for t in timestamps if t > window_start]
|
|
|
|
if len(timestamps) >= self.max_requests:
|
|
self._hits[key] = timestamps # 更新清理后的列表
|
|
return False
|
|
|
|
timestamps.append(now)
|
|
self._hits[key] = timestamps
|
|
return True
|
|
|
|
def remaining(self, key: str) -> int:
|
|
"""当前窗口内剩余可用次数。"""
|
|
now = time.time()
|
|
timestamps = [t for t in self._hits.get(key, []) if t > now - self.window_seconds]
|
|
return max(0, self.max_requests - len(timestamps))
|
|
|
|
|
|
# 全局限流实例: /api/v1/predict 每分钟 10 次
|
|
_predict_limiter = _RateLimiter(max_requests=10, window_seconds=60)
|
|
|
|
|
|
async def rate_limit_predict(request: Request) -> None:
|
|
"""POST /api/v1/predict 限流依赖。
|
|
|
|
基于客户端 IP,超过 10 次/分钟返回 429。
|
|
IP 提取逻辑:优先用 get_client_ip(防伪造)。
|
|
"""
|
|
client_ip = get_client_ip(request)
|
|
|
|
if not _predict_limiter.is_allowed(client_ip):
|
|
logger.warning("rate limit exceeded for %s", client_ip)
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail="请求过于频繁,请稍后再试(每分钟最多 10 次)",
|
|
)
|
|
|
|
|
|
def get_predict_rate_limit_remaining(request: Request) -> int:
|
|
"""查询当前 IP 剩余的预测配额(用于响应中提示前端)。"""
|
|
return _predict_limiter.remaining(get_client_ip(request))
|