id_session 落地:opaque refresh(token_urlsafe(48))+ sha256 哈希存储 + 一次性轮换(旧行撤销与新行同一事务)+ logout 幂等撤销 + 已撤销重放检测 告警(V1 不做家族连坐);错误码 40103/40104/40105 接入。新增 POST /auth/refresh、POST /auth/logout;dev-token 扩展为双令牌,旧字段 access_token/token_type/expires_in/user_id 原名保留,新增 refresh_token/refresh_expires_in(smoke.sh 不受影响)。 测试:tests/unit/test_refresh_tokens.py 覆盖 token 长度/字符域/唯一性、 哈希稳定性与不可逆(7 例,不连网不连库);tests/integration/ test_auth_refresh.py 覆盖轮换后新 access 可调 /quota、旧 refresh 二次 使用 40105、logout 后失效 40105、伪造 40103、TTL=0 过期 40104(5 例)。 门禁全绿:ruff check/format、mypy 两段、lint-imports 4 契约、 单测 176 通过、集成 8 通过。 偏差说明:1) SessionIssued 较任务文档字段清单增加 user_id —— /auth/refresh 换发 access JWT 必须知道用户,opaque refresh 本身不携带 信息,只能随签发结果带回;2) 过期分支「顺手落库 revoked=true」在 HTTP 路径会随 401 的统一回滚一起丢失(仅事务内标记,已注释说明,不影响判定 语义);3) repo 撤销用 SELECT FOR UPDATE + ORM 变更而非 UPDATE rowcount —— 对齐 billing.repo 现行模式,且 rowcount 在 mypy 下类型 不可用。
31 lines
1.2 KiB
Python
Executable File
31 lines
1.2 KiB
Python
Executable File
"""Opaque refresh tokens (FR-ACC-05, 架构文档 §6.2 id_session / §9.1).
|
||
|
||
为什么是 opaque 随机串而不是 JWT:refresh 的核心价值就是「服务端可单条撤销」
|
||
(FR-ACC-05),无状态 JWT 天生做不到撤销——除非引入黑名单,而黑名单本身就是把
|
||
状态搬回服务端,那不如直接用查表就能撤销的 opaque 令牌。
|
||
|
||
库里只存 sha256 哈希而非明文:查库校验时重算一次哈希即可比对,而泄漏数据库
|
||
不等于泄漏令牌——sha256 不可逆,拿到哈希换不出新 access。
|
||
|
||
本模块是纯函数(无 IO、无配置),方便穷举式单测。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import secrets
|
||
|
||
#: token_urlsafe(48) 输出 64 个 URL 安全字符,携带 384 bit 熵,
|
||
#: 暴力枚举哈希空间在计算上不可行。
|
||
_TOKEN_BYTES = 48
|
||
|
||
|
||
def generate_refresh_token() -> str:
|
||
"""生成新 refresh token。明文只在签发瞬间出现一次,此后系统内只见哈希。"""
|
||
return secrets.token_urlsafe(_TOKEN_BYTES)
|
||
|
||
|
||
def hash_refresh_token(token: str) -> str:
|
||
"""sha256 hex(64 字符,与 id_session.refresh_token_hash 列宽一致)。"""
|
||
return hashlib.sha256(token.encode()).hexdigest()
|