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
This commit is contained in:
logan committed 2026-05-25 12:57:59 +08:00
1 parent 0576b253e4
commit 344c519209
15 files changed
+510 -97

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
"""操作审计中间件 — 自动记录所有 POST/PUT/DELETE/PATCH 请求。"""
import time
import json
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.database import async_session
from app.models.operation_log import OperationLog
# 不记录审计的路径(高频/非业务)
_EXCLUDE_PREFIXES = (
"/api/v1/audit-logs",
"/api/v1/dashboard",
"/docs",
"/openapi",
"/redoc",
"/api/v1/query/execute", # 查询执行单独记录
)
# 资源类型映射: URL前缀 -> 资源类型
_RESOURCE_MAP = {
"/api/v1/tickets": "ticket",
"/api/v1/instances": "instance",
"/api/v1/users": "user",
"/api/v1/audit-rules": "audit_rule",
"/api/v1/notifications": "notification",
"/api/v1/auth": "auth",
"/api/v1/workflows": "workflow",
"/api/v1/settings": "system_setting",
}
# action 映射
_ACTION_MAP = {
"POST": "create",
"PUT": "update",
"PATCH": "update",
"DELETE": "delete",
}
class AuditLogMiddleware(BaseHTTPMiddleware):
"""自动审计中间件 — 记录所有写操作。"""
async def dispatch(self, request: Request, call_next):
# 只审计写操作
if request.method not in ("POST", "PUT", "PATCH", "DELETE"):
return await call_next(request)
path = request.url.path
# 跳过排除列表
if any(path.startswith(p) for p in _EXCLUDE_PREFIXES):
return await call_next(request)
# 特殊: POST /auth/login 在 auth.py 中单独记录
if path == "/api/v1/auth/login" or path == "/api/v1/auth/logout":
return await call_next(request)
response = await call_next(request)
# 异步记录审计日志(不阻塞响应)
try:
await self._record(request, response.status_code)
except Exception:
pass
return response
@staticmethod
async def _record(request: Request, status_code: int):
path = request.url.path
method = request.method
# 确定 action
action = _ACTION_MAP.get(method, method.lower())
if path.endswith("/review"):
action = "approve" if status_code < 300 else "reject"
elif path.endswith("/execute"):
action = "execute"
elif path.endswith("/cancel"):
action = "cancel"
# 确定资源类型
resource_type = "other"
for prefix, rtype in _RESOURCE_MAP.items():
if path.startswith(prefix):
resource_type = rtype
break
# 提取 resource_id
resource_id = None
parts = path.rstrip("/").split("/")
for p in parts:
if p.isdigit():
resource_id = int(p)
break
# 获取操作者信息
user_id = None
username = None
try:
from app.core.security import decode_token
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
payload = decode_token(auth_header[7:])
user_id = int(payload.get("sub", 0))
username = payload.get("role", "unknown")
except Exception:
pass
# IP
forwarded = request.headers.get("x-forwarded-for")
ip_address = forwarded.split(",")[0].strip() if forwarded else (
request.client.host if request.client else None
)
# 写入DB
async with async_session() as db:
log = OperationLog(
user_id=user_id,
username=username,
action=action,
resource_type=resource_type,
resource_id=resource_id,
detail=f"{method} {path} -> {status_code}",
ip_address=ip_address,
)
db.add(log)
await db.commit()
+17 -3
View File
@@ -2,12 +2,17 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
import secrets
class Settings(BaseSettings):
ENV: str = "development"
DEBUG: bool = True
SECRET_KEY: str = "dev-secret-key-change-in-production"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# JWT secret: 生产环境必须通过环境变量设置
# 若未设置则每次启动随机生成(重启后旧 token 失效)
SECRET_KEY: str = ""
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480 # 8小时
ALGORITHM: str = "HS256"
DATABASE_URL: str = "sqlite+aiosqlite:///./sqlite3.db"
@@ -18,9 +23,18 @@ class Settings(BaseSettings):
# CORS: production comma-separated origins (e.g. "https://sql.example.com,https://sql2.example.com")
CORS_ORIGINS: str = ""
# 登录频率限制: 每IP每分钟最多尝试次数
LOGIN_RATE_LIMIT: int = 10
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
@lru_cache()
def get_settings() -> Settings:
return Settings()
s = Settings()
if not s.SECRET_KEY:
s.SECRET_KEY = secrets.token_urlsafe(32)
if s.ENV == "development":
import logging
logging.getLogger(__name__).info("SECRET_KEY 未设置,已自动生成(重启后失效)")
return s
+50
View File
@@ -0,0 +1,50 @@
"""登录频率限制 — 基于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"