ShiErFeng/shierfeng-fastapi-backend/middlewares/transport_crypto_middleware.py
2026-07-24 09:43:10 +08:00

758 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)