| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154 |
- """小程序 WebView 一次性短时 ticket(不把 JWT 放进 URL)。
- 默认签发短时签名票(S. 前缀):不依赖 Redis 跨实例共享,避免生产多机
- 「签发在 A、兑换在 B」导致 ticket 无效。
- 仍兼容旧版 UUID+Redis 票;签名票在 Redis 可用时用 jti 做记录(允许 TTL 内复兑)。
- """
- from __future__ import annotations
- import json
- import logging
- from datetime import datetime, timedelta, timezone
- from typing import Callable, Optional, TypeVar
- from jose import JWTError, jwt
- from redis import exceptions as redis_exceptions
- from app.core.config import settings
- from app.core.redis_client import get_sync_redis_client
- from app.core.utils import generate_uuid
- logger = logging.getLogger("sqxp")
- TICKET_TTL_SEC = 60
- KEY_PREFIX = "mp_wv_ticket:"
- JTI_USED_PREFIX = "mp_wv_jti:"
- SIGNED_PREFIX = "S."
- SIGNED_TYP = "mp_wv_ticket"
- _GETDEL_LUA = """
- local val = redis.call('GET', KEYS[1])
- if val then
- redis.call('DEL', KEYS[1])
- end
- return val
- """
- T = TypeVar("T")
- def _key(ticket: str) -> str:
- return f"{KEY_PREFIX}{ticket}"
- def _jti_key(jti: str) -> str:
- return f"{JTI_USED_PREFIX}{jti}"
- def _atomic_getdel(r, key: str):
- """原子 GET+DEL。
- 不用 redis-py 的 ``getdel``:客户端有该方法,但 Redis < 6.2 不支持 GETDEL 命令,
- 生产常见 5.x 会直接报错并被包装成 503。Lua 在旧版 Redis 上可用。
- """
- return r.eval(_GETDEL_LUA, 1, key)
- def _redis_call(fn: Callable[..., T], unavailable_msg: str) -> T:
- r = get_sync_redis_client()
- if r is None:
- raise RuntimeError(unavailable_msg)
- try:
- return fn(r)
- except (
- redis_exceptions.TimeoutError,
- redis_exceptions.ConnectionError,
- redis_exceptions.RedisError,
- ) as exc:
- raise RuntimeError(unavailable_msg) from exc
- def _issue_signed_ticket(user_id: str, session_id: str, refresh_jti: str = "") -> str:
- now = datetime.now(timezone.utc)
- payload = {
- "typ": SIGNED_TYP,
- "userId": user_id,
- "sessionId": session_id,
- "refreshJti": refresh_jti or "",
- "jti": generate_uuid(),
- "iat": now,
- "exp": now + timedelta(seconds=TICKET_TTL_SEC),
- }
- token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
- if isinstance(token, bytes):
- token = token.decode("utf-8")
- return f"{SIGNED_PREFIX}{token}"
- def _try_mark_jti_once(jti: str) -> Optional[bool]:
- """Redis 可用时标记 jti 已用。返回 True=首次, False=重复, None=Redis 不可用。"""
- if not jti:
- return None
- def _mark(r) -> bool:
- return bool(r.set(_jti_key(jti), "1", ex=TICKET_TTL_SEC, nx=True))
- try:
- return _redis_call(_mark, "Redis 不可用")
- except RuntimeError:
- return None
- def _consume_signed_ticket(raw_jwt: str) -> Optional[dict]:
- try:
- data = jwt.decode(raw_jwt, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
- except JWTError:
- return None
- if str(data.get("typ") or "") != SIGNED_TYP:
- return None
- user_id = str(data.get("userId") or "").strip()
- session_id = str(data.get("sessionId") or "").strip()
- if not user_id or not session_id:
- return None
- jti = str(data.get("jti") or "").strip()
- marked = _try_mark_jti_once(jti)
- if marked is False:
- # 已兑换过;微信 web-view 可能二次加载,同 TTL 内允许再兑
- logger.info("mp-webview signed ticket jti already used, allow within TTL: %s", jti[:8])
- return {
- "userId": user_id,
- "sessionId": session_id,
- "refreshJti": str(data.get("refreshJti") or ""),
- }
- def issue_ticket(user_id: str, session_id: str, refresh_jti: str = "") -> str:
- """签发签名票(跨实例不依赖 Redis 读到对方写入的 key)。"""
- return _issue_signed_ticket(user_id, session_id, refresh_jti)
- def consume_ticket(ticket: str) -> Optional[dict]:
- raw = (ticket or "").strip()
- if not raw:
- return None
- if raw.startswith(SIGNED_PREFIX):
- return _consume_signed_ticket(raw[len(SIGNED_PREFIX) :])
- # 兼容部署过渡期仍在流通的 UUID+Redis 票
- def _consume(r) -> Optional[dict]:
- payload = _atomic_getdel(r, _key(raw))
- if not payload:
- return None
- if isinstance(payload, bytes):
- payload = payload.decode("utf-8")
- data = json.loads(payload)
- if not isinstance(data, dict):
- return None
- return data
- try:
- return _redis_call(_consume, "Redis 不可用,无法兑换 WebView ticket")
- except RuntimeError as exc:
- logger.warning("mp-webview ticket Redis 兑换失败: %s", exc)
- raise
|