security.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. """
  2. 安全中间件 - 支持AES+RSA混合加密和签名验证
  3. """
  4. import os
  5. import hmac
  6. import hashlib
  7. import base64
  8. import time
  9. import secrets
  10. import json
  11. import inspect
  12. from typing import Callable, Optional, Dict, Any, Tuple
  13. from fastapi import Request, HTTPException, Depends
  14. from fastapi.responses import JSONResponse
  15. from sqlalchemy.orm import Session
  16. from datetime import datetime, timedelta
  17. from app.config import settings
  18. from app.core.encrypt import encryptor
  19. from app.database import SessionLocal, get_db
  20. from app.models.api_nonce import APINonce
  21. from app.models.api_key import APIKey
  22. from app.models.user import User
  23. from app.models.session import Session as SessionModel
  24. from app.models.software import Software
  25. from app.models.user_software_subscription import UserSoftwareSubscription
  26. from app.utils.timezone import beijing_now_naive
  27. from app.utils.api_logger import api_logger
  28. from app.utils.api_context import get_request_context, set_request_context
  29. from app.utils.machine_code import raw_machine_info_to_machine_code
  30. from app.core.logger import logger
  31. def _normalize_machine_code(decrypted_params: dict) -> str:
  32. """与登录接口一致:对 raw_machine_info 规范化后做 MD5,再与 session.machine_code 比较"""
  33. raw = (decrypted_params.get("raw_machine_info") or decrypted_params.get("machine_code") or "").strip()
  34. return raw_machine_info_to_machine_code(raw)
  35. # 服务端签名密钥:从环境变量获取,未设置时默认 buff-*-secret-{当前年}(每次调用取当前年,跨年无需重启)
  36. def get_server_secret() -> bytes:
  37. """获取服务端签名密钥(响应签名用)"""
  38. default = f"buff-server-secret-{datetime.now().year}"
  39. return os.getenv("BUFF_API_SERVER_SECRET", default).encode()
  40. def get_client_secret() -> bytes:
  41. """获取客户端签名密钥(请求验签用)"""
  42. default = f"buff-client-secret-{datetime.now().year}"
  43. return os.getenv("BUFF_API_CLIENT_SECRET", default).encode()
  44. # 服务端内置AES密钥 (用于公钥获取接口)
  45. SERVER_AES_KEY = os.getenv("SERVER_AES_KEY", "server-aes-key-32bytes!").encode()
  46. def generate_signature(data: str, secret_key: bytes = None) -> str:
  47. """生成HMAC-SHA256签名"""
  48. if secret_key is None:
  49. secret_key = get_server_secret()
  50. signature = hmac.new(
  51. secret_key,
  52. data.encode(),
  53. hashlib.sha256
  54. ).digest()
  55. return base64.b64encode(signature).decode()
  56. def verify_signature(data: str, signature: str, secret_key: bytes = None) -> bool:
  57. """验证HMAC-SHA256签名"""
  58. if secret_key is None:
  59. secret_key = get_client_secret()
  60. try:
  61. expected = generate_signature(data, secret_key)
  62. return hmac.compare_digest(signature, expected)
  63. except Exception:
  64. return False
  65. def verify_timestamp(timestamp: int, tolerance: int = 300) -> bool:
  66. """验证时间戳 (容忍±5分钟)"""
  67. current_time = int(time.time())
  68. return abs(current_time - timestamp) <= tolerance
  69. def generate_nonce() -> str:
  70. """生成随机nonce"""
  71. return secrets.token_hex(16)
  72. def verify_nonce(nonce: str, endpoint: str, db: Session) -> bool:
  73. """验证nonce (防重放)"""
  74. if not nonce:
  75. return False
  76. # 检查nonce是否已存在
  77. existing = db.query(APINonce).filter(
  78. APINonce.nonce == nonce,
  79. APINonce.endpoint == endpoint,
  80. APINonce.expires_at > beijing_now_naive()
  81. ).first()
  82. if existing:
  83. return False
  84. # 存储新nonce
  85. new_nonce = APINonce(
  86. nonce=nonce,
  87. endpoint=endpoint,
  88. expires_at=beijing_now_naive() + timedelta(minutes=5)
  89. )
  90. db.add(new_nonce)
  91. db.commit()
  92. return True
  93. def decrypt_request_data(
  94. encrypted_params: str,
  95. key_version: int,
  96. db: Session,
  97. encrypted_aes_key: Optional[str] = None,
  98. ) -> Dict[str, Any]:
  99. """
  100. 解密客户端请求数据。
  101. 若同时提供 encrypted_params 与 encrypted_aes_key,按客户端分离格式解密(IV+AES-CTR + RSA 加密的 AES 密钥);
  102. 否则按单 blob 格式解密(兼容旧逻辑)。
  103. """
  104. try:
  105. if not encrypted_params:
  106. return {}
  107. if not settings.ENCRYPT_ENABLED:
  108. return json.loads(encrypted_params)
  109. if encrypted_aes_key:
  110. decrypted = encryptor.decrypt_from_client_separate(
  111. encrypted_params, encrypted_aes_key, key_version, db
  112. )
  113. else:
  114. decrypted = encryptor.decrypt_from_client(encrypted_params, key_version, db)
  115. return decrypted
  116. except Exception as e:
  117. logger.exception("请求数据解密失败")
  118. raise HTTPException(status_code=400, detail="请求数据解密失败")
  119. def encrypt_response_data(data: Dict[str, Any], db: Session) -> Tuple[str, int]:
  120. """加密响应数据(用于非安全端点或无客户端密钥时)"""
  121. try:
  122. encrypted_data, key_version = encryptor.encrypt_for_client(data, db)
  123. return encrypted_data, key_version
  124. except Exception as e:
  125. logger.exception("响应数据加密失败")
  126. return json.dumps(data, ensure_ascii=False), 0
  127. def encrypt_response_data_with_client_key(
  128. data: Dict[str, Any],
  129. encrypted_aes_key_b64: str,
  130. key_version: int,
  131. db: Session,
  132. ) -> Tuple[str, int]:
  133. """使用请求中的客户端 AES 密钥加密响应(IV + AES-CTR),与 client-tauri 约定一致"""
  134. try:
  135. return encryptor.encrypt_response_with_client_key(
  136. data, encrypted_aes_key_b64, key_version, db
  137. )
  138. except Exception as e:
  139. logger.exception("使用客户端密钥加密响应失败")
  140. raise
  141. class SecureEndpoint:
  142. """安全端点装饰器类"""
  143. def __init__(self, require_login: bool = True, require_machine_code: bool = True):
  144. self.require_login = require_login
  145. self.require_machine_code = require_machine_code
  146. def __call__(self, func: Callable):
  147. async def wrapper(request: Request, db: Session = Depends(get_db)):
  148. try:
  149. # 1. 获取并解密请求参数(仅从 Request 读 body,避免被 FastAPI 当作 body 参数校验导致 422)
  150. body = await request.body()
  151. body_str = body.decode() if body else "{}"
  152. logger.debug(
  153. f"[SecureEndpoint] 收到请求 path={request.url.path} body_len={len(body_str)} body_preview={repr(body_str[:200]) if body_str else 'empty'}"
  154. )
  155. # 尝试解析JSON请求体
  156. try:
  157. body_json = json.loads(body_str) if body_str else {}
  158. except json.JSONDecodeError as e:
  159. logger.warning(f"[SecureEndpoint] JSON 解析失败 path={request.url.path} err={e} body_len={len(body_str)}")
  160. body_json = {}
  161. body_keys = list(body_json.keys()) if body_json else []
  162. logger.debug(f"[SecureEndpoint] body_json keys={body_keys}")
  163. # 获取请求参数(与客户端 JSON 字段一致;数值统一为 int 避免 float 导致签名字符串不一致)
  164. encrypted_params = body_json.get("encrypted_params") or ""
  165. aes_key = body_json.get("aes_key") or ""
  166. _kv = body_json.get("key_version", 1)
  167. _ts = body_json.get("timestamp", 0)
  168. key_version = int(_kv) if _kv is not None else 1
  169. timestamp = int(_ts) if _ts is not None else 0
  170. nonce = body_json.get("nonce") or ""
  171. signature = body_json.get("signature") or ""
  172. # 2. 验证时间戳
  173. if not verify_timestamp(timestamp):
  174. logger.warning(f"[SecureEndpoint] 时间戳验证失败 path={request.url.path} ts={timestamp}")
  175. return JSONResponse(
  176. status_code=400,
  177. content={"code": 400, "msg": "请求已过期"}
  178. )
  179. # 3. 验证nonce (防重放)
  180. if not verify_nonce(nonce, request.url.path, db):
  181. logger.warning(f"[SecureEndpoint] nonce 验证失败 path={request.url.path} nonce_len={len(nonce)}")
  182. return JSONResponse(
  183. status_code=400,
  184. content={"code": 400, "msg": "重复请求或nonce无效"}
  185. )
  186. # 4. 验证客户端签名(确保与客户端构造顺序、类型一致)
  187. # 客户端格式: timestamp:nonce:encrypted_params:aes_key:key_version,均为字符串拼接
  188. sign_string = f"{timestamp}:{nonce}:{encrypted_params}:{aes_key}:{key_version}"
  189. logger.debug(
  190. f"[SecureEndpoint] 签名字符串: ts={timestamp} kv={key_version} nonce_len={len(nonce)} "
  191. f"enc_len={len(encrypted_params)} aes_key_len={len(aes_key)} sign_str_len={len(sign_string)}"
  192. )
  193. # 统一使用按年份写死的静态密钥验签(CLIENT_SECRET = buff-client-secret-{年})
  194. if not verify_signature(sign_string, signature):
  195. try:
  196. expected = generate_signature(sign_string, get_client_secret())
  197. sign_prefix = sign_string[:80] + "..." if len(sign_string) > 80 else sign_string
  198. expect_tail = expected[-12:] if len(expected) >= 12 else expected
  199. recv_tail = signature[-12:] if len(signature) >= 12 else signature
  200. logger.warning(
  201. f"[SecureEndpoint] 签名验证失败: body_len={len(body_str)} sign_str_len={len(sign_string)} "
  202. f"ts={timestamp} kv={key_version} sign_prefix={sign_prefix} "
  203. f"expect_tail={expect_tail} recv_tail={recv_tail}"
  204. )
  205. except Exception as e:
  206. logger.exception(f"[SecureEndpoint] 签名验证失败且记录日志异常: {e}")
  207. return JSONResponse(
  208. status_code=400,
  209. content={"code": 400, "msg": "签名验证失败"}
  210. )
  211. # 5. 解密请求参数(客户端分离格式:encrypted_params + aes_key)
  212. decrypted_params = decrypt_request_data(
  213. encrypted_params, key_version, db, encrypted_aes_key=aes_key
  214. )
  215. # 6. 登录验证 (如果需要)
  216. session = None
  217. user = None
  218. if self.require_login:
  219. session_id = (decrypted_params.get("session_id") or "").strip()
  220. # 与登录一致:客户端传原始串,后端统一 MD5 后与 session.machine_code 比较
  221. machine_code = _normalize_machine_code(decrypted_params)
  222. if not session_id:
  223. return JSONResponse(
  224. status_code=401,
  225. content={"code": 401, "msg": "未登录"}
  226. )
  227. # 验证会话
  228. session = db.query(SessionModel).filter(
  229. SessionModel.session_id == session_id,
  230. SessionModel.is_active == 1,
  231. SessionModel.expires_at > beijing_now_naive()
  232. ).first()
  233. if not session:
  234. return JSONResponse(
  235. status_code=401,
  236. content={"code": 401, "msg": "会话已过期,请重新登录"}
  237. )
  238. # 验证机器码 (如果需要):支持 session 存 MD5(32位hex) 或 旧版原始串
  239. if self.require_machine_code:
  240. raw_from_params = (decrypted_params.get("raw_machine_info") or decrypted_params.get("machine_code") or "").strip()
  241. session_mc = (session.machine_code or "").strip()
  242. is_session_md5 = len(session_mc) == 32 and all(c in "0123456789abcdef" for c in session_mc.lower())
  243. if is_session_md5:
  244. machine_code_match = machine_code == session_mc
  245. else:
  246. machine_code_match = raw_from_params == session_mc
  247. if not machine_code_match:
  248. logger.warning(
  249. "[SecureEndpoint] 机器码不匹配: session_id=%s keys=%s "
  250. "session_mc_len=%s req_norm_len=%s session_prefix=%s req_prefix=%s raw_len=%s",
  251. session_id[:16] if session_id else "",
  252. list(decrypted_params.keys()),
  253. len(session_mc),
  254. len(machine_code or ""),
  255. session_mc[:8],
  256. (machine_code or "")[:8],
  257. len(raw_from_params),
  258. )
  259. return JSONResponse(
  260. status_code=403,
  261. content={"code": 403, "msg": "账号已在其他设备登录,如非本人操作请联系客服"}
  262. )
  263. # 获取用户信息
  264. user = db.query(User).filter(
  265. User.id == session.user_id,
  266. User.is_deleted == 0
  267. ).first()
  268. if not user:
  269. return JSONResponse(
  270. status_code=404,
  271. content={"code": 404, "msg": "用户不存在"}
  272. )
  273. # 检查用户状态
  274. if user.status != 1:
  275. return JSONResponse(
  276. status_code=403,
  277. content={"code": 403, "msg": "账号已被禁用"}
  278. )
  279. # 按软件检查卡密有效期(多软件共用用户表:优先 user_software_subscription)
  280. software_code = (decrypted_params.get("software_code") or "").strip() or "buff-xp-steam-sh"
  281. sw = db.query(Software).filter(
  282. Software.code == software_code,
  283. Software.is_deleted == 0
  284. ).first()
  285. expire_time = None
  286. if sw:
  287. sub = db.query(UserSoftwareSubscription).filter(
  288. UserSoftwareSubscription.user_id == user.id,
  289. UserSoftwareSubscription.software_id == sw.id,
  290. UserSoftwareSubscription.is_deleted == 0
  291. ).first()
  292. if sub and sub.expire_time is not None:
  293. expire_time = sub.expire_time
  294. if expire_time is None:
  295. expire_time = user.expire_time
  296. if expire_time and expire_time < beijing_now_naive():
  297. return JSONResponse(
  298. status_code=403,
  299. content={"code": 403, "msg": "您的卡密已到期,请前往续费或联系客户支持"}
  300. )
  301. # 7. 执行实际业务逻辑
  302. response_data = await func(decrypted_params, request, db, session, user)
  303. # 8. 使用请求中的客户端 AES 密钥加密响应(IV + AES-CTR),与 client-tauri 一致
  304. encrypted_response, response_key_version = encrypt_response_data_with_client_key(
  305. response_data, aes_key, key_version, db
  306. )
  307. # 9. 生成响应签名
  308. response_timestamp = int(time.time())
  309. response_nonce = generate_nonce()
  310. response_sign_string = f"{response_timestamp}:{response_nonce}:{encrypted_response}:{response_key_version}"
  311. response_signature = generate_signature(response_sign_string)
  312. # 10. 记录API日志
  313. request_user_id = session.user_id if session else (user.id if user else None)
  314. client_ip = request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or \
  315. request.headers.get("X-Real-IP", "") or \
  316. "unknown"
  317. # 解密响应数据用于日志记录
  318. decrypted_response = response_data
  319. # 更新请求上下文,供 APILoggingASGIMiddleware 写入 api_logs 明文(脱敏)
  320. try:
  321. ctx = get_request_context()
  322. if ctx is not None:
  323. ctx = dict(ctx)
  324. ctx["decrypted_request"] = decrypted_params
  325. ctx["decrypted_response"] = decrypted_response
  326. set_request_context(ctx)
  327. except Exception:
  328. pass
  329. api_logger.log_api_call(
  330. endpoint=request.url.path,
  331. method=request.method,
  332. encrypted_request=encrypted_params,
  333. decrypted_request=decrypted_params,
  334. encrypted_response=encrypted_response,
  335. decrypted_response=decrypted_response,
  336. processing_time_ms=0, # 可以添加处理时间计算
  337. status_code=200,
  338. user_id=request_user_id,
  339. client_ip=client_ip,
  340. )
  341. # 11. 返回加密响应(字段名与客户端验签约定一致)
  342. return {
  343. "code": 200,
  344. "msg": "成功",
  345. "encrypted_data": encrypted_response,
  346. "response_timestamp": response_timestamp,
  347. "response_nonce": response_nonce,
  348. "response_signature": response_signature,
  349. "key_version": response_key_version
  350. }
  351. except HTTPException:
  352. raise
  353. except Exception as e:
  354. logger.exception(f"安全端点处理异常: {e}")
  355. # 记录错误日志
  356. api_logger.log_api_call(
  357. endpoint=request.url.path if 'request' in dir() else "unknown",
  358. method=request.method if 'request' in dir() else "unknown",
  359. error_message=str(e),
  360. status_code=500,
  361. )
  362. return JSONResponse(
  363. status_code=500,
  364. content={"code": 500, "msg": "系统异常,请稍后重试"}
  365. )
  366. finally:
  367. pass # db 由 Depends(get_db) 注入,由 FastAPI 管理生命周期
  368. # 显式签名:仅 request + db,避免 FastAPI 按被装饰函数的 decrypted_params 解析 body 导致 422
  369. wrapper.__signature__ = inspect.signature(
  370. lambda request: None
  371. ).replace(
  372. parameters=[
  373. inspect.Parameter("request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request),
  374. inspect.Parameter("db", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Session, default=Depends(get_db)),
  375. ]
  376. )
  377. return wrapper
  378. def secure_endpoint(require_login: bool = True, require_machine_code: bool = True):
  379. """安全端点装饰器工厂函数"""
  380. def decorator(func: Callable):
  381. return SecureEndpoint(require_login, require_machine_code).__call__(func)
  382. return decorator
  383. # ============ 统一安全响应中间件 ============
  384. class SecureResponseMiddleware:
  385. """
  386. 统一安全响应中间件
  387. 自动为所有响应添加加密和签名
  388. """
  389. @staticmethod
  390. def create_secure_response(
  391. data: Any,
  392. db: Session,
  393. code: int = 200,
  394. msg: str = "成功"
  395. ) -> Dict[str, Any]:
  396. """
  397. 创建带签名的安全响应
  398. 符合技术方案文档要求的响应格式
  399. """
  400. from app.core.encrypt import (
  401. encryptor,
  402. generate_response_signature,
  403. get_server_secret
  404. )
  405. try:
  406. # 构建响应数据
  407. response_data = {
  408. "code": code,
  409. "msg": msg,
  410. "data": data
  411. }
  412. # 加密响应数据
  413. encrypted_data, key_version = encryptor.encrypt_for_client(response_data, db)
  414. # 生成响应签名
  415. signature, timestamp, nonce = generate_response_signature(
  416. encrypted_data,
  417. key_version,
  418. get_server_secret()
  419. )
  420. # 返回完整的带签名响应
  421. return {
  422. "code": 200,
  423. "msg": "成功",
  424. "encrypted_data": encrypted_data,
  425. "timestamp": timestamp,
  426. "nonce": nonce,
  427. "signature": signature,
  428. "key_version": key_version
  429. }
  430. except Exception as e:
  431. logger.exception("创建安全响应失败")
  432. # 如果加密失败,返回错误信息
  433. return {
  434. "code": 500,
  435. "msg": "系统异常,请稍后重试",
  436. "encrypted_data": None,
  437. "timestamp": int(time.time()),
  438. "nonce": secrets.token_hex(16),
  439. "signature": None,
  440. "key_version": 0
  441. }
  442. def create_encrypted_response(
  443. data: Any,
  444. db: Session,
  445. code: int = 200,
  446. msg: str = "成功"
  447. ) -> Dict[str, Any]:
  448. """
  449. 便捷函数:创建带签名的安全响应
  450. 使用方法:
  451. return create_encrypted_response(user_data, db, 200, "成功")
  452. """
  453. return SecureResponseMiddleware.create_secure_response(data, db, code, msg)