api_crypto_middleware.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. """Web API 加解密中间件。"""
  2. from __future__ import annotations
  3. import asyncio
  4. import json
  5. import logging
  6. import time
  7. from typing import Any, Optional, Tuple
  8. from urllib.parse import urlencode
  9. import redis.asyncio as redis
  10. from jose import jwt
  11. from redis import exceptions as redis_exceptions
  12. from starlette.middleware.base import BaseHTTPMiddleware
  13. from starlette.requests import Request
  14. from starlette.responses import Response
  15. from app.core import api_crypto
  16. from app.core.config import settings
  17. from app.core.response import error_response
  18. logger = logging.getLogger("sqxp")
  19. _redis_client: Optional[redis.Redis] = None
  20. _local_nonce_cache: dict[str, float] = {}
  21. def _nonce_key(user_id: str, nonce: str) -> str:
  22. return f"api-enc:nonce:{user_id}:{nonce}"
  23. def _should_skip_path(path: str) -> bool:
  24. excludes = [x.strip() for x in (settings.API_ENCRYPT_EXCLUDE_PATHS or "").split(",") if x.strip()]
  25. return path in excludes
  26. def _is_web_api(path: str) -> bool:
  27. return path.startswith(f"{settings.API_V1_PREFIX}/web/")
  28. def _claims_from_auth(auth_header: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
  29. if not auth_header or not auth_header.startswith("Bearer "):
  30. return None, None
  31. token = auth_header[7:].strip()
  32. if not token:
  33. return None, None
  34. try:
  35. payload = jwt.decode(
  36. token,
  37. settings.SECRET_KEY,
  38. algorithms=[settings.ALGORITHM],
  39. options={"verify_signature": False, "verify_exp": False},
  40. )
  41. return payload.get("sub"), token
  42. except Exception:
  43. return None, token
  44. def _get_redis_client() -> Optional[redis.Redis]:
  45. global _redis_client
  46. if not settings.REDIS_HOST:
  47. return None
  48. if _redis_client is None:
  49. _redis_client = redis.Redis(
  50. host=settings.REDIS_HOST,
  51. port=settings.REDIS_PORT,
  52. db=settings.REDIS_DB,
  53. password=settings.REDIS_PASSWORD or None,
  54. socket_timeout=settings.REDIS_TIMEOUT_SEC,
  55. ssl=settings.REDIS_SSL_ENABLED,
  56. decode_responses=True,
  57. )
  58. return _redis_client
  59. async def _mark_nonce_once(user_id: str, nonce: str) -> bool:
  60. ttl_sec = max(30, int(settings.API_ENCRYPT_NONCE_TTL_SEC))
  61. key = _nonce_key(user_id, nonce)
  62. client = _get_redis_client()
  63. if client is not None:
  64. try:
  65. ok = await asyncio.wait_for(client.set(key, "1", ex=ttl_sec, nx=True), timeout=2.0)
  66. return bool(ok)
  67. except (asyncio.TimeoutError, redis_exceptions.RedisError):
  68. logger.warning("api crypto nonce redis unavailable, fallback to local memory")
  69. now = time.time()
  70. expire_before = now - ttl_sec
  71. stale_keys = [k for k, v in _local_nonce_cache.items() if v < expire_before]
  72. for stale in stale_keys:
  73. _local_nonce_cache.pop(stale, None)
  74. if key in _local_nonce_cache:
  75. return False
  76. _local_nonce_cache[key] = now
  77. return True
  78. def _json_error_response(code: int, message: str) -> Response:
  79. body = error_response(code, message)
  80. return Response(content=json.dumps(body, ensure_ascii=False), media_type="application/json", status_code=200)
  81. class ApiCryptoMiddleware(BaseHTTPMiddleware):
  82. async def dispatch(self, request: Request, call_next):
  83. if not settings.API_ENCRYPT_ENABLED:
  84. return await call_next(request)
  85. if not settings.API_ENCRYPT_FIXED_SECRET:
  86. if settings.API_ENCRYPT_FAIL_OPEN:
  87. logger.warning("api crypto bypassed: API_ENCRYPT_FIXED_SECRET is empty")
  88. return await call_next(request)
  89. return _json_error_response(500, "服务端加密配置缺失")
  90. path = request.url.path
  91. if not _is_web_api(path) or _should_skip_path(path):
  92. return await call_next(request)
  93. content_type = (request.headers.get("content-type") or "").lower()
  94. if "multipart/form-data" in content_type:
  95. return await call_next(request)
  96. body_bytes = await request.body()
  97. req_json = {}
  98. if body_bytes:
  99. try:
  100. req_json = json.loads(body_bytes.decode("utf-8"))
  101. if not isinstance(req_json, dict):
  102. req_json = {}
  103. except Exception:
  104. req_json = {}
  105. is_encrypted_req = int(req_json.get("enc", 0) or 0) == 1
  106. if not is_encrypted_req:
  107. logger.info("api crypto bypassed plaintext request: %s", path)
  108. return await call_next(request)
  109. user_id, _ = _claims_from_auth(request.headers.get("authorization"))
  110. if not user_id:
  111. return _json_error_response(401, "未授权或 token 无效")
  112. ts = req_json.get("ts")
  113. nonce = req_json.get("nonce")
  114. edata = req_json.get("edata")
  115. if ts is None or not nonce or not isinstance(edata, str):
  116. logger.warning("api crypto invalid envelope: path=%s", path)
  117. return _json_error_response(400, "加密报文格式错误")
  118. if not api_crypto.is_ts_in_window(int(ts), settings.API_ENCRYPT_TS_SKEW_SEC):
  119. logger.warning("api crypto ts expired: path=%s user_id=%s", path, user_id)
  120. return _json_error_response(400, "请求已过期,请重试")
  121. if not await _mark_nonce_once(str(user_id), str(nonce)):
  122. logger.warning("api crypto replay detected: path=%s user_id=%s nonce=%s", path, user_id, nonce)
  123. return _json_error_response(409, "重复请求")
  124. key = api_crypto.derive_key(
  125. fixed_secret=settings.API_ENCRYPT_FIXED_SECRET,
  126. user_id=str(user_id),
  127. ts=int(ts),
  128. nonce=str(nonce),
  129. )
  130. try:
  131. plain = api_crypto.decrypt_json(edata, key)
  132. except Exception as exc:
  133. logger.warning("api crypto decrypt failed: path=%s user_id=%s err=%s", path, user_id, exc)
  134. return _json_error_response(400, "密文解密失败")
  135. override_method = request.headers.get("x-http-method-override", "").upper()
  136. payload = plain.get("data", plain)
  137. if not isinstance(payload, dict):
  138. payload = {}
  139. if override_method == "GET":
  140. request.scope["method"] = "GET"
  141. request.scope["query_string"] = urlencode(payload, doseq=True).encode("utf-8")
  142. body_for_route = b""
  143. else:
  144. body_for_route = json.dumps(payload, ensure_ascii=False).encode("utf-8")
  145. async def receive():
  146. return {"type": "http.request", "body": body_for_route, "more_body": False}
  147. request = Request(request.scope, receive)
  148. response = await call_next(request)
  149. content_type = (response.headers.get("content-type") or "").lower()
  150. is_json_response = "application/json" in content_type or response.media_type == "application/json"
  151. if not is_json_response:
  152. return response
  153. raw_resp = b""
  154. if hasattr(response, "body_iterator"):
  155. chunks: list[bytes] = []
  156. async for chunk in response.body_iterator:
  157. chunks.append(chunk)
  158. raw_resp = b"".join(chunks)
  159. elif getattr(response, "body", None) is not None:
  160. raw_resp = response.body
  161. if not raw_resp:
  162. return response
  163. try:
  164. resp_obj = json.loads(raw_resp.decode("utf-8"))
  165. if not isinstance(resp_obj, dict):
  166. return response
  167. except Exception:
  168. return response
  169. try:
  170. resp_ts = int(time.time() * 1000)
  171. resp_nonce = api_crypto.gen_nonce_hex()
  172. resp_key = api_crypto.derive_key(
  173. fixed_secret=settings.API_ENCRYPT_FIXED_SECRET,
  174. user_id=str(user_id),
  175. ts=resp_ts,
  176. nonce=resp_nonce,
  177. )
  178. resp_edata = api_crypto.encrypt_json(resp_obj, resp_key)
  179. wrapped = {
  180. "code": resp_obj.get("code", 200),
  181. "encrypted": True,
  182. "ts": resp_ts,
  183. "nonce": resp_nonce,
  184. "edata": resp_edata,
  185. }
  186. except Exception as exc:
  187. logger.warning("api crypto response encrypt failed: path=%s user_id=%s err=%s", path, user_id, exc)
  188. if settings.API_ENCRYPT_FAIL_OPEN:
  189. return response
  190. return _json_error_response(500, "响应加密失败")
  191. headers = {k: v for k, v in response.headers.items() if k.lower() != "content-length"}
  192. return Response(
  193. content=json.dumps(wrapped, ensure_ascii=False),
  194. status_code=response.status_code,
  195. headers=headers,
  196. media_type="application/json",
  197. background=response.background,
  198. )