Files
yaoyaoai/app/middleware/api_crypto.py
fengchuanhn@gmail.com 8a55976d00 11
2026-05-17 22:05:37 +08:00

184 lines
5.7 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.
"""对 /api/v1 请求体解密、响应体加密(需配置 API_AES_KEY
使用纯 ASGI 中间件,避免 BaseHTTPMiddleware 读取 body 后缓存导致路由拿不到明文。
"""
from __future__ import annotations
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from app.config import get_settings
from app.core.aes_crypto import (
ENCRYPTED_HEADER,
is_encrypted_envelope,
parse_aes_key,
unwrap_encrypted,
wrap_encrypted,
)
_API_PREFIX = "/api/v1"
_api_aes_key: bytes | None = None
_api_aes_key_initialized = False
def get_api_aes_key() -> bytes | None:
print("get_api_aes_key",get_settings().api_aes_key)
global _api_aes_key, _api_aes_key_initialized
if not _api_aes_key_initialized:
_api_aes_key = parse_aes_key(get_settings().api_aes_key)
_api_aes_key_initialized = True
return _api_aes_key
async def _read_body(receive: Receive) -> bytes:
body = b""
more = True
while more:
message = await receive()
if message["type"] != "http.request":
continue
body += message.get("body", b"")
more = message.get("more_body", False)
return body
def _make_receive(body: bytes) -> Receive:
sent = False
async def receive() -> Message:
nonlocal sent
if sent:
return {"type": "http.disconnect"}
sent = True
return {"type": "http.request", "body": body, "more_body": False}
return receive
def _scope_with_content_length(scope: Scope, length: int) -> Scope:
new_scope = dict(scope)
headers: list[tuple[bytes, bytes]] = []
for name, value in scope.get("headers", []):
if name.lower() != b"content-length":
headers.append((name, value))
headers.append((b"content-length", str(length).encode()))
new_scope["headers"] = headers
return new_scope
def _header_value(scope: Scope, name: str) -> str | None:
target = name.lower().encode()
for key, value in scope.get("headers", []):
if key.lower() == target:
return value.decode("latin-1")
return None
def _is_json_response(headers: list[tuple[bytes, bytes]]) -> bool:
for key, value in headers:
if key.lower() == b"content-type":
return b"json" in value.lower()
return False
def _filter_response_headers(headers: list[tuple[bytes, bytes]]) -> list[tuple[bytes, bytes]]:
skip = {b"content-length", b"content-encoding"}
return [(k, v) for k, v in headers if k.lower() not in skip]
async def _send_plain_json(send: Send, scope: Scope, status: int, payload: bytes) -> None:
await send(
{
"type": "http.response.start",
"status": status,
"headers": [(b"content-type", b"application/json; charset=utf-8")],
}
)
await send({"type": "http.response.body", "body": payload, "more_body": False})
def _encrypting_send(send: Send, key: bytes) -> Send:
status = 200
headers: list[tuple[bytes, bytes]] = []
chunks: list[bytes] = []
started = False
async def wrapper(message: Message) -> None:
nonlocal status, headers, started
if message["type"] == "http.response.start":
started = True
status = message["status"]
headers = list(message.get("headers", []))
return
if message["type"] != "http.response.body":
await send(message)
return
chunks.append(message.get("body", b""))
if message.get("more_body", False):
return
body = b"".join(chunks)
out_headers = _filter_response_headers(headers)
if _is_json_response(headers):
try:
body = wrap_encrypted(body, key)
except Exception as exc:
err = (
'{"ok":false,"message":"响应加密失败: '
+ str(exc).replace('"', '\\"')
+ '"}'
).encode("utf-8")
await _send_plain_json(send, {}, 500, err)
return
out_headers.append((ENCRYPTED_HEADER.encode(), b"1"))
out_headers.append((b"content-length", str(len(body)).encode()))
await send({"type": "http.response.start", "status": status, "headers": out_headers})
await send({"type": "http.response.body", "body": body, "more_body": False})
return wrapper
class ApiCryptoMiddleware:
"""纯 ASGI 中间件(勿使用 BaseHTTPMiddleware"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
key = get_api_aes_key()
path = scope.get("path", "")
if key is None or not path.startswith(_API_PREFIX):
await self.app(scope, receive, send)
return
method = scope.get("method", "GET")
if method in ("POST", "PUT", "PATCH"):
body = await _read_body(receive)
if (
body
and _header_value(scope, ENCRYPTED_HEADER) == "1"
and is_encrypted_envelope(body)
):
try:
body = unwrap_encrypted(body, key)
except Exception as exc:
err = f'{{"ok":false,"message":"请求解密失败: {exc}"}}'.encode("utf-8")
await _send_plain_json(send, scope, 400, err)
return
receive = _make_receive(body)
scope = _scope_with_content_length(scope, len(body))
await self.app(scope, receive, _encrypting_send(send, key))