e9d2831850
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m30s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 3m13s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m18s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 16m16s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 16m20s
CI/CD Pipeline / Integration Tests (push) Successful in 2m30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m24s
157 lines
4.9 KiB
Python
157 lines
4.9 KiB
Python
"""请求日志和限流中间件"""
|
|
|
|
import logging
|
|
import re
|
|
import time
|
|
from typing import Optional
|
|
|
|
from fastapi import Request
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 敏感参数名称模式(不区分大小写)
|
|
SENSITIVE_PARAM_PATTERNS = re.compile(
|
|
r"^(password|passwd|pwd|token|secret|key|authorization|auth|api_key|" # noqa: E501
|
|
r"apikey|access_token|refresh_token|accesstoken|refreshtoken|" # noqa: E501
|
|
r"session_id|sessionid|sid|cookie|csrf|xsrf|bearer)$", # noqa: E501
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def filter_sensitive_params(query_string: Optional[str]) -> Optional[str]:
|
|
"""
|
|
过滤 query string 中的敏感参数
|
|
|
|
Args:
|
|
query_string: 原始 query string,例如 "name=xxx&password=secret&token=abc"
|
|
|
|
Returns:
|
|
过滤后的 query string,敏感参数的值被替换为 "***"
|
|
如果 query_string 为空或 None,返回原始值
|
|
"""
|
|
if not query_string:
|
|
return query_string
|
|
|
|
if query_string.startswith("?"):
|
|
query_string = query_string[1:]
|
|
|
|
if not query_string:
|
|
return query_string
|
|
|
|
parts = query_string.split("&")
|
|
filtered_parts = []
|
|
|
|
for part in parts:
|
|
if "=" in part:
|
|
key, value = part.split("=", 1)
|
|
if SENSITIVE_PARAM_PATTERNS.match(key):
|
|
filtered_parts.append(f"{key}=***")
|
|
else:
|
|
filtered_parts.append(part)
|
|
else:
|
|
# 没有 = 的参数,保留原样
|
|
filtered_parts.append(part)
|
|
|
|
return "&".join(filtered_parts)
|
|
|
|
|
|
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|
"""请求日志中间件"""
|
|
|
|
async def dispatch(self, request: Request, call_next):
|
|
# 记录请求开始时间
|
|
start_time = time.time()
|
|
|
|
# 过滤 query string 中的敏感参数
|
|
raw_query = str(request.url.query) if request.url.query else ""
|
|
safe_query = filter_sensitive_params(raw_query)
|
|
|
|
# 记录请求信息(不包含敏感参数)
|
|
if safe_query:
|
|
logger.info(f"Request: {request.method} {request.url.path}?{safe_query}") # noqa: E501
|
|
else:
|
|
logger.info(f"Request: {request.method} {request.url.path}")
|
|
|
|
# 处理请求
|
|
response = await call_next(request)
|
|
|
|
# 记录请求结束时间
|
|
process_time = time.time() - start_time
|
|
|
|
# 记录响应信息
|
|
logger.info(
|
|
f"Response: {request.method} {request.url.path} " f"status={response.status_code} time={process_time:.3f}s"
|
|
)
|
|
|
|
# 添加处理时间到响应头
|
|
response.headers["X-Process-Time"] = str(process_time)
|
|
|
|
return response
|
|
|
|
|
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
"""基于 IP 的简单限流中间件
|
|
|
|
Args:
|
|
app: ASGI 应用
|
|
max_requests: 窗口期内最大请求数
|
|
window_seconds: 时间窗口(秒)
|
|
paths: 限流的路径列表,None 表示所有路径
|
|
"""
|
|
|
|
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60, paths: list[str] | None = None):
|
|
super().__init__(app)
|
|
self.max_requests = max_requests
|
|
self.window_seconds = window_seconds
|
|
self.paths = set(paths) if paths else None
|
|
self.requests: dict[str, list[float]] = {}
|
|
|
|
async def dispatch(self, request: Request, call_next):
|
|
# 如果配置了路径过滤,只对指定路径限流
|
|
if self.paths is not None and request.url.path not in self.paths:
|
|
return await call_next(request)
|
|
|
|
# 获取客户端 IP
|
|
client_ip = request.client.host
|
|
|
|
current_time = time.time()
|
|
|
|
# 清理过期记录
|
|
if client_ip in self.requests:
|
|
self.requests[client_ip] = [
|
|
ts for ts in self.requests[client_ip] if current_time - ts < self.window_seconds
|
|
]
|
|
|
|
# 计算请求次数
|
|
request_count = len(self.requests.get(client_ip, []))
|
|
|
|
if request_count >= self.max_requests:
|
|
from fastapi.responses import JSONResponse
|
|
|
|
return JSONResponse(
|
|
status_code=429,
|
|
content={
|
|
"error": {
|
|
"code": "RATE_LIMIT_EXCEEDED",
|
|
"message": ( # noqa: E501
|
|
f"Too many requests. Limit: " f"{self.max_requests} per {self.window_seconds}s"
|
|
),
|
|
}
|
|
},
|
|
)
|
|
|
|
# 记录请求
|
|
if client_ip not in self.requests:
|
|
self.requests[client_ip] = []
|
|
self.requests[client_ip].append(current_time)
|
|
|
|
# 处理请求
|
|
response = await call_next(request)
|
|
|
|
# 添加限流信息到响应头
|
|
response.headers["X-RateLimit-Limit"] = str(self.max_requests)
|
|
response.headers["X-RateLimit-Remaining"] = str(self.max_requests - len(self.requests[client_ip]))
|
|
|
|
return response
|