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

+94
View File
@@ -0,0 +1,94 @@
"""操作审计日志 API。"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.models.operation_log import OperationLog
router = APIRouter(prefix="/audit-logs", tags=["audit-logs"])
@router.get("")
async def list_audit_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
action: Optional[str] = Query(None),
resource_type: Optional[str] = Query(None),
username: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""查询操作审计日志(仅管理员)。"""
query = select(OperationLog)
count_query = select(func.count(OperationLog.id))
if action:
query = query.where(OperationLog.action == action)
count_query = count_query.where(OperationLog.action == action)
if resource_type:
query = query.where(OperationLog.resource_type == resource_type)
count_query = count_query.where(OperationLog.resource_type == resource_type)
if username:
query = query.where(OperationLog.username.contains(username))
count_query = count_query.where(OperationLog.username.contains(username))
total = (await db.execute(count_query)).scalar() or 0
query = query.order_by(desc(OperationLog.created_at))
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
logs = result.scalars().all()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": l.id,
"user_id": l.user_id,
"username": l.username,
"action": l.action,
"resource_type": l.resource_type,
"resource_id": l.resource_id,
"detail": l.detail,
"ip_address": l.ip_address,
"created_at": l.created_at.isoformat() if l.created_at else None,
}
for l in logs
],
}
@router.get("/stats")
async def audit_log_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""审计日志统计概览。"""
# 最近7天操作分布
from datetime import datetime, timedelta, timezone
since = datetime.now(timezone.utc) - timedelta(days=7)
result = await db.execute(
select(OperationLog.action, func.count(OperationLog.id))
.where(OperationLog.created_at >= since)
.group_by(OperationLog.action)
)
action_stats = {row[0]: row[1] for row in result.all()}
# 操作最多的用户
result = await db.execute(
select(OperationLog.username, func.count(OperationLog.id))
.where(OperationLog.created_at >= since)
.group_by(OperationLog.username)
.order_by(desc(func.count(OperationLog.id)))
.limit(5)
)
top_users = [{"username": row[0], "count": row[1]} for row in result.all()]
return {"action_stats": action_stats, "top_users": top_users}
+12 -2
View File
@@ -1,5 +1,5 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,11 +12,16 @@ router = APIRouter(prefix="/auth", tags=["认证"])
@router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
async def login(req: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.username == req.username))
user = result.scalar_one_or_none()
if not user or not verify_password(req.password, user.hashed_password):
# 记录登录失败
from app.services.audit_logger import log_operation
await log_operation(db, action="login_failed", username=req.username,
detail=f"登录失败: 用户名或密码错误", request=request)
await db.commit()
raise HTTPException(status_code=401, detail="用户名或密码错误")
if not user.is_active:
@@ -26,6 +31,11 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
raise HTTPException(status_code=403, detail="访客账户已禁用,请联系管理员")
user.last_login = datetime.now(timezone.utc)
# 记录登录成功
from app.services.audit_logger import log_operation
await log_operation(db, action="login", user_id=user.id, username=user.username,
detail=f"登录成功", request=request)
await db.commit()
token = create_access_token(data={"sub": str(user.id), "role": user.role})