Files
opsxsql/backend/app/core/rate_limit.py
T
logan 344c519209 feat: P0生产加固 — 认证安全+操作审计+错误提示
P0-1 认证安全:
- JWT secret 改为环境变量配置, 未设置则随机生成(重启失效)
- 新增 LoginRateLimitMiddleware: 5次/分钟登录频率限制(滑动窗口)
- 429 响应 + 前端 toast 提示

P0-2 操作审计日志:
- 新增 OperationLog 模型 (user/username/action/resource/detail/ip)
- 新增 AuditLogMiddleware 自动记录 POST/PUT/DELETE/PATCH 操作
- 新增 GET /audit-logs API (分页/过滤/统计)
- 前端 AuditLogsPage 对接真实 API (字段/路径修正)

P0-3 实例密码加密:
- 已确认 crypto.py AES-256-GCM 加密存储, 无需修改

P1 前端错误提示:
- 新增 useToast hook + ToastContainer 组件
- request.ts 拦截器: 4xx/5xx 错误 toast 显示后端 detail 信息
- 422 验证错误提取第一条 msg
- 网络失败显示通用提示

1182 tests passed
2026-05-25 12:57:59 +08:00

51 lines
1.8 KiB
Python

"""登录频率限制 — 基于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"