Files
2026-09-17 09:10:10 +08:00

155 lines
4.6 KiB
Python

"""小程序 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