121 lines
4.0 KiB
Python
121 lines
4.0 KiB
Python
"""审计日志中间件:拦截所有 API 请求,异步写入审计日志"""
|
|
import json
|
|
import time
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response
|
|
from app.core.security import verify_token
|
|
|
|
# 不记录审计日志的路径前缀
|
|
_SKIP_PATHS = {
|
|
"/health",
|
|
"/docs",
|
|
"/redoc",
|
|
"/openapi.json",
|
|
"/api/auth/login", # 仅获取登录 URL,无用户身份
|
|
"/api/auth/permissions", # 高频只读
|
|
"/api/stats/",
|
|
"/api/olt/regions",
|
|
"/api/olt/new-devices",
|
|
"/api/olt/duplicate-macs",
|
|
}
|
|
|
|
# 只记录写操作 + 登录回调 + 特定查询(GET 默认跳过,以下 GET 例外)
|
|
_ALWAYS_LOG_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
|
|
_LOG_GET_PATHS = {
|
|
"/api/auth/profile",
|
|
}
|
|
|
|
_SENSITIVE_PARAM_MARKERS = ("password", "passwd", "secret", "token", "authorization", "credential", "code")
|
|
|
|
|
|
def sanitize_audit_params(value):
|
|
"""Recursively redact sensitive request parameters before asynchronous logging."""
|
|
if isinstance(value, dict):
|
|
return {
|
|
key: "***" if any(marker in key.lower() for marker in _SENSITIVE_PARAM_MARKERS)
|
|
else sanitize_audit_params(item)
|
|
for key, item in value.items()
|
|
}
|
|
if isinstance(value, list):
|
|
return [sanitize_audit_params(item) for item in value]
|
|
return value
|
|
|
|
|
|
def _should_log(method: str, path: str) -> bool:
|
|
for skip in _SKIP_PATHS:
|
|
if path.startswith(skip):
|
|
return False
|
|
if method in _ALWAYS_LOG_METHODS:
|
|
return True
|
|
if method == "GET":
|
|
return path in _LOG_GET_PATHS
|
|
return False
|
|
|
|
|
|
def _extract_token_payload(request: Request) -> dict:
|
|
auth = request.headers.get("Authorization", "")
|
|
if auth.startswith("Bearer "):
|
|
payload = verify_token(auth[7:])
|
|
if payload:
|
|
return payload
|
|
return {}
|
|
|
|
|
|
class AuditMiddleware(BaseHTTPMiddleware):
|
|
async def dispatch(self, request: Request, call_next) -> Response:
|
|
method = request.method
|
|
path = request.url.path
|
|
|
|
if not _should_log(method, path):
|
|
return await call_next(request)
|
|
|
|
# 读取请求体(只读一次,需要重新构造)
|
|
request_params = None
|
|
try:
|
|
body_bytes = await request.body()
|
|
if body_bytes:
|
|
try:
|
|
request_params = json.loads(body_bytes)
|
|
request_params = sanitize_audit_params(request_params)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
start_time = time.time()
|
|
response = await call_next(request)
|
|
duration_ms = int((time.time() - start_time) * 1000)
|
|
|
|
# 异步写日志(不等待)
|
|
try:
|
|
payload = _extract_token_payload(request)
|
|
user_id = payload.get("sub", "anonymous")
|
|
username = payload.get("username", "anonymous")
|
|
user_role = payload.get("role", "")
|
|
ip_address = ""
|
|
forwarded = request.headers.get("x-forwarded-for")
|
|
if forwarded:
|
|
ip_address = forwarded.split(",")[0].strip()
|
|
elif request.client:
|
|
ip_address = request.client.host
|
|
|
|
from app.tasks.audit_tasks import create_audit_log_task
|
|
create_audit_log_task.delay(
|
|
user_id=str(user_id),
|
|
username=username,
|
|
user_role=user_role,
|
|
method=method,
|
|
path=path,
|
|
ip_address=ip_address,
|
|
user_agent=request.headers.get("user-agent", "")[:500],
|
|
status_code=response.status_code,
|
|
request_params=request_params,
|
|
response_data=None, # 不捕获响应体(性能考虑)
|
|
error_message=None if response.status_code < 400 else f"HTTP {response.status_code}",
|
|
)
|
|
except Exception:
|
|
pass # 中间件异常绝不影响主响应
|
|
|
|
return response
|