|
|
import json
|
|
|
import time
|
|
|
import uuid
|
|
|
from dataclasses import replace
|
|
|
from types import MappingProxyType
|
|
|
|
|
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
|
from starlette.middleware.cors import CORSMiddleware
|
|
|
from starlette.middleware.gzip import GZipMiddleware
|
|
|
from starlette.requests import Request
|
|
|
from starlette.responses import Response
|
|
|
from starlette.types import ASGIApp
|
|
|
|
|
|
from app.api.v1.module_system.params.service import ParamsService
|
|
|
from app.common.response import ErrorResponse
|
|
|
from app.config.setting import settings
|
|
|
from app.core.exceptions import CustomException
|
|
|
from app.core.logger import logger
|
|
|
from app.core.request_context import RequestContext, clear_current_tenant, reset_correlation_id, set_correlation_id, set_current_tenant
|
|
|
from app.core.security import decode_access_token
|
|
|
from app.utils.ip_local_util import get_client_ip
|
|
|
|
|
|
|
|
|
def _strip_bearer(authorization: str) -> str | None:
|
|
|
"""从 Authorization header 提取 token,非 Bearer 返回 None。"""
|
|
|
v = authorization.strip()
|
|
|
if v[:7].lower() == "bearer ":
|
|
|
v = v[7:].strip()
|
|
|
elif v[:6].lower() == "bearer":
|
|
|
v = v[6:].strip()
|
|
|
else:
|
|
|
return None
|
|
|
return v or None
|
|
|
|
|
|
|
|
|
# 中间件配置的「安全默认值」:Redis 不可用 / 解析异常时启用,确保中间件行为可预测。
|
|
|
# 使用 MappingProxyType 防止任何地方误改导致跨请求污染。
|
|
|
_DEFAULT_CONFIG: MappingProxyType = MappingProxyType({
|
|
|
"demo_enable": False,
|
|
|
"ip_white_list": (),
|
|
|
"ip_black_list": (),
|
|
|
"white_api_list_path": (),
|
|
|
})
|
|
|
|
|
|
|
|
|
class CustomCORSMiddleware(CORSMiddleware):
|
|
|
def __init__(self, app: ASGIApp) -> None:
|
|
|
super().__init__(
|
|
|
app,
|
|
|
allow_origins=settings.ALLOW_ORIGINS,
|
|
|
allow_methods=settings.ALLOW_METHODS,
|
|
|
allow_headers=settings.ALLOW_HEADERS,
|
|
|
allow_credentials=settings.ALLOW_CREDENTIALS,
|
|
|
expose_headers=settings.CORS_EXPOSE_HEADERS,
|
|
|
)
|
|
|
|
|
|
|
|
|
class RequestLogMiddleware(BaseHTTPMiddleware):
|
|
|
"""请求日志 & 演示模式拦截"""
|
|
|
|
|
|
def __init__(self, app: ASGIApp) -> None:
|
|
|
super().__init__(app)
|
|
|
|
|
|
@staticmethod
|
|
|
def _hydrate_session_id(request: Request) -> None:
|
|
|
"""从 ctx / JWT 中提取 session_id 并写入 ctx。"""
|
|
|
ctx = getattr(request.state, "ctx", None)
|
|
|
sid = ctx.session_id if ctx else None
|
|
|
if not sid and ctx and ctx.jwt_user_info:
|
|
|
sid = ctx.jwt_user_info.get("session_id")
|
|
|
if not sid:
|
|
|
token = _strip_bearer(request.headers.get("Authorization", ""))
|
|
|
if token:
|
|
|
try:
|
|
|
payload = decode_access_token(token)
|
|
|
sid = getattr(payload, "sub", None) if payload else None
|
|
|
except Exception:
|
|
|
sid = None
|
|
|
if sid:
|
|
|
request.state.ctx = replace(ctx or RequestContext(), session_id=sid)
|
|
|
|
|
|
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
|
start_time = time.time()
|
|
|
self._hydrate_session_id(request)
|
|
|
|
|
|
client_ip = get_client_ip(request)
|
|
|
logger.info("请求: {} {} | client={}", request.method, request.url.path, client_ip or "unknown")
|
|
|
|
|
|
try:
|
|
|
path = request.url.path
|
|
|
config = await self._load_config(request)
|
|
|
is_blacklisted = bool(client_ip and client_ip in config["ip_black_list"])
|
|
|
in_demo = (
|
|
|
config["demo_enable"]
|
|
|
and request.method != "GET"
|
|
|
and (client_ip is None or client_ip not in config["ip_white_list"])
|
|
|
and not _is_path_whitelisted(path, config["white_api_list_path"])
|
|
|
)
|
|
|
|
|
|
if is_blacklisted or in_demo:
|
|
|
logger.warning(
|
|
|
"请求被拦截: {} {} | ip={} | 原因={}",
|
|
|
request.method, path, client_ip,
|
|
|
"IP黑名单" if is_blacklisted else "演示模式",
|
|
|
)
|
|
|
return ErrorResponse(
|
|
|
msg="IP已被黑名单" if is_blacklisted else "演示环境,禁止操作"
|
|
|
)
|
|
|
|
|
|
response = await call_next(request)
|
|
|
process_time = round(time.time() - start_time, 5)
|
|
|
response.headers["X-Process-Time"] = str(process_time)
|
|
|
logger.info("响应: {} | {:.1f}ms", response.status_code, process_time * 1000)
|
|
|
return response
|
|
|
except CustomException as e:
|
|
|
logger.exception(f"中间件异常: {e!s}")
|
|
|
return ErrorResponse(msg="系统异常,请联系管理员", data=str(e))
|
|
|
|
|
|
@staticmethod
|
|
|
async def _load_config(request: Request) -> dict:
|
|
|
"""加载中间件配置(带 60 秒内存缓存),失败时返回全部默认值。"""
|
|
|
redis = getattr(request.app.state, "redis", None)
|
|
|
if not redis:
|
|
|
return _DEFAULT_CONFIG
|
|
|
try:
|
|
|
tenant_id = await _extract_tenant_from_token(request) or 1
|
|
|
return await ParamsService.get_system_config_for_middleware(redis, tenant_id)
|
|
|
except Exception:
|
|
|
return _DEFAULT_CONFIG
|
|
|
|
|
|
|
|
|
class CustomGZipMiddleware(GZipMiddleware):
|
|
|
def __init__(self, app: ASGIApp) -> None:
|
|
|
super().__init__(app, minimum_size=settings.GZIP_MIN_SIZE, compresslevel=settings.GZIP_COMPRESS_LEVEL)
|
|
|
|
|
|
|
|
|
class CorrelationIdMiddleware(BaseHTTPMiddleware):
|
|
|
def __init__(self, app: ASGIApp) -> None:
|
|
|
self._header = "X-Correlation-ID"
|
|
|
super().__init__(app)
|
|
|
|
|
|
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
|
cid = request.headers.get(self._header) or str(uuid.uuid4())
|
|
|
token = set_correlation_id(cid)
|
|
|
try:
|
|
|
response = await call_next(request)
|
|
|
response.headers[self._header] = cid
|
|
|
return response
|
|
|
finally:
|
|
|
reset_correlation_id(token)
|
|
|
|
|
|
|
|
|
_TENANT_WHITELIST_PREFIXES = ("/docs", "/redoc", "/openapi.json", "/metrics", "/static/")
|
|
|
_WHITELIST_ALL = (
|
|
|
"/api/v1/system/auth/login", "/api/v1/system/auth/captcha",
|
|
|
"/api/v1/system/auth/refresh", "/api/v1/health", "/api/v1/common/health",
|
|
|
) + tuple(settings.TENANT_WHITELIST_PATHS)
|
|
|
|
|
|
|
|
|
def _tenant_is_whitelisted(path: str) -> bool:
|
|
|
"""白名单路径:精确匹配公共接口,前缀匹配文档 / 静态资源。"""
|
|
|
for prefix in (*_WHITELIST_ALL, *_TENANT_WHITELIST_PREFIXES):
|
|
|
if path == prefix or path.startswith(prefix):
|
|
|
return True
|
|
|
return False
|
|
|
|
|
|
|
|
|
async def _extract_tenant_from_token(request: Request) -> int | None:
|
|
|
"""从 JWT + Redis 会话解析租户 ID;结果挂到 request.state 上以便本请求内复用。
|
|
|
|
|
|
返回 None 表示未登录 / 会话过期,调用方应避免回退到平台租户(1)。
|
|
|
"""
|
|
|
if hasattr(request.state, "tenant_id_resolved"):
|
|
|
return request.state.tenant_id
|
|
|
|
|
|
request.state.tenant_id_resolved = True
|
|
|
request.state.tenant_id = None
|
|
|
|
|
|
token = _strip_bearer(request.headers.get("Authorization", ""))
|
|
|
if not token:
|
|
|
return None
|
|
|
try:
|
|
|
payload = decode_access_token(token)
|
|
|
if not payload or not hasattr(payload, "sub"):
|
|
|
return None
|
|
|
|
|
|
session_id = payload.sub
|
|
|
redis = getattr(request.app.state, "redis", None)
|
|
|
raw = await await_redis_get(redis, session_id) if redis else None
|
|
|
user_info = json.loads(raw) if raw else None
|
|
|
|
|
|
base = getattr(request.state, "ctx", None) or RequestContext()
|
|
|
request.state.ctx = replace(base, jwt_payload=payload, jwt_user_info=user_info)
|
|
|
if user_info and user_info.get("tenant_id"):
|
|
|
request.state.tenant_id = int(user_info["tenant_id"])
|
|
|
except Exception:
|
|
|
pass
|
|
|
return request.state.tenant_id
|
|
|
|
|
|
|
|
|
async def await_redis_get(redis, key: str) -> str | None:
|
|
|
"""获取用户会话;Redis 不可用时返回 None。"""
|
|
|
from app.common.enums import RedisInitKeyConfig
|
|
|
from app.core.redis_crud import RedisCURD
|
|
|
try:
|
|
|
return await RedisCURD(redis).get(f"{RedisInitKeyConfig.USER_SESSION.key}:{key}")
|
|
|
except Exception:
|
|
|
return None
|
|
|
|
|
|
|
|
|
def _is_path_whitelisted(path: str, whitelist: list) -> bool:
|
|
|
"""精确匹配;``*`` 结尾表示前缀通配。"""
|
|
|
for item in whitelist:
|
|
|
if not isinstance(item, str) or not item:
|
|
|
continue
|
|
|
if item.endswith("*"):
|
|
|
if path.startswith(item.rstrip("*")):
|
|
|
return True
|
|
|
elif path == item:
|
|
|
return True
|
|
|
return False
|
|
|
|
|
|
|
|
|
class TenantMiddleware(BaseHTTPMiddleware):
|
|
|
def __init__(self, app: ASGIApp) -> None:
|
|
|
super().__init__(app)
|
|
|
|
|
|
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
|
if request.method == "OPTIONS" or _tenant_is_whitelisted(request.url.path):
|
|
|
return await call_next(request)
|
|
|
try:
|
|
|
set_current_tenant(await _extract_tenant_from_token(request))
|
|
|
except Exception:
|
|
|
logger.exception("租户中间件异常: path={}", request.url.path)
|
|
|
try:
|
|
|
return await call_next(request)
|
|
|
finally:
|
|
|
clear_current_tenant()
|