初始提交
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import Response
|
||||
|
||||
|
||||
class ApiResponseHeaderMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
接口响应头追加中间件
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
"""
|
||||
在响应返回前统一追加接口响应头
|
||||
"""
|
||||
response = await call_next(request)
|
||||
api_response_headers = getattr(request.state, 'api_response_headers', None)
|
||||
if api_response_headers:
|
||||
response.headers.update(api_response_headers)
|
||||
return response
|
||||
|
||||
|
||||
def add_api_response_header_middleware(app: FastAPI) -> None:
|
||||
"""
|
||||
添加接口响应头追加中间件
|
||||
|
||||
:param app: FastAPI对象
|
||||
"""
|
||||
app.add_middleware(ApiResponseHeaderMiddleware)
|
||||
@@ -0,0 +1,29 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import Response
|
||||
|
||||
from common.context import RequestContext
|
||||
|
||||
|
||||
class ContextCleanupMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
上下文清理中间件
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
"""
|
||||
在每个请求处理完成后清理上下文信息
|
||||
"""
|
||||
response = await call_next(request)
|
||||
# 请求处理完成后清理所有上下文变量
|
||||
RequestContext.clear_all()
|
||||
return response
|
||||
|
||||
|
||||
def add_context_cleanup_middleware(app: FastAPI) -> None:
|
||||
"""
|
||||
添加上下文清理中间件
|
||||
|
||||
:param app: FastAPI对象
|
||||
"""
|
||||
app.add_middleware(ContextCleanupMiddleware)
|
||||
@@ -0,0 +1,28 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
|
||||
def add_cors_middleware(app: FastAPI) -> None:
|
||||
"""
|
||||
添加跨域中间件
|
||||
|
||||
:param app: FastAPI对象
|
||||
:return:
|
||||
"""
|
||||
# 前端页面url
|
||||
origins = ['*']
|
||||
expose_headers = [
|
||||
'x-body-encrypted',
|
||||
'x-key-id',
|
||||
'x-encrypt-alg',
|
||||
]
|
||||
|
||||
# 后台api允许跨域
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=['*'],
|
||||
allow_headers=['*'],
|
||||
expose_headers=expose_headers,
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import Response
|
||||
|
||||
from utils.client_ip_util import ClientIPUtil
|
||||
from utils.log_util import logger
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
|
||||
class DemoModeMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
演示模式中间件
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
"""
|
||||
演示模式下拦截指定请求
|
||||
"""
|
||||
url_path = str(request.url)
|
||||
method = request.method.lower()
|
||||
intercept_url_list = [
|
||||
'system/user',
|
||||
'system/role',
|
||||
'system/menu',
|
||||
'system/dept',
|
||||
'system/post',
|
||||
'system/dict',
|
||||
'system/config',
|
||||
'system/notice',
|
||||
'monitor/operlog',
|
||||
'monitor/logininfor',
|
||||
'monitor/online',
|
||||
'monitor/job',
|
||||
'monitor/jobLog',
|
||||
'monitor/cache',
|
||||
'ai/model',
|
||||
'ai/chat',
|
||||
]
|
||||
|
||||
for item in intercept_url_list:
|
||||
if (url_path.startswith(f'{request.base_url!s}{item}') and method != 'get') or url_path.startswith(
|
||||
(
|
||||
f'{request.base_url!s}common',
|
||||
f'{request.base_url!s}register',
|
||||
f'{request.base_url!s}tool/gen/createTable',
|
||||
)
|
||||
):
|
||||
operate_ip = ClientIPUtil.get_client_ip(request)
|
||||
logger.warning(
|
||||
'请求IP:{}||请求API:{}||请求方法:{}||请求结果:演示模式,不允许操作!', operate_ip, url_path, method
|
||||
)
|
||||
return ResponseUtil.failure(msg='演示模式,不允许操作!')
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
|
||||
def add_demo_mode_middleware(app: FastAPI) -> None:
|
||||
"""
|
||||
添加演示模式中间件
|
||||
|
||||
:param app: FastAPI对象
|
||||
"""
|
||||
app.add_middleware(DemoModeMiddleware)
|
||||
@@ -0,0 +1,12 @@
|
||||
from fastapi import FastAPI
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
|
||||
|
||||
def add_gzip_middleware(app: FastAPI) -> None:
|
||||
"""
|
||||
添加gzip压缩中间件
|
||||
|
||||
:param app: FastAPI对象
|
||||
:return:
|
||||
"""
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=9)
|
||||
@@ -0,0 +1,31 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from config.env import AppConfig
|
||||
from middlewares.api_response_header_middleware import add_api_response_header_middleware
|
||||
from middlewares.context_middleware import add_context_cleanup_middleware
|
||||
from middlewares.cors_middleware import add_cors_middleware
|
||||
from middlewares.demo_mode_middleware import add_demo_mode_middleware
|
||||
from middlewares.gzip_middleware import add_gzip_middleware
|
||||
from middlewares.trace_middleware import add_trace_middleware
|
||||
from middlewares.transport_crypto_middleware import add_transport_crypto_middleware
|
||||
|
||||
|
||||
def handle_middleware(app: FastAPI) -> None:
|
||||
"""
|
||||
全局中间件处理
|
||||
"""
|
||||
# 加载上下文清理中间件
|
||||
add_context_cleanup_middleware(app)
|
||||
# 加载跨域中间件
|
||||
add_cors_middleware(app)
|
||||
# 加载gzip压缩中间件
|
||||
add_gzip_middleware(app)
|
||||
# 加载接口响应头追加中间件
|
||||
add_api_response_header_middleware(app)
|
||||
# 加载trace中间件
|
||||
add_trace_middleware(app)
|
||||
if AppConfig.app_demo_mode:
|
||||
# 加载演示模式中间件
|
||||
add_demo_mode_middleware(app)
|
||||
# 加载传输层请求解密/响应加密中间件
|
||||
add_transport_crypto_middleware(app)
|
||||
@@ -0,0 +1,18 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .ctx import TraceCtx
|
||||
from .middle import TraceASGIMiddleware
|
||||
|
||||
__all__ = ('TraceASGIMiddleware', 'TraceCtx')
|
||||
|
||||
__version__ = '0.1.0'
|
||||
|
||||
|
||||
def add_trace_middleware(app: FastAPI) -> None:
|
||||
"""
|
||||
添加trace中间件
|
||||
|
||||
:param app: FastAPI对象
|
||||
:return:
|
||||
"""
|
||||
app.add_middleware(TraceASGIMiddleware)
|
||||
@@ -0,0 +1,64 @@
|
||||
import contextvars
|
||||
from uuid import uuid4
|
||||
|
||||
CTX_TRACE_ID: contextvars.ContextVar[str] = contextvars.ContextVar('trace-id', default='')
|
||||
CTX_REQUEST_ID: contextvars.ContextVar[str] = contextvars.ContextVar('request-id', default='')
|
||||
CTX_SPAN_ID: contextvars.ContextVar[str] = contextvars.ContextVar('span-id', default='')
|
||||
CTX_REQUEST_PATH: contextvars.ContextVar[str] = contextvars.ContextVar('request-path', default='')
|
||||
CTX_REQUEST_METHOD: contextvars.ContextVar[str] = contextvars.ContextVar('request-method', default='')
|
||||
|
||||
|
||||
class TraceCtx:
|
||||
@staticmethod
|
||||
def set_trace_id() -> str:
|
||||
_id = uuid4().hex
|
||||
CTX_TRACE_ID.set(_id)
|
||||
return _id
|
||||
|
||||
@staticmethod
|
||||
def get_trace_id() -> str:
|
||||
return CTX_TRACE_ID.get()
|
||||
|
||||
@staticmethod
|
||||
def set_request_id() -> str:
|
||||
_id = uuid4().hex
|
||||
CTX_REQUEST_ID.set(_id)
|
||||
return _id
|
||||
|
||||
@staticmethod
|
||||
def get_request_id() -> str:
|
||||
return CTX_REQUEST_ID.get()
|
||||
|
||||
@staticmethod
|
||||
def set_span_id() -> str:
|
||||
_id = uuid4().hex
|
||||
CTX_SPAN_ID.set(_id)
|
||||
return _id
|
||||
|
||||
@staticmethod
|
||||
def get_span_id() -> str:
|
||||
return CTX_SPAN_ID.get()
|
||||
|
||||
@staticmethod
|
||||
def set_request_path(path: str) -> None:
|
||||
CTX_REQUEST_PATH.set(path)
|
||||
|
||||
@staticmethod
|
||||
def get_request_path() -> str:
|
||||
return CTX_REQUEST_PATH.get()
|
||||
|
||||
@staticmethod
|
||||
def set_request_method(method: str) -> None:
|
||||
CTX_REQUEST_METHOD.set(method)
|
||||
|
||||
@staticmethod
|
||||
def get_request_method() -> str:
|
||||
return CTX_REQUEST_METHOD.get()
|
||||
|
||||
@staticmethod
|
||||
def clear() -> None:
|
||||
CTX_TRACE_ID.set('')
|
||||
CTX_REQUEST_ID.set('')
|
||||
CTX_SPAN_ID.set('')
|
||||
CTX_REQUEST_PATH.set('')
|
||||
CTX_REQUEST_METHOD.set('')
|
||||
@@ -0,0 +1,46 @@
|
||||
from functools import wraps
|
||||
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from .ctx import TraceCtx
|
||||
from .span import Span, get_current_span
|
||||
|
||||
|
||||
class TraceASGIMiddleware:
|
||||
"""
|
||||
fastapi-example:
|
||||
app = FastAPI()
|
||||
app.add_middleware(TraceASGIMiddleware)
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
@staticmethod
|
||||
async def my_receive(receive: Receive, span: Span) -> Receive:
|
||||
await span.request_before()
|
||||
|
||||
@wraps(receive)
|
||||
async def my_receive() -> Message:
|
||||
message = await receive()
|
||||
await span.request_after(message)
|
||||
return message
|
||||
|
||||
return my_receive
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope['type'] != 'http':
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
async with get_current_span(scope) as span:
|
||||
handle_outgoing_receive = await self.my_receive(receive, span)
|
||||
|
||||
async def handle_outgoing_request(message: 'Message') -> None:
|
||||
await span.response(message)
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self.app(scope, handle_outgoing_receive, handle_outgoing_request)
|
||||
finally:
|
||||
TraceCtx.clear()
|
||||
@@ -0,0 +1,54 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from starlette.types import Message, Scope
|
||||
|
||||
from .ctx import TraceCtx
|
||||
|
||||
|
||||
class Span:
|
||||
"""
|
||||
整个http生命周期:
|
||||
request(before) --> request(after) --> response(before) --> response(after)
|
||||
"""
|
||||
|
||||
def __init__(self, scope: Scope) -> None:
|
||||
self.scope = scope
|
||||
|
||||
async def request_before(self) -> None:
|
||||
"""
|
||||
request_before: 处理header信息等, 如记录请求体信息
|
||||
"""
|
||||
TraceCtx.set_trace_id()
|
||||
TraceCtx.set_request_id()
|
||||
TraceCtx.set_span_id()
|
||||
TraceCtx.set_request_path(self.scope.get('path', ''))
|
||||
TraceCtx.set_request_method(self.scope.get('method', ''))
|
||||
|
||||
async def request_after(self, message: Message) -> Message:
|
||||
"""
|
||||
request_after: 处理请求bytes, 如记录请求参数
|
||||
|
||||
example:
|
||||
message: {'type': 'http.request', 'body': b'{\r\n "name": "\xe8\x8b\x8f\xe8\x8b\x8f\xe8\x8b\x8f"\r\n}', 'more_body': False}
|
||||
"""
|
||||
return message
|
||||
|
||||
async def response(self, message: Message) -> Message:
|
||||
"""
|
||||
if message['type'] == "http.response.start": -----> request-before
|
||||
pass
|
||||
if message['type'] == "http.response.body": -----> request-after
|
||||
message.get('body', b'')
|
||||
pass
|
||||
"""
|
||||
if message['type'] == 'http.response.start':
|
||||
message['headers'].append((b'request-id', TraceCtx.get_request_id().encode()))
|
||||
message['headers'].append((b'trace-id', TraceCtx.get_trace_id().encode()))
|
||||
message['headers'].append((b'span-id', TraceCtx.get_span_id().encode()))
|
||||
return message
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_current_span(scope: Scope) -> AsyncGenerator[Span, None]:
|
||||
yield Span(scope)
|
||||
@@ -0,0 +1,757 @@
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from urllib.parse import parse_qs, urlencode
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.datastructures import Headers, QueryParams
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from common.constant import HttpStatusConstant
|
||||
from config.env import AppConfig, TransportCryptoConfig
|
||||
from utils.transport_crypto_util import (
|
||||
DecryptedTransportEnvelope,
|
||||
TransportCryptoMonitorUtil,
|
||||
TransportCryptoUtil,
|
||||
TransportSecurityUtil,
|
||||
)
|
||||
|
||||
|
||||
class TransportCryptoMiddleware:
|
||||
"""
|
||||
传输层请求解密与响应加密中间件
|
||||
"""
|
||||
|
||||
_ENCRYPT_REQUEST_HEADER = 'x-transport-encrypt'
|
||||
_ENCRYPT_RESPONSE_HEADER = 'x-body-encrypted'
|
||||
_ENCRYPT_ALG_HEADER = 'x-encrypt-alg'
|
||||
_ENCRYPT_KID_HEADER = 'x-key-id'
|
||||
_MONITOR_REQUEST_MODE_HEADER = 'x-transport-request-mode'
|
||||
_MONITOR_RESPONSE_MODE_HEADER = 'x-transport-response-mode'
|
||||
_MONITOR_STATUS_HEADER = 'x-transport-crypto-status'
|
||||
_MONITOR_KID_HEADER = 'x-transport-key-id'
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
"""
|
||||
初始化传输层加解密中间件
|
||||
|
||||
:param app: FastAPI/Starlette应用对象
|
||||
:return: None
|
||||
"""
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
"""
|
||||
拦截HTTP请求,按配置执行请求解密与响应加密
|
||||
|
||||
:param scope: 当前ASGI请求作用域
|
||||
:param receive: ASGI receive函数
|
||||
:param send: ASGI send函数
|
||||
:return: None
|
||||
"""
|
||||
if scope['type'] != 'http' or not TransportCryptoConfig.transport_crypto_enabled:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
current_app = scope.get('app')
|
||||
path = self._normalize_path(str(scope.get('path', '')))
|
||||
if (
|
||||
self._is_excluded_path(path)
|
||||
or TransportCryptoConfig.transport_crypto_mode == 'off'
|
||||
or not self._is_enabled_path(path)
|
||||
):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = Headers(scope=scope)
|
||||
request_encrypted = headers.get(self._ENCRYPT_REQUEST_HEADER) == '1'
|
||||
request_required = TransportCryptoConfig.transport_crypto_mode == 'required' or self._is_required_path(path)
|
||||
|
||||
if request_required and not request_encrypted:
|
||||
await TransportCryptoMonitorUtil.record_plain_request(current_app)
|
||||
await TransportCryptoMonitorUtil.record_required_rejected(
|
||||
current_app, str(scope.get('method', '')).upper(), path
|
||||
)
|
||||
await TransportCryptoMonitorUtil.record_plain_response(current_app)
|
||||
await self._send_error_response(
|
||||
scope,
|
||||
receive,
|
||||
send,
|
||||
'当前接口要求使用加密传输',
|
||||
headers=self._build_monitor_headers(
|
||||
request_mode='plain',
|
||||
response_mode='plain',
|
||||
crypto_status='required_missing',
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
if not request_encrypted:
|
||||
await TransportCryptoMonitorUtil.record_plain_request(current_app)
|
||||
response_observer = self._build_passthrough_response_observer(
|
||||
app=current_app,
|
||||
send=send,
|
||||
request_mode='plain',
|
||||
crypto_status='pass_through',
|
||||
)
|
||||
await self.app(scope, receive, response_observer)
|
||||
return
|
||||
|
||||
body = await self._read_body(receive)
|
||||
request = Request(scope, receive=self._build_receive(body))
|
||||
try:
|
||||
decrypted_scope, decrypted_body, crypto_context = await self._decrypt_request(scope, request, headers, body)
|
||||
await TransportCryptoMonitorUtil.record_encrypted_request(current_app, str(crypto_context['kid']))
|
||||
await TransportCryptoMonitorUtil.record_decrypt_success(current_app, str(crypto_context['kid']))
|
||||
except Exception as exc:
|
||||
error_crypto_context = self._build_error_crypto_context(scope, headers, body)
|
||||
error_kid = (
|
||||
str(error_crypto_context['kid'])
|
||||
if error_crypto_context
|
||||
else self._extract_request_kid(scope, headers, body)
|
||||
)
|
||||
await TransportCryptoMonitorUtil.record_encrypted_request(current_app, error_kid)
|
||||
failure_reason = self._classify_failure_reason(str(exc))
|
||||
await TransportCryptoMonitorUtil.record_decrypt_failure(
|
||||
current_app,
|
||||
method=str(scope.get('method', '')).upper(),
|
||||
path=path,
|
||||
reason=failure_reason,
|
||||
kid=error_kid,
|
||||
)
|
||||
if error_crypto_context:
|
||||
await TransportCryptoMonitorUtil.record_encrypted_response(current_app, error_kid, is_error=True)
|
||||
else:
|
||||
await TransportCryptoMonitorUtil.record_plain_response(current_app)
|
||||
await self._send_error_response(
|
||||
scope,
|
||||
receive,
|
||||
send,
|
||||
str(exc) or '加密请求解析失败',
|
||||
error_crypto_context,
|
||||
headers=self._build_monitor_headers(
|
||||
request_mode='encrypted',
|
||||
response_mode='encrypted' if error_crypto_context else 'plain',
|
||||
crypto_status=failure_reason,
|
||||
kid=error_kid,
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
async def send_wrapper(message: Message) -> None:
|
||||
await response_encryptor(message)
|
||||
|
||||
response_encryptor = self._build_response_encryptor(
|
||||
app=current_app,
|
||||
scope=decrypted_scope,
|
||||
send=send,
|
||||
crypto_context=crypto_context,
|
||||
)
|
||||
await self.app(decrypted_scope, self._build_receive(decrypted_body), send_wrapper)
|
||||
|
||||
async def _decrypt_request(
|
||||
self,
|
||||
scope: Scope,
|
||||
request: Request,
|
||||
headers: Headers,
|
||||
body: bytes,
|
||||
) -> tuple[Scope, bytes, dict[str, str | bytes | bool]]:
|
||||
"""
|
||||
解密请求并回写解密后的headers、query和body
|
||||
|
||||
:param scope: 当前ASGI请求作用域
|
||||
:param request: FastAPI请求对象
|
||||
:param headers: 当前请求头对象
|
||||
:param body: 原始请求体字节串
|
||||
:return: 解密后的scope、请求体与加密上下文
|
||||
"""
|
||||
new_scope = dict(scope)
|
||||
new_scope['state'] = dict(scope.get('state', {}))
|
||||
new_scope['headers'] = self._remove_header(new_scope.get('headers', []), b'accept-encoding')
|
||||
content_type = headers.get('content-type', '')
|
||||
|
||||
query_envelope = self._extract_query_envelope(new_scope)
|
||||
body_envelope = self._extract_body_envelope(content_type, body)
|
||||
|
||||
if query_envelope is None and body_envelope is None:
|
||||
raise ValueError('未找到可解密的请求载荷')
|
||||
|
||||
crypto_context: dict[str, str | bytes | bool] | None = None
|
||||
if query_envelope is not None:
|
||||
decrypted_query = await self._decrypt_envelope(request, scope, query_envelope)
|
||||
query_payload = self._loads_json_mapping(decrypted_query.plaintext.decode('utf-8'))
|
||||
new_scope['query_string'] = urlencode(query_payload, doseq=True).encode('utf-8')
|
||||
crypto_context = self._build_crypto_context(decrypted_query)
|
||||
|
||||
decrypted_body = body
|
||||
if body_envelope is not None:
|
||||
decrypted_body_payload = await self._decrypt_envelope(request, scope, body_envelope)
|
||||
if crypto_context and crypto_context['kid'] != decrypted_body_payload.kid:
|
||||
raise ValueError('请求中存在不一致的密钥版本')
|
||||
if crypto_context and crypto_context['aes_key'] != decrypted_body_payload.aes_key:
|
||||
raise ValueError('请求中存在不一致的会话密钥')
|
||||
if crypto_context is None:
|
||||
crypto_context = self._build_crypto_context(decrypted_body_payload)
|
||||
|
||||
if 'application/x-www-form-urlencoded' in content_type:
|
||||
form_payload = self._loads_json_mapping(decrypted_body_payload.plaintext.decode('utf-8'))
|
||||
decrypted_body = urlencode(form_payload, doseq=True).encode('utf-8')
|
||||
else:
|
||||
decrypted_body = decrypted_body_payload.plaintext
|
||||
new_scope['headers'] = self._replace_header(
|
||||
new_scope.get('headers', []), b'content-length', str(len(decrypted_body)).encode('utf-8')
|
||||
)
|
||||
|
||||
if crypto_context is None:
|
||||
raise ValueError('加密请求缺少可用的密钥上下文')
|
||||
|
||||
new_scope['state']['transport_crypto_context'] = crypto_context
|
||||
return new_scope, decrypted_body, crypto_context
|
||||
|
||||
async def _decrypt_envelope(
|
||||
self,
|
||||
request: Request,
|
||||
scope: Scope,
|
||||
envelope: dict[str, str],
|
||||
) -> DecryptedTransportEnvelope:
|
||||
"""
|
||||
解密单个请求信封并执行时间窗、防重放校验
|
||||
|
||||
:param request: 当前请求对象
|
||||
:param scope: 当前ASGI请求作用域
|
||||
:param envelope: 请求信封字典
|
||||
:return: 解密后的请求信封对象
|
||||
"""
|
||||
decrypted_payload = TransportCryptoUtil.decrypt_envelope(
|
||||
envelope,
|
||||
expected_method=str(scope.get('method', '')).upper(),
|
||||
expected_path=self._normalize_path(str(scope.get('path', ''))),
|
||||
)
|
||||
TransportSecurityUtil.validate_timestamp(decrypted_payload.timestamp)
|
||||
await TransportSecurityUtil.validate_replay(request, decrypted_payload.kid, decrypted_payload.nonce)
|
||||
return decrypted_payload
|
||||
|
||||
def _extract_query_envelope(self, scope: Scope) -> dict[str, str] | None:
|
||||
"""
|
||||
从查询参数中提取加密信封
|
||||
|
||||
:param scope: 当前ASGI请求作用域
|
||||
:return: 查询参数中的信封字典,不存在时返回None
|
||||
"""
|
||||
query_params = QueryParams(scope.get('query_string', b'').decode('utf-8'))
|
||||
encrypted_query = query_params.get('__enc')
|
||||
if not encrypted_query:
|
||||
return None
|
||||
return TransportCryptoUtil.decode_query_envelope(encrypted_query)
|
||||
|
||||
def _extract_body_envelope(self, content_type: str, body: bytes) -> dict[str, str] | None:
|
||||
"""
|
||||
根据内容类型从请求体中提取加密信封
|
||||
|
||||
:param content_type: 当前请求内容类型
|
||||
:param body: 原始请求体字节串
|
||||
:return: 请求体中的信封字典,不存在时返回None
|
||||
"""
|
||||
if not body or 'multipart/form-data' in content_type:
|
||||
return None
|
||||
|
||||
if 'application/json' in content_type:
|
||||
body_payload = json.loads(body.decode('utf-8'))
|
||||
if not isinstance(body_payload, dict):
|
||||
raise ValueError('加密请求体格式不合法')
|
||||
return body_payload
|
||||
|
||||
if 'application/x-www-form-urlencoded' in content_type:
|
||||
parsed_form = parse_qs(body.decode('utf-8'), keep_blank_values=True)
|
||||
body_envelope = {
|
||||
key: values[-1] if isinstance(values, list) else values for key, values in parsed_form.items()
|
||||
}
|
||||
aad = body_envelope.get('aad')
|
||||
if isinstance(aad, str) and aad:
|
||||
try:
|
||||
parsed_aad = json.loads(aad)
|
||||
if isinstance(parsed_aad, dict):
|
||||
body_envelope['aad'] = parsed_aad
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return body_envelope
|
||||
|
||||
return None
|
||||
|
||||
def _build_response_encryptor(
|
||||
self,
|
||||
app: FastAPI | None,
|
||||
scope: Scope,
|
||||
send: Send,
|
||||
crypto_context: dict[str, str | bytes | bool],
|
||||
) -> Callable[[Message], Awaitable[None]]:
|
||||
"""
|
||||
构建响应加密发送器,仅对JSON响应执行加密
|
||||
|
||||
:param scope: 当前ASGI请求作用域
|
||||
:param send: ASGI send函数
|
||||
:param crypto_context: 当前请求加密上下文
|
||||
:return: 包装后的ASGI send函数
|
||||
"""
|
||||
response_start_message: Message | None = None
|
||||
buffered_json_body: list[bytes] = []
|
||||
should_buffer_json = False
|
||||
|
||||
async def _encrypt_response(message: Message) -> None:
|
||||
nonlocal response_start_message, should_buffer_json
|
||||
|
||||
if message['type'] == 'http.response.start':
|
||||
response_start_message = message
|
||||
headers = Headers(raw=message.get('headers', []))
|
||||
content_type = headers.get('content-type', '')
|
||||
should_buffer_json = 'application/json' in content_type
|
||||
if not should_buffer_json:
|
||||
await TransportCryptoMonitorUtil.record_plain_response(app)
|
||||
await send(
|
||||
{
|
||||
**message,
|
||||
'headers': self._merge_response_headers(
|
||||
message.get('headers', []),
|
||||
self._build_monitor_headers(
|
||||
request_mode='encrypted',
|
||||
response_mode='plain',
|
||||
crypto_status='ok',
|
||||
kid=str(crypto_context['kid']),
|
||||
),
|
||||
),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
if message['type'] != 'http.response.body':
|
||||
await send(message)
|
||||
return
|
||||
|
||||
if not should_buffer_json or response_start_message is None:
|
||||
await send(message)
|
||||
return
|
||||
|
||||
buffered_json_body.append(message.get('body', b''))
|
||||
if message.get('more_body', False):
|
||||
return
|
||||
|
||||
encrypted_body = TransportCryptoUtil.encrypt_response_body(
|
||||
aes_key=crypto_context['aes_key'],
|
||||
payload=b''.join(buffered_json_body),
|
||||
kid=str(crypto_context['kid']),
|
||||
method=str(scope.get('method', '')),
|
||||
path=self._normalize_path(str(scope.get('path', ''))),
|
||||
)
|
||||
response_headers = self._replace_header(
|
||||
response_start_message.get('headers', []),
|
||||
b'content-length',
|
||||
str(len(encrypted_body)).encode('utf-8'),
|
||||
)
|
||||
response_headers = self._replace_header(response_headers, b'content-type', b'application/json')
|
||||
response_headers = self._replace_header(
|
||||
response_headers, self._ENCRYPT_RESPONSE_HEADER.encode('utf-8'), b'1'
|
||||
)
|
||||
response_headers = self._replace_header(
|
||||
response_headers,
|
||||
self._ENCRYPT_ALG_HEADER.encode('utf-8'),
|
||||
TransportCryptoUtil.get_response_envelope_algorithm().encode('utf-8'),
|
||||
)
|
||||
response_headers = self._replace_header(
|
||||
response_headers,
|
||||
self._ENCRYPT_KID_HEADER.encode('utf-8'),
|
||||
str(crypto_context['kid']).encode('utf-8'),
|
||||
)
|
||||
response_headers = self._merge_response_headers(
|
||||
response_headers,
|
||||
self._build_monitor_headers(
|
||||
request_mode='encrypted',
|
||||
response_mode='encrypted',
|
||||
crypto_status='ok',
|
||||
kid=str(crypto_context['kid']),
|
||||
),
|
||||
)
|
||||
await TransportCryptoMonitorUtil.record_encrypted_response(app, str(crypto_context['kid']))
|
||||
await send({**response_start_message, 'headers': response_headers})
|
||||
await send({'type': 'http.response.body', 'body': encrypted_body, 'more_body': False})
|
||||
|
||||
return _encrypt_response
|
||||
|
||||
def _build_passthrough_response_observer(
|
||||
self,
|
||||
app: FastAPI | None,
|
||||
send: Send,
|
||||
request_mode: str,
|
||||
crypto_status: str,
|
||||
kid: str | None = None,
|
||||
) -> Callable[[Message], Awaitable[None]]:
|
||||
"""
|
||||
构建明文响应观察器,为响应追加监控诊断头
|
||||
|
||||
:param send: ASGI send函数
|
||||
:param request_mode: 请求传输模式
|
||||
:param crypto_status: 当前传输层处理状态
|
||||
:param kid: 可选的密钥版本
|
||||
:return: 包装后的ASGI send函数
|
||||
"""
|
||||
has_recorded_response = False
|
||||
|
||||
async def _observe_response(message: Message) -> None:
|
||||
nonlocal has_recorded_response
|
||||
|
||||
if message['type'] == 'http.response.start':
|
||||
if not has_recorded_response:
|
||||
await TransportCryptoMonitorUtil.record_plain_response(app)
|
||||
has_recorded_response = True
|
||||
await send(
|
||||
{
|
||||
**message,
|
||||
'headers': self._merge_response_headers(
|
||||
message.get('headers', []),
|
||||
self._build_monitor_headers(
|
||||
request_mode=request_mode,
|
||||
response_mode='plain',
|
||||
crypto_status=crypto_status,
|
||||
kid=kid,
|
||||
),
|
||||
),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
await send(message)
|
||||
|
||||
return _observe_response
|
||||
|
||||
def _build_crypto_context(self, decrypted_payload: DecryptedTransportEnvelope) -> dict[str, str | bytes | bool]:
|
||||
"""
|
||||
从解密结果构建请求生命周期内的加密上下文
|
||||
|
||||
:param decrypted_payload: 解密后的请求信封对象
|
||||
:return: 请求加密上下文字典
|
||||
"""
|
||||
return {
|
||||
'active': True,
|
||||
'kid': decrypted_payload.kid,
|
||||
'aes_key': decrypted_payload.aes_key,
|
||||
}
|
||||
|
||||
def _build_error_crypto_context(
|
||||
self,
|
||||
scope: Scope,
|
||||
headers: Headers,
|
||||
body: bytes,
|
||||
) -> dict[str, str | bytes | bool] | None:
|
||||
"""
|
||||
尝试在解密失败场景下提取AES会话密钥,以便返回加密错误响应
|
||||
|
||||
:param scope: 当前ASGI请求作用域
|
||||
:param headers: 当前请求头对象
|
||||
:param body: 原始请求体字节串
|
||||
:return: 可用于构造加密错误响应的上下文字典,失败时返回None
|
||||
"""
|
||||
content_type = headers.get('content-type', '')
|
||||
try:
|
||||
query_envelope = self._extract_query_envelope(scope)
|
||||
body_envelope = self._extract_body_envelope(content_type, body)
|
||||
envelope = body_envelope or query_envelope
|
||||
if envelope is None:
|
||||
return None
|
||||
TransportCryptoUtil._extract_and_validate_aad(
|
||||
envelope,
|
||||
expected_method=str(scope.get('method', '')).upper(),
|
||||
expected_path=self._normalize_path(str(scope.get('path', ''))),
|
||||
)
|
||||
return {
|
||||
'active': True,
|
||||
'kid': str(envelope['kid']),
|
||||
'aes_key': TransportCryptoUtil.decrypt_request_key(envelope),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _extract_request_kid(self, scope: Scope, headers: Headers, body: bytes) -> str | None:
|
||||
"""
|
||||
尝试从原始请求信封中提取密钥版本
|
||||
|
||||
:param scope: 当前ASGI请求作用域
|
||||
:param headers: 当前请求头对象
|
||||
:param body: 原始请求体字节串
|
||||
:return: 密钥版本,不存在时返回None
|
||||
"""
|
||||
content_type = headers.get('content-type', '')
|
||||
try:
|
||||
query_envelope = self._extract_query_envelope(scope)
|
||||
body_envelope = self._extract_body_envelope(content_type, body)
|
||||
envelope = body_envelope or query_envelope
|
||||
except Exception:
|
||||
return None
|
||||
if envelope is None or not envelope.get('kid'):
|
||||
return None
|
||||
return str(envelope['kid'])
|
||||
|
||||
@staticmethod
|
||||
def _loads_json_mapping(payload: str) -> dict:
|
||||
"""
|
||||
将JSON字符串解析为字典,并限制结果必须为JSON对象
|
||||
|
||||
:param payload: JSON字符串
|
||||
:return: 解析后的字典对象
|
||||
"""
|
||||
json_payload = json.loads(payload)
|
||||
if not isinstance(json_payload, dict):
|
||||
raise ValueError('解密后的请求载荷必须为JSON对象')
|
||||
return json_payload
|
||||
|
||||
@staticmethod
|
||||
async def _read_body(receive: Receive) -> bytes:
|
||||
"""
|
||||
从ASGI receive中读取完整请求体
|
||||
|
||||
:param receive: ASGI receive函数
|
||||
:return: 完整请求体字节串
|
||||
"""
|
||||
body_chunks: list[bytes] = []
|
||||
more_body = True
|
||||
while more_body:
|
||||
message = await receive()
|
||||
if message['type'] != 'http.request':
|
||||
continue
|
||||
body_chunks.append(message.get('body', b''))
|
||||
more_body = message.get('more_body', False)
|
||||
return b''.join(body_chunks)
|
||||
|
||||
@staticmethod
|
||||
def _build_receive(body: bytes) -> Receive:
|
||||
"""
|
||||
根据指定请求体重建一次性可消费的ASGI receive函数
|
||||
|
||||
:param body: 需要回放的请求体字节串
|
||||
:return: 重建后的ASGI receive函数
|
||||
"""
|
||||
has_been_called = False
|
||||
|
||||
async def _receive() -> Message:
|
||||
nonlocal has_been_called
|
||||
if has_been_called:
|
||||
return {'type': 'http.request', 'body': b'', 'more_body': False}
|
||||
has_been_called = True
|
||||
return {'type': 'http.request', 'body': body, 'more_body': False}
|
||||
|
||||
return _receive
|
||||
|
||||
@staticmethod
|
||||
def _replace_header(headers: list[tuple[bytes, bytes]], key: bytes, value: bytes) -> list[tuple[bytes, bytes]]:
|
||||
"""
|
||||
替换或新增指定响应头
|
||||
|
||||
:param headers: 原始请求/响应头列表
|
||||
:param key: 头名称
|
||||
:param value: 头值
|
||||
:return: 替换后的头列表
|
||||
"""
|
||||
normalized_key = key.lower()
|
||||
filtered_headers = [
|
||||
(header_key, header_value) for header_key, header_value in headers if header_key.lower() != normalized_key
|
||||
]
|
||||
filtered_headers.append((key, value))
|
||||
return filtered_headers
|
||||
|
||||
@staticmethod
|
||||
def _remove_header(headers: list[tuple[bytes, bytes]], key: bytes) -> list[tuple[bytes, bytes]]:
|
||||
"""
|
||||
删除指定请求头
|
||||
|
||||
:param headers: 原始请求头列表
|
||||
:param key: 头名称
|
||||
:return: 删除后的头列表
|
||||
"""
|
||||
normalized_key = key.lower()
|
||||
return [
|
||||
(header_key, header_value) for header_key, header_value in headers if header_key.lower() != normalized_key
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_path(path: str) -> str:
|
||||
"""
|
||||
标准化请求路径,剥离应用根路径前缀
|
||||
|
||||
:param path: 原始请求路径
|
||||
:return: 标准化后的业务路径
|
||||
"""
|
||||
app_root_path = AppConfig.app_root_path
|
||||
if app_root_path and path.startswith(app_root_path):
|
||||
normalized_path = path[len(app_root_path) :]
|
||||
return normalized_path or '/'
|
||||
return path or '/'
|
||||
|
||||
@classmethod
|
||||
def _merge_response_headers(
|
||||
cls,
|
||||
headers: list[tuple[bytes, bytes]],
|
||||
extra_headers: dict[str, str],
|
||||
) -> list[tuple[bytes, bytes]]:
|
||||
"""
|
||||
将字符串响应头批量写回到原始headers列表
|
||||
|
||||
:param headers: 原始请求/响应头列表
|
||||
:param extra_headers: 需要追加的响应头
|
||||
:return: 合并后的响应头列表
|
||||
"""
|
||||
merged_headers = headers
|
||||
for header_key, header_value in extra_headers.items():
|
||||
merged_headers = cls._replace_header(
|
||||
merged_headers, header_key.encode('utf-8'), header_value.encode('utf-8')
|
||||
)
|
||||
return merged_headers
|
||||
|
||||
@classmethod
|
||||
def _build_monitor_headers(
|
||||
cls,
|
||||
request_mode: str,
|
||||
response_mode: str,
|
||||
crypto_status: str,
|
||||
kid: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
构建传输层加解密监控响应头
|
||||
|
||||
:param request_mode: 请求传输模式
|
||||
:param response_mode: 响应传输模式
|
||||
:param crypto_status: 当前传输层处理状态
|
||||
:param kid: 可选的密钥版本
|
||||
:return: 监控响应头字典
|
||||
"""
|
||||
headers = {
|
||||
cls._MONITOR_REQUEST_MODE_HEADER: request_mode,
|
||||
cls._MONITOR_RESPONSE_MODE_HEADER: response_mode,
|
||||
cls._MONITOR_STATUS_HEADER: crypto_status,
|
||||
}
|
||||
if kid:
|
||||
headers[cls._MONITOR_KID_HEADER] = kid
|
||||
return headers
|
||||
|
||||
@classmethod
|
||||
async def _send_error_response(
|
||||
cls,
|
||||
scope: Scope,
|
||||
receive: Receive,
|
||||
send: Send,
|
||||
message: str,
|
||||
crypto_context: dict[str, str | bytes | bool] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
发送错误响应,在存在AES会话密钥时优先返回加密错误响应
|
||||
|
||||
:param scope: 当前ASGI请求作用域
|
||||
:param receive: ASGI receive函数
|
||||
:param send: ASGI send函数
|
||||
:param message: 错误信息
|
||||
:param crypto_context: 可选的请求加密上下文
|
||||
:param headers: 需要追加的诊断响应头
|
||||
:return: None
|
||||
"""
|
||||
response_content = {'code': HttpStatusConstant.BAD_REQUEST, 'msg': message, 'success': False}
|
||||
response = JSONResponse(status_code=HttpStatusConstant.BAD_REQUEST, content=response_content)
|
||||
if crypto_context:
|
||||
encrypted_body = TransportCryptoUtil.encrypt_response_body(
|
||||
aes_key=crypto_context['aes_key'],
|
||||
payload=json.dumps(response_content, ensure_ascii=False).encode('utf-8'),
|
||||
kid=str(crypto_context['kid']),
|
||||
method=str(scope.get('method', '')),
|
||||
path=cls._normalize_path(str(scope.get('path', ''))),
|
||||
)
|
||||
response.body = encrypted_body
|
||||
response.init_headers()
|
||||
response.headers[cls._ENCRYPT_RESPONSE_HEADER] = '1'
|
||||
response.headers[cls._ENCRYPT_ALG_HEADER] = TransportCryptoUtil.get_response_envelope_algorithm()
|
||||
response.headers[cls._ENCRYPT_KID_HEADER] = str(crypto_context['kid'])
|
||||
if headers:
|
||||
response.headers.update(headers)
|
||||
await response(scope, receive, send)
|
||||
|
||||
@staticmethod
|
||||
def _classify_failure_reason(message: str) -> str:
|
||||
"""
|
||||
根据异常信息归类传输层失败原因
|
||||
|
||||
:param message: 原始异常信息
|
||||
:return: 失败原因分类编码
|
||||
"""
|
||||
if not message or message == '加密请求解析失败':
|
||||
return 'decrypt_failed'
|
||||
failure_reason_mapping = (
|
||||
('method/path与当前接口不匹配', 'aad_mismatch'),
|
||||
('缺少合法的aad', 'aad_invalid'),
|
||||
('已过期', 'timestamp_expired'),
|
||||
('缺少必要字段', 'envelope_fields_missing'),
|
||||
('协议版本不受支持', 'protocol_version_invalid'),
|
||||
('算法不受支持', 'algorithm_invalid'),
|
||||
('未找到可解密的请求载荷', 'envelope_missing'),
|
||||
('密钥版本', 'kid_mismatch'),
|
||||
)
|
||||
for reason_keyword, reason_code in failure_reason_mapping:
|
||||
if reason_keyword in message:
|
||||
return reason_code
|
||||
if '重复请求' in message or '重放' in message:
|
||||
return 'replay_detected'
|
||||
return 'decrypt_failed'
|
||||
|
||||
@classmethod
|
||||
def _is_excluded_path(cls, path: str) -> bool:
|
||||
"""
|
||||
判断当前路径是否在传输加密排除列表内
|
||||
|
||||
:param path: 当前请求路径
|
||||
:return: 是否命中排除列表
|
||||
"""
|
||||
excluded_paths = [
|
||||
excluded_path.strip()
|
||||
for excluded_path in TransportCryptoConfig.transport_crypto_exclude_paths.split(',')
|
||||
if excluded_path.strip()
|
||||
]
|
||||
return any(path == excluded_path or path.startswith(f'{excluded_path}/') for excluded_path in excluded_paths)
|
||||
|
||||
@classmethod
|
||||
def _is_required_path(cls, path: str) -> bool:
|
||||
"""
|
||||
判断当前路径是否在强制加密列表内
|
||||
|
||||
:param path: 当前请求路径
|
||||
:return: 是否命中强制加密列表
|
||||
"""
|
||||
required_paths = [
|
||||
required_path.strip()
|
||||
for required_path in TransportCryptoConfig.transport_crypto_required_paths.split(',')
|
||||
if required_path.strip()
|
||||
]
|
||||
if not required_paths:
|
||||
return False
|
||||
return any(path == required_path or path.startswith(f'{required_path}/') for required_path in required_paths)
|
||||
|
||||
@classmethod
|
||||
def _is_enabled_path(cls, path: str) -> bool:
|
||||
"""
|
||||
判断当前路径是否在启用传输加密的列表内
|
||||
|
||||
:param path: 当前请求路径
|
||||
:return: 当前路径是否启用传输加密
|
||||
"""
|
||||
enabled_paths = [
|
||||
enabled_path.strip()
|
||||
for enabled_path in TransportCryptoConfig.transport_crypto_enabled_paths.split(',')
|
||||
if enabled_path.strip()
|
||||
]
|
||||
if not enabled_paths:
|
||||
return True
|
||||
return any(path == enabled_path or path.startswith(f'{enabled_path}/') for enabled_path in enabled_paths)
|
||||
|
||||
|
||||
def add_transport_crypto_middleware(app: ASGIApp) -> None:
|
||||
"""
|
||||
添加传输层加解密中间件
|
||||
|
||||
:param app: FastAPI/Starlette应用对象
|
||||
:return: None
|
||||
"""
|
||||
app.add_middleware(TransportCryptoMiddleware)
|
||||
Reference in New Issue
Block a user