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 下类型 不可用。
59 lines
2.1 KiB
Python
Executable File
59 lines
2.1 KiB
Python
Executable File
"""refresh token 纯函数(packages/identity/tokens.py)。
|
|
|
|
无 IO、无配置:token 长度/字符域/唯一性、哈希稳定性与不可逆性在这里穷举,
|
|
服务层的轮换/撤销语义由 tests/integration/test_auth_refresh.py 用真实 PG 验证。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import string
|
|
|
|
from packages.identity.tokens import generate_refresh_token, hash_refresh_token
|
|
|
|
#: token_urlsafe 的字母表:base64url(含 ``-`` ``_``),无 ``+`` ``/`` ``=``
|
|
_URLSAFE_ALPHABET = frozenset(string.ascii_letters + string.digits + "_-")
|
|
|
|
|
|
def test_token_has_fixed_length() -> None:
|
|
# 48 字节 → base64 无填充恒 64 字符;长度恒定才好做列宽与日志脱敏
|
|
assert len(generate_refresh_token()) == 64
|
|
|
|
|
|
def test_token_stays_within_urlsafe_alphabet() -> None:
|
|
# 必须能原样放进 JSON / URL / Header,不引入需要转义的字符
|
|
tokens = [generate_refresh_token() for _ in range(50)]
|
|
assert all(set(token) <= _URLSAFE_ALPHABET for token in tokens)
|
|
|
|
|
|
def test_tokens_are_unique() -> None:
|
|
tokens = [generate_refresh_token() for _ in range(100)]
|
|
assert len(set(tokens)) == len(tokens)
|
|
|
|
|
|
def test_hash_is_stable() -> None:
|
|
# 校验路径 = 重算一次哈希再比对,稳定性是查库成立的前提
|
|
token = generate_refresh_token()
|
|
assert hash_refresh_token(token) == hash_refresh_token(token)
|
|
|
|
|
|
def test_hash_is_lowercase_sha256_hex() -> None:
|
|
digest = hash_refresh_token("abc")
|
|
# sha256 恒 64 个小写 hex 字符,与 id_session.refresh_token_hash varchar(64) 对齐
|
|
assert len(digest) == 64
|
|
assert digest == digest.lower()
|
|
assert set(digest) <= set(string.digits + "abcdef")
|
|
|
|
|
|
def test_hash_is_not_the_plaintext_token() -> None:
|
|
# 不可逆(≠原文):库里存的是哈希,泄漏数据库换不回可用令牌
|
|
token = generate_refresh_token()
|
|
digest = hash_refresh_token(token)
|
|
assert digest != token
|
|
assert token not in digest
|
|
|
|
|
|
def test_different_tokens_hash_differently() -> None:
|
|
assert hash_refresh_token(generate_refresh_token()) != hash_refresh_token(
|
|
generate_refresh_token()
|
|
)
|