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