api_crypto_middleware.py 11 KB

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