api_crypto_middleware.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  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 parse_qsl, 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. async def _coerce_post_method_override_to_get(request: Request, call_next) -> Response:
  84. """前端 Axios 在开启加密时会把 GET 改为 POST 并带 X-HTTP-Method-Override: GET。
  85. 当后端未启用 API_ENCRYPT_ENABLED 时,原逻辑直接放行,路由仍按 POST 匹配,导致仅注册了 GET 的接口返回 405。
  86. 此处在不解密的情况下,将此类请求还原为 GET(空或明文 JSON 体);若仍为 enc=1 密文则提示配置不一致。
  87. """
  88. if request.method != "POST":
  89. return await call_next(request)
  90. override = (request.headers.get("x-http-method-override") or "").upper()
  91. if override != "GET":
  92. return await call_next(request)
  93. body_bytes = await request.body()
  94. req_json: dict = {}
  95. if body_bytes:
  96. try:
  97. raw = json.loads(body_bytes.decode("utf-8"))
  98. req_json = raw if isinstance(raw, dict) else {}
  99. except Exception:
  100. req_json = {}
  101. if int(req_json.get("enc", 0) or 0) == 1:
  102. return _json_error_response(
  103. 400,
  104. "客户端已开启接口加密(enc=1),但服务端未启用 API_ENCRYPT_ENABLED;"
  105. "请在后端开启加密并配置 API_ENCRYPT_FIXED_SECRET,或将前端 VITE_API_ENCRYPT_ENABLED 设为 false。",
  106. )
  107. scope = dict(request.scope)
  108. scope["method"] = "GET"
  109. payload = req_json if isinstance(req_json, dict) else {}
  110. q0 = (request.scope.get("query_string") or b"").decode("utf-8")
  111. pairs = dict(parse_qsl(q0, keep_blank_values=True))
  112. for k, v in payload.items():
  113. if isinstance(v, (dict, list)):
  114. continue
  115. pairs[k] = "" if v is None else str(v)
  116. scope["query_string"] = urlencode(pairs, doseq=True).encode("utf-8") if pairs else b""
  117. async def receive():
  118. return {"type": "http.request", "body": b"", "more_body": False}
  119. new_request = Request(scope, receive)
  120. return await call_next(new_request)
  121. class ApiCryptoMiddleware(BaseHTTPMiddleware):
  122. async def dispatch(self, request: Request, call_next):
  123. if not settings.API_ENCRYPT_ENABLED:
  124. return await _coerce_post_method_override_to_get(request, call_next)
  125. if not settings.API_ENCRYPT_FIXED_SECRET:
  126. if settings.API_ENCRYPT_FAIL_OPEN:
  127. logger.warning("api crypto bypassed: API_ENCRYPT_FIXED_SECRET is empty")
  128. return await call_next(request)
  129. return _json_error_response(500, "服务端加密配置缺失")
  130. path = request.url.path
  131. if not _is_crypto_api(path) or _should_skip_path(path):
  132. return await call_next(request)
  133. content_type = (request.headers.get("content-type") or "").lower()
  134. if "multipart/form-data" in content_type:
  135. return await call_next(request)
  136. body_bytes = await request.body()
  137. req_json = {}
  138. if body_bytes:
  139. try:
  140. req_json = json.loads(body_bytes.decode("utf-8"))
  141. if not isinstance(req_json, dict):
  142. req_json = {}
  143. except Exception:
  144. req_json = {}
  145. is_encrypted_req = int(req_json.get("enc", 0) or 0) == 1
  146. if not is_encrypted_req:
  147. logger.info("api crypto bypassed plaintext request: %s", path)
  148. return await call_next(request)
  149. user_id, _ = _claims_from_auth(request.headers.get("authorization"))
  150. if not user_id:
  151. return _json_error_response(401, "未授权或 token 无效")
  152. ts = req_json.get("ts")
  153. nonce = req_json.get("nonce")
  154. edata = req_json.get("edata")
  155. if ts is None or not nonce or not isinstance(edata, str):
  156. logger.warning("api crypto invalid envelope: path=%s", path)
  157. return _json_error_response(400, "加密报文格式错误")
  158. if not api_crypto.is_ts_in_window(int(ts), settings.API_ENCRYPT_TS_SKEW_SEC):
  159. logger.warning("api crypto ts expired: path=%s user_id=%s", path, user_id)
  160. return _json_error_response(400, "请求已过期,请重试")
  161. if not await _mark_nonce_once(str(user_id), str(nonce)):
  162. logger.warning("api crypto replay detected: path=%s user_id=%s nonce=%s", path, user_id, nonce)
  163. return _json_error_response(409, "重复请求")
  164. key = api_crypto.derive_key(
  165. fixed_secret=settings.API_ENCRYPT_FIXED_SECRET,
  166. user_id=str(user_id),
  167. ts=int(ts),
  168. nonce=str(nonce),
  169. )
  170. try:
  171. plain = api_crypto.decrypt_json(edata, key)
  172. except Exception as exc:
  173. logger.warning("api crypto decrypt failed: path=%s user_id=%s err=%s", path, user_id, exc)
  174. return _json_error_response(400, "密文解密失败")
  175. override_method = request.headers.get("x-http-method-override", "").upper()
  176. payload = plain.get("data", plain)
  177. if override_method == "GET":
  178. request.scope["method"] = "GET"
  179. query_dict = payload if isinstance(payload, dict) else {}
  180. request.scope["query_string"] = urlencode(query_dict, doseq=True).encode("utf-8")
  181. body_for_route = b""
  182. else:
  183. # JSON body 可能是对象(多数 CRUD)或数组(如批量轮播图 POST)
  184. if isinstance(payload, (dict, list)):
  185. body_for_route = json.dumps(payload, ensure_ascii=False).encode("utf-8")
  186. else:
  187. body_for_route = json.dumps({}, ensure_ascii=False).encode("utf-8")
  188. async def receive():
  189. return {"type": "http.request", "body": body_for_route, "more_body": False}
  190. request = Request(request.scope, receive)
  191. response = await call_next(request)
  192. content_type = (response.headers.get("content-type") or "").lower()
  193. is_json_response = "application/json" in content_type or response.media_type == "application/json"
  194. if not is_json_response:
  195. return response
  196. raw_resp = b""
  197. if hasattr(response, "body_iterator"):
  198. chunks: list[bytes] = []
  199. async for chunk in response.body_iterator:
  200. chunks.append(chunk)
  201. raw_resp = b"".join(chunks)
  202. elif getattr(response, "body", None) is not None:
  203. raw_resp = response.body
  204. if not raw_resp:
  205. return response
  206. try:
  207. resp_obj = json.loads(raw_resp.decode("utf-8"))
  208. if not isinstance(resp_obj, dict):
  209. return response
  210. except Exception:
  211. return response
  212. try:
  213. resp_ts = int(time.time() * 1000)
  214. resp_nonce = api_crypto.gen_nonce_hex()
  215. resp_key = api_crypto.derive_key(
  216. fixed_secret=settings.API_ENCRYPT_FIXED_SECRET,
  217. user_id=str(user_id),
  218. ts=resp_ts,
  219. nonce=resp_nonce,
  220. )
  221. resp_edata = api_crypto.encrypt_json(resp_obj, resp_key)
  222. wrapped = {
  223. "code": resp_obj.get("code", 200),
  224. "encrypted": True,
  225. "ts": resp_ts,
  226. "nonce": resp_nonce,
  227. "edata": resp_edata,
  228. }
  229. except Exception as exc:
  230. logger.warning("api crypto response encrypt failed: path=%s user_id=%s err=%s", path, user_id, exc)
  231. if settings.API_ENCRYPT_FAIL_OPEN:
  232. return response
  233. return _json_error_response(500, "响应加密失败")
  234. headers = {k: v for k, v in response.headers.items() if k.lower() != "content-length"}
  235. return Response(
  236. content=json.dumps(wrapped, ensure_ascii=False),
  237. status_code=response.status_code,
  238. headers=headers,
  239. media_type="application/json",
  240. background=response.background,
  241. )