Files
shanxia2.0/backend/app/core/api_crypto_middleware.py
T
2026-05-12 16:35:07 +08:00

240 lines
9.0 KiB
Python

"""Web API 加解密中间件。"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from typing import Any, Optional, Tuple
from urllib.parse import urlencode
import redis.asyncio as redis
from jose import jwt
from redis import exceptions as redis_exceptions
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from app.core import api_crypto
from app.core.config import settings
from app.core.response import error_response
logger = logging.getLogger("sqxp")
_redis_client: Optional[redis.Redis] = None
_local_nonce_cache: dict[str, float] = {}
def _nonce_key(user_id: str, nonce: str) -> str:
return f"api-enc:nonce:{user_id}:{nonce}"
def _should_skip_path(path: str) -> bool:
excludes = [x.strip() for x in (settings.API_ENCRYPT_EXCLUDE_PATHS or "").split(",") if x.strip()]
return path in excludes
def _is_crypto_api(path: str) -> bool:
"""仅对运营端与小程序业务 API 应用统一密文协议。"""
prefix = settings.API_V1_PREFIX
return path.startswith(f"{prefix}/web/") or path.startswith(f"{prefix}/mini/")
def _claims_from_auth(auth_header: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
if not auth_header or not auth_header.startswith("Bearer "):
return None, None
token = auth_header[7:].strip()
if not token:
return None, None
try:
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
options={"verify_signature": False, "verify_exp": False},
)
return payload.get("sub"), token
except Exception:
return None, token
def _get_redis_client() -> Optional[redis.Redis]:
global _redis_client
if not settings.REDIS_HOST:
return None
if _redis_client is None:
_redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB,
password=settings.REDIS_PASSWORD or None,
socket_timeout=settings.REDIS_TIMEOUT_SEC,
ssl=settings.REDIS_SSL_ENABLED,
decode_responses=True,
)
return _redis_client
async def _mark_nonce_once(user_id: str, nonce: str) -> bool:
ttl_sec = max(30, int(settings.API_ENCRYPT_NONCE_TTL_SEC))
key = _nonce_key(user_id, nonce)
client = _get_redis_client()
if client is not None:
try:
ok = await asyncio.wait_for(client.set(key, "1", ex=ttl_sec, nx=True), timeout=2.0)
return bool(ok)
except (asyncio.TimeoutError, redis_exceptions.RedisError):
logger.warning("api crypto nonce redis unavailable, fallback to local memory")
now = time.time()
expire_before = now - ttl_sec
stale_keys = [k for k, v in _local_nonce_cache.items() if v < expire_before]
for stale in stale_keys:
_local_nonce_cache.pop(stale, None)
if key in _local_nonce_cache:
return False
_local_nonce_cache[key] = now
return True
def _json_error_response(code: int, message: str) -> Response:
body = error_response(code, message)
return Response(content=json.dumps(body, ensure_ascii=False), media_type="application/json", status_code=200)
class ApiCryptoMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if not settings.API_ENCRYPT_ENABLED:
return await call_next(request)
if not settings.API_ENCRYPT_FIXED_SECRET:
if settings.API_ENCRYPT_FAIL_OPEN:
logger.warning("api crypto bypassed: API_ENCRYPT_FIXED_SECRET is empty")
return await call_next(request)
return _json_error_response(500, "服务端加密配置缺失")
path = request.url.path
if not _is_crypto_api(path) or _should_skip_path(path):
return await call_next(request)
content_type = (request.headers.get("content-type") or "").lower()
if "multipart/form-data" in content_type:
return await call_next(request)
body_bytes = await request.body()
req_json = {}
if body_bytes:
try:
req_json = json.loads(body_bytes.decode("utf-8"))
if not isinstance(req_json, dict):
req_json = {}
except Exception:
req_json = {}
is_encrypted_req = int(req_json.get("enc", 0) or 0) == 1
if not is_encrypted_req:
logger.info("api crypto bypassed plaintext request: %s", path)
return await call_next(request)
user_id, _ = _claims_from_auth(request.headers.get("authorization"))
if not user_id:
return _json_error_response(401, "未授权或 token 无效")
ts = req_json.get("ts")
nonce = req_json.get("nonce")
edata = req_json.get("edata")
if ts is None or not nonce or not isinstance(edata, str):
logger.warning("api crypto invalid envelope: path=%s", path)
return _json_error_response(400, "加密报文格式错误")
if not api_crypto.is_ts_in_window(int(ts), settings.API_ENCRYPT_TS_SKEW_SEC):
logger.warning("api crypto ts expired: path=%s user_id=%s", path, user_id)
return _json_error_response(400, "请求已过期,请重试")
if not await _mark_nonce_once(str(user_id), str(nonce)):
logger.warning("api crypto replay detected: path=%s user_id=%s nonce=%s", path, user_id, nonce)
return _json_error_response(409, "重复请求")
key = api_crypto.derive_key(
fixed_secret=settings.API_ENCRYPT_FIXED_SECRET,
user_id=str(user_id),
ts=int(ts),
nonce=str(nonce),
)
try:
plain = api_crypto.decrypt_json(edata, key)
except Exception as exc:
logger.warning("api crypto decrypt failed: path=%s user_id=%s err=%s", path, user_id, exc)
return _json_error_response(400, "密文解密失败")
override_method = request.headers.get("x-http-method-override", "").upper()
payload = plain.get("data", plain)
if override_method == "GET":
request.scope["method"] = "GET"
query_dict = payload if isinstance(payload, dict) else {}
request.scope["query_string"] = urlencode(query_dict, doseq=True).encode("utf-8")
body_for_route = b""
else:
# JSON body 可能是对象(多数 CRUD)或数组(如批量轮播图 POST)
if isinstance(payload, (dict, list)):
body_for_route = json.dumps(payload, ensure_ascii=False).encode("utf-8")
else:
body_for_route = json.dumps({}, ensure_ascii=False).encode("utf-8")
async def receive():
return {"type": "http.request", "body": body_for_route, "more_body": False}
request = Request(request.scope, receive)
response = await call_next(request)
content_type = (response.headers.get("content-type") or "").lower()
is_json_response = "application/json" in content_type or response.media_type == "application/json"
if not is_json_response:
return response
raw_resp = b""
if hasattr(response, "body_iterator"):
chunks: list[bytes] = []
async for chunk in response.body_iterator:
chunks.append(chunk)
raw_resp = b"".join(chunks)
elif getattr(response, "body", None) is not None:
raw_resp = response.body
if not raw_resp:
return response
try:
resp_obj = json.loads(raw_resp.decode("utf-8"))
if not isinstance(resp_obj, dict):
return response
except Exception:
return response
try:
resp_ts = int(time.time() * 1000)
resp_nonce = api_crypto.gen_nonce_hex()
resp_key = api_crypto.derive_key(
fixed_secret=settings.API_ENCRYPT_FIXED_SECRET,
user_id=str(user_id),
ts=resp_ts,
nonce=resp_nonce,
)
resp_edata = api_crypto.encrypt_json(resp_obj, resp_key)
wrapped = {
"code": resp_obj.get("code", 200),
"encrypted": True,
"ts": resp_ts,
"nonce": resp_nonce,
"edata": resp_edata,
}
except Exception as exc:
logger.warning("api crypto response encrypt failed: path=%s user_id=%s err=%s", path, user_id, exc)
if settings.API_ENCRYPT_FAIL_OPEN:
return response
return _json_error_response(500, "响应加密失败")
headers = {k: v for k, v in response.headers.items() if k.lower() != "content-length"}
return Response(
content=json.dumps(wrapped, ensure_ascii=False),
status_code=response.status_code,
headers=headers,
media_type="application/json",
background=response.background,
)