"""登录频率限制 — 基于IP的滑动窗口计数器。""" import time from collections import defaultdict from typing import Dict, List, Tuple from fastapi import Request, HTTPException, status from starlette.middleware.base import BaseHTTPMiddleware from app.core.config import get_settings class LoginRateLimitMiddleware(BaseHTTPMiddleware): """限制每IP每分钟的登录尝试次数。""" def __init__(self, app, **kwargs): super().__init__(app, **kwargs) self._attempts: Dict[str, List[float]] = defaultdict(list) async def dispatch(self, request: Request, call_next): if request.url.path == "/api/v1/auth/login" and request.method == "POST": client_ip = self._get_client_ip(request) settings = get_settings() max_attempts = settings.LOGIN_RATE_LIMIT now = time.time() # 清理60秒前的记录 self._attempts[client_ip] = [ t for t in self._attempts[client_ip] if now - t < 60 ] if len(self._attempts[client_ip]) >= max_attempts: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=f"登录尝试过于频繁,请稍后再试(限制: {max_attempts}次/分钟)", ) response = await call_next(request) # 只在登录失败时记录(成功不计入限制) if response.status_code != 200: self._attempts[client_ip].append(now) return response return await call_next(request) @staticmethod def _get_client_ip(request: Request) -> str: forwarded = request.headers.get("x-forwarded-for") if forwarded: return forwarded.split(",")[0].strip() return request.client.host if request.client else "unknown"