api_crypto_middleware.py 8.8 KB

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