mp_webview_ticket.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. """小程序 WebView 一次性短时 ticket(不把 JWT 放进 URL)。
  2. 默认签发短时签名票(S. 前缀):不依赖 Redis 跨实例共享,避免生产多机
  3. 「签发在 A、兑换在 B」导致 ticket 无效。
  4. 仍兼容旧版 UUID+Redis 票;签名票在 Redis 可用时用 jti 做记录(允许 TTL 内复兑)。
  5. """
  6. from __future__ import annotations
  7. import json
  8. import logging
  9. from datetime import datetime, timedelta, timezone
  10. from typing import Callable, Optional, TypeVar
  11. from jose import JWTError, jwt
  12. from redis import exceptions as redis_exceptions
  13. from app.core.config import settings
  14. from app.core.redis_client import get_sync_redis_client
  15. from app.core.utils import generate_uuid
  16. logger = logging.getLogger("sqxp")
  17. TICKET_TTL_SEC = 60
  18. KEY_PREFIX = "mp_wv_ticket:"
  19. JTI_USED_PREFIX = "mp_wv_jti:"
  20. SIGNED_PREFIX = "S."
  21. SIGNED_TYP = "mp_wv_ticket"
  22. _GETDEL_LUA = """
  23. local val = redis.call('GET', KEYS[1])
  24. if val then
  25. redis.call('DEL', KEYS[1])
  26. end
  27. return val
  28. """
  29. T = TypeVar("T")
  30. def _key(ticket: str) -> str:
  31. return f"{KEY_PREFIX}{ticket}"
  32. def _jti_key(jti: str) -> str:
  33. return f"{JTI_USED_PREFIX}{jti}"
  34. def _atomic_getdel(r, key: str):
  35. """原子 GET+DEL。
  36. 不用 redis-py 的 ``getdel``:客户端有该方法,但 Redis < 6.2 不支持 GETDEL 命令,
  37. 生产常见 5.x 会直接报错并被包装成 503。Lua 在旧版 Redis 上可用。
  38. """
  39. return r.eval(_GETDEL_LUA, 1, key)
  40. def _redis_call(fn: Callable[..., T], unavailable_msg: str) -> T:
  41. r = get_sync_redis_client()
  42. if r is None:
  43. raise RuntimeError(unavailable_msg)
  44. try:
  45. return fn(r)
  46. except (
  47. redis_exceptions.TimeoutError,
  48. redis_exceptions.ConnectionError,
  49. redis_exceptions.RedisError,
  50. ) as exc:
  51. raise RuntimeError(unavailable_msg) from exc
  52. def _issue_signed_ticket(user_id: str, session_id: str, refresh_jti: str = "") -> str:
  53. now = datetime.now(timezone.utc)
  54. payload = {
  55. "typ": SIGNED_TYP,
  56. "userId": user_id,
  57. "sessionId": session_id,
  58. "refreshJti": refresh_jti or "",
  59. "jti": generate_uuid(),
  60. "iat": now,
  61. "exp": now + timedelta(seconds=TICKET_TTL_SEC),
  62. }
  63. token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
  64. if isinstance(token, bytes):
  65. token = token.decode("utf-8")
  66. return f"{SIGNED_PREFIX}{token}"
  67. def _try_mark_jti_once(jti: str) -> Optional[bool]:
  68. """Redis 可用时标记 jti 已用。返回 True=首次, False=重复, None=Redis 不可用。"""
  69. if not jti:
  70. return None
  71. def _mark(r) -> bool:
  72. return bool(r.set(_jti_key(jti), "1", ex=TICKET_TTL_SEC, nx=True))
  73. try:
  74. return _redis_call(_mark, "Redis 不可用")
  75. except RuntimeError:
  76. return None
  77. def _consume_signed_ticket(raw_jwt: str) -> Optional[dict]:
  78. try:
  79. data = jwt.decode(raw_jwt, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
  80. except JWTError:
  81. return None
  82. if str(data.get("typ") or "") != SIGNED_TYP:
  83. return None
  84. user_id = str(data.get("userId") or "").strip()
  85. session_id = str(data.get("sessionId") or "").strip()
  86. if not user_id or not session_id:
  87. return None
  88. jti = str(data.get("jti") or "").strip()
  89. marked = _try_mark_jti_once(jti)
  90. if marked is False:
  91. # 已兑换过;微信 web-view 可能二次加载,同 TTL 内允许再兑
  92. logger.info("mp-webview signed ticket jti already used, allow within TTL: %s", jti[:8])
  93. return {
  94. "userId": user_id,
  95. "sessionId": session_id,
  96. "refreshJti": str(data.get("refreshJti") or ""),
  97. }
  98. def issue_ticket(user_id: str, session_id: str, refresh_jti: str = "") -> str:
  99. """签发签名票(跨实例不依赖 Redis 读到对方写入的 key)。"""
  100. return _issue_signed_ticket(user_id, session_id, refresh_jti)
  101. def consume_ticket(ticket: str) -> Optional[dict]:
  102. raw = (ticket or "").strip()
  103. if not raw:
  104. return None
  105. if raw.startswith(SIGNED_PREFIX):
  106. return _consume_signed_ticket(raw[len(SIGNED_PREFIX) :])
  107. # 兼容部署过渡期仍在流通的 UUID+Redis 票
  108. def _consume(r) -> Optional[dict]:
  109. payload = _atomic_getdel(r, _key(raw))
  110. if not payload:
  111. return None
  112. if isinstance(payload, bytes):
  113. payload = payload.decode("utf-8")
  114. data = json.loads(payload)
  115. if not isinstance(data, dict):
  116. return None
  117. return data
  118. try:
  119. return _redis_call(_consume, "Redis 不可用,无法兑换 WebView ticket")
  120. except RuntimeError as exc:
  121. logger.warning("mp-webview ticket Redis 兑换失败: %s", exc)
  122. raise