feat(identity): refresh token rotation with server-side revocable sessions

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 下类型
不可用。
This commit is contained in:
xsl
2026-08-14 16:21:52 +08:00
parent 9e7a886863
commit 140a63effa
7 changed files with 497 additions and 28 deletions
+67 -23
View File
@@ -1,18 +1,24 @@
"""Development-only token issuance. """Auth endpoints: dev-token / refresh / logout (FR-ACC-05 双令牌会话).
**This is not the V1 auth design.** FR-ACC-01/04/05 require 华为账号登录、手机号 登出语义 = 撤销 ``id_session`` 会话,**refresh token 立即失效**。access JWT
验证码、双令牌与服务端可撤销的 refresh token. This endpoint exists so the 无状态、15 分钟 TTL:登出后旧 access 在其 TTL 内仍有效的残留窗口是 V1 已知
vertical slice can be exercised end to end, and it refuses to run unless 且接受的设计权衡(要即时失效就得引入黑名单或极短 TTL,V1 不做)——
``DEV_AUTH_ENABLED`` is true and the environment is local/dev. 「登出后旧令牌立即失效」的验收对象是 refresh 令牌与会话。
明确不做(防止后人误判「漏了」):华为账号登录(T-P1-12,等资质)、手机验证码
(T-P1-12b,等短信通道)、游客/合并/注销。dev-token 端点不是正式鉴权设计,
仅为纵向切片与联调服务:``DEV_AUTH_ENABLED`` + 本地环境双开关才放行。
""" """
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter from fastapi import APIRouter
from apps.api.deps import IdentityServiceDep, SettingsDep from apps.api.deps import CurrentUserId, IdentityServiceDep, SettingsDep
from apps.api.schemas import DevTokenRequest, DevTokenResponse from apps.api.schemas import DevTokenRequest
from packages.identity.service import DEV_PROVIDER from packages.identity.schemas import LogoutRequest, RefreshRequest, TokenPairResponse
from packages.identity.service import DEV_PROVIDER, SessionIssued
from packages.shared.config import Settings
from packages.shared.errors import AppError, ErrorCode from packages.shared.errors import AppError, ErrorCode
from packages.shared.response import ok from packages.shared.response import ok
from packages.shared.security import create_access_token from packages.shared.security import create_access_token
@@ -20,7 +26,34 @@ from packages.shared.security import create_access_token
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/dev-token", summary="[dev only] Issue an access token for testing") def _access_token(settings: Settings, user_id: int) -> str:
return create_access_token(
user_id=user_id,
secret=settings.jwt_secret,
algorithm=settings.jwt_algorithm,
ttl_seconds=settings.jwt_access_ttl_seconds,
issuer=settings.jwt_issuer,
)
def _token_pair(
settings: Settings, access_token: str, issued: SessionIssued
) -> dict[str, str | int]:
"""dev-token 与 refresh 共用的双令牌响应。
旧字段名 ``access_token/token_type/expires_in/user_id`` 一字不改
(smoke.sh 与既有测试依赖),只新增 ``refresh_token/refresh_expires_in``。
"""
return TokenPairResponse(
access_token=access_token,
refresh_token=issued.refresh_token,
expires_in=settings.jwt_access_ttl_seconds,
refresh_expires_in=settings.refresh_token_ttl_seconds,
user_id=str(issued.user_id),
).model_dump()
@router.post("/dev-token", summary="[dev only] Issue a token pair for testing")
async def dev_token( async def dev_token(
payload: DevTokenRequest, payload: DevTokenRequest,
settings: SettingsDep, settings: SettingsDep,
@@ -34,17 +67,28 @@ async def dev_token(
union_id=payload.device_key, union_id=payload.device_key,
register_source="dev", register_source="dev",
) )
token = create_access_token( issued = await identity.issue_session(user.user_id)
user_id=user.user_id, return ok(_token_pair(settings, _access_token(settings, user.user_id), issued))
secret=settings.jwt_secret,
algorithm=settings.jwt_algorithm,
ttl_seconds=settings.jwt_access_ttl_seconds, @router.post("/refresh", summary="Rotate a refresh token into a new token pair")
issuer=settings.jwt_issuer, async def refresh(
) payload: RefreshRequest,
return ok( settings: SettingsDep,
DevTokenResponse( identity: IdentityServiceDep,
access_token=token, ) -> dict[str, object]:
expires_in=settings.jwt_access_ttl_seconds, issued = await identity.rotate(payload.refresh_token)
user_id=str(user.user_id), return ok(_token_pair(settings, _access_token(settings, issued.user_id), issued))
).model_dump()
)
@router.post("/logout", summary="Revoke the session bound to this refresh token")
async def logout(
payload: LogoutRequest,
# CurrentUserId 只做鉴权门槛(不带 access 的登出请求一律 40100),
# 撤销仍以 refresh_token 定位会话,不信任 token 里的 user_id。
user_id: CurrentUserId,
identity: IdentityServiceDep,
) -> dict[str, object]:
revoked = await identity.revoke(payload.refresh_token)
# 幂等登出:查无此 token 或已撤销都返回 revoked=false,不报错
return ok({"revoked": revoked})
+57 -1
View File
@@ -2,10 +2,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from packages.shared.models import Consent, User, UserIdentity from packages.shared.models import Consent, User, UserIdentity, UserSession
async def get_user(session: AsyncSession, user_id: int) -> User | None: async def get_user(session: AsyncSession, user_id: int) -> User | None:
@@ -36,3 +38,57 @@ def add_identity(session: AsyncSession, identity: UserIdentity) -> UserIdentity:
def add_consent(session: AsyncSession, consent: Consent) -> Consent: def add_consent(session: AsyncSession, consent: Consent) -> Consent:
session.add(consent) session.add(consent)
return consent return consent
async def find_session_by_hash(session: AsyncSession, token_hash: str) -> UserSession | None:
# UNIQUE(refresh_token_hash) 保证至多一行,scalar_one_or_none 即可
result = await session.execute(
select(UserSession).where(UserSession.refresh_token_hash == token_hash)
)
return result.scalar_one_or_none()
def add_session(session: AsyncSession, user_session: UserSession) -> UserSession:
session.add(user_session)
return user_session
async def revoke_session(session: AsyncSession, session_id: int) -> bool:
"""撤销单条会话。返回 False = 该行不存在或已被撤销。
WHERE 里带 ``revoked = false`` 再 FOR UPDATE(照 billing.repo 的
lock_or_create_quota 模式):两个并发请求拿着同一 refresh 轮换时,
后到者等先到者提交后重估条件、匹配 0 行返回 False——调用方据此按
「已撤销/重放」处理,无需自己加锁。
"""
result = await session.execute(
select(UserSession)
.where(UserSession.session_id == session_id, UserSession.revoked.is_(False))
.with_for_update()
)
row = result.scalar_one_or_none()
if row is None:
return False
row.revoked = True
row.revoked_at = datetime.now(UTC)
return True
async def revoke_all_sessions(session: AsyncSession, user_id: int) -> int:
"""撤销某用户全部会话(logout-all)。返回实际撤销的行数。
V1 先备着:对应 FR 后续的「退出全部设备」,本批次不接路由,留实现是为了
把「会话可撤销」的数据通路一次铺好。
"""
result = await session.execute(
select(UserSession)
.where(UserSession.user_id == user_id, UserSession.revoked.is_(False))
.with_for_update()
)
now = datetime.now(UTC)
revoked_count = 0
for row in result.scalars():
row.revoked = True
row.revoked_at = now
revoked_count += 1
return revoked_count
+36
View File
@@ -0,0 +1,36 @@
"""identity 域的请求/响应模型(T4:双令牌会话,FR-ACC-05)。
``apps/api/schemas.py`` 是本批次的共享禁改文件,refresh/logout 的契约模型
放在域内这里,由 ``apps/api/routers/auth.py`` 引用;wire 响应仍只是统一
信封 (架构文档 §9.2) 里的 ``data`` 部分。
"""
from __future__ import annotations
from pydantic import BaseModel, Field
#: 生成端 token_urlsafe(48) 恒为 64 字符;上限放宽到 128 容忍未来换编码。
_REFRESH_TOKEN_MAX_LENGTH = 128
class RefreshRequest(BaseModel):
refresh_token: str = Field(min_length=1, max_length=_REFRESH_TOKEN_MAX_LENGTH)
class LogoutRequest(BaseModel):
refresh_token: str = Field(min_length=1, max_length=_REFRESH_TOKEN_MAX_LENGTH)
class TokenPairResponse(BaseModel):
"""dev-token 与 /auth/refresh 共用的双令牌响应。
旧字段名 ``access_token/token_type/expires_in/user_id`` 保持不变
(smoke.sh 与既有测试依赖),新增 ``refresh_token/refresh_expires_in``。
"""
access_token: str
refresh_token: str
token_type: str = "Bearer"
expires_in: int = Field(description="access token 有效期(秒)")
refresh_expires_in: int = Field(description="refresh token 有效期(秒)")
user_id: str = Field(description="雪花 ID 以字符串返回,避免 JS 精度丢失")
+104 -4
View File
@@ -5,25 +5,47 @@ import-linter enforces this (contract "identity must not depend on any other
business domain"). business domain").
V1 scope still missing here: 华为账号登录 (FR-ACC-01)、手机号验证码 (FR-ACC-04)、 V1 scope still missing here: 华为账号登录 (FR-ACC-01)、手机号验证码 (FR-ACC-04)、
双令牌与撤销 (FR-ACC-05)、游客合并 (FR-ACC-03)、注销 (FR-ACC-09)、未成年判定 游客合并 (FR-ACC-03)、注销 (FR-ACC-09)、未成年判定 (FR-ACC-11). See
(FR-ACC-11). See packages/identity/README.md. packages/identity/README.md. 双令牌与撤销 (FR-ACC-05) 已由 refresh 会话补齐
(`issue_session`/`rotate`/`revoke`)。
""" """
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from packages.domain.ids import next_id from packages.domain.ids import next_id
from packages.identity import repo from packages.identity import repo
from packages.identity.tokens import generate_refresh_token, hash_refresh_token
from packages.shared.config import get_settings
from packages.shared.errors import AppError, ErrorCode from packages.shared.errors import AppError, ErrorCode
from packages.shared.models import Consent, User, UserIdentity from packages.shared.logging import get_logger
from packages.shared.models import Consent, User, UserIdentity, UserSession
logger = get_logger(__name__)
#: Provider used by the dev-only token endpoint. Never valid in prod. #: Provider used by the dev-only token endpoint. Never valid in prod.
DEV_PROVIDER = "dev" DEV_PROVIDER = "dev"
@dataclass(frozen=True, slots=True)
class SessionIssued:
"""一次 refresh 签发的结果。
``refresh_token`` 明文只在本对象里短暂存在,由调用方直接下发给客户端;
服务端只留 ``session_id``(撤销句柄)与 ``expires_at``。``user_id`` 供
/auth/refresh 换发 access JWT 使用——refresh 本身不携带任何可解码信息。
"""
session_id: int
user_id: int
refresh_token: str
expires_at: datetime
class IdentityService: class IdentityService:
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
self._session = session self._session = session
@@ -98,3 +120,81 @@ class IdentityService:
) )
await self._session.flush() await self._session.flush()
return consent return consent
# --- refresh 会话(FR-ACC-05)-------------------------------------------
async def issue_session(self, user_id: int) -> SessionIssued:
"""登录成功后签发新 refresh 会话。
先 ``get_user``:已注销/被禁用的账号不得再拿新会话(防已注销用户续签)。
TTL 在用时读进程级配置而非构造器注入——``apps/api/deps.py``(共享禁改
文件)构造本 service 时拿不到 Settings;测试借 ``get_settings.cache_clear()``
注入 TTL 即可命中过期分支。
"""
await self.get_user(user_id)
return await self._create_session(user_id)
async def rotate(self, refresh_token: str) -> SessionIssued:
"""一次性轮换:旧 refresh 用过即死,成功则当场换发新 token。
旧行撤销与新行创建发生在**同一事务**(调用方 apps/api/deps.session_dep
统一 commit):半途失败两者一起回滚,不会出现「旧 token 已死、新 token
未生」把用户锁在门外,也不会出现双活会话。
"""
token_hash = hash_refresh_token(refresh_token)
row = await repo.find_session_by_hash(self._session, token_hash)
if row is None:
raise AppError(ErrorCode.REFRESH_TOKEN_INVALID)
if row.revoked:
# 重放检测:已撤销的 refresh 又出现了(可能是被盗用的副本,也可能是
# 客户端重试)。V1 只告警 + 拒绝;RFC 6819 的完整做法是撤销整个
# token 家族(家族连坐),留作 V2 硬化项。
logger.warning("refresh_reuse_detected", user_id=row.user_id)
raise AppError(ErrorCode.SESSION_REVOKED)
now = datetime.now(UTC)
if row.expire_at < now:
# 过期行顺手标记撤销,别让死会话一直可查。注意:HTTP 路径上这个
# 写入会随 401 的统一回滚一起丢失(仅事务内标记),落库与否不影响
# 判定本身——过期的行本来也不可能再通过校验。
row.revoked = True
row.revoked_at = now
await self._session.flush()
raise AppError(ErrorCode.REFRESH_TOKEN_EXPIRED)
await self.get_user(row.user_id) # 防已注销用户续签
if not await repo.revoke_session(self._session, row.session_id):
# 并发轮换:另一请求刚撤销了这一行(revoke_session 的 WHERE 守卫
# 只放行第一个),后到者按重放处理。
raise AppError(ErrorCode.SESSION_REVOKED)
return await self._create_session(row.user_id)
async def revoke(self, refresh_token: str) -> bool:
"""登出:撤销该 refresh 对应的会话。
查无此 token 或已撤销都返回 False 而不报错——登出必须幂等,客户端
重试不应收到 4xx。
"""
token_hash = hash_refresh_token(refresh_token)
row = await repo.find_session_by_hash(self._session, token_hash)
if row is None:
return False
return await repo.revoke_session(self._session, row.session_id)
async def _create_session(self, user_id: int) -> SessionIssued:
token = generate_refresh_token()
expires_at = datetime.now(UTC) + timedelta(seconds=get_settings().refresh_token_ttl_seconds)
row = repo.add_session(
self._session,
UserSession(
session_id=next_id(),
user_id=user_id,
refresh_token_hash=hash_refresh_token(token),
expire_at=expires_at,
),
)
await self._session.flush()
return SessionIssued(
session_id=row.session_id,
user_id=user_id,
refresh_token=token,
expires_at=row.expire_at,
)
+30
View File
@@ -0,0 +1,30 @@
"""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()
+145
View File
@@ -0,0 +1,145 @@
"""Refresh token 链路(FR-ACC-05)—— 真实 PostgreSQL,auth 用例不涉及 LLM。
与 test_vertical_slice.py 不同:这里不替换 LLM provider(没人会调到它),
只验证 dev-token → refresh 轮换 → logout 撤销的会话生命周期。
"""
from __future__ import annotations
import os
import uuid
from collections.abc import AsyncIterator
from typing import Any
import httpx
import pytest
def _infra_available() -> bool:
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _infra_available(),
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
),
]
@pytest.fixture
async def client() -> AsyncIterator[httpx.AsyncClient]:
from apps.api.main import create_app
from packages.shared.config import get_settings
app = create_app(get_settings())
transport = httpx.ASGITransport(app=app)
async with (
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
app.router.lifespan_context(app),
):
yield http
async def _dev_token(client: httpx.AsyncClient) -> dict[str, Any]:
"""每个用例用独立 device_key,避免共享用户状态互相污染。"""
resp = await client.post(
"/v1/auth/dev-token", json={"device_key": f"t4-{uuid.uuid4().hex[:8]}"}
)
assert resp.status_code == 200
body = resp.json()
assert body["code"] == 0
data = body["data"]
# 兼容性:旧字段名一字不改,新字段在场
assert data["access_token"]
assert data["expires_in"] > 0
assert data["user_id"]
assert data["refresh_token"]
return data
async def test_refresh_rotates_and_new_access_calls_quota(client: httpx.AsyncClient) -> None:
pair = await _dev_token(client)
resp = await client.post("/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]})
assert resp.status_code == 200
assert resp.json()["code"] == 0
data = resp.json()["data"]
# 轮换的核心契约是 refresh 换新(随机串必不同)。access JWT 不比不等:
# 同一用户同一秒签发时 claims 完全一致,token 字节级相同是正常现象,
# 断言不等只会让用例变成秒边界的掷硬币。
assert data["refresh_token"] != pair["refresh_token"]
assert data["refresh_expires_in"] > 0
quota = await client.get(
"/v1/quota", headers={"Authorization": f"Bearer {data['access_token']}"}
)
assert quota.status_code == 200
assert quota.json()["code"] == 0
async def test_old_refresh_is_single_use(client: httpx.AsyncClient) -> None:
pair = await _dev_token(client)
first = await client.post("/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]})
assert first.status_code == 200
second = await client.post("/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]})
assert second.status_code == 401
assert second.json()["code"] == 40105
async def test_logout_revokes_the_session(client: httpx.AsyncClient) -> None:
pair = await _dev_token(client)
headers = {"Authorization": f"Bearer {pair['access_token']}"}
out = await client.post(
"/v1/auth/logout", json={"refresh_token": pair["refresh_token"]}, headers=headers
)
assert out.status_code == 200
assert out.json()["data"] == {"revoked": True}
# 幂等登出:同一 token 再登出一次不报错,revoked=false
again = await client.post(
"/v1/auth/logout", json={"refresh_token": pair["refresh_token"]}, headers=headers
)
assert again.status_code == 200
assert again.json()["data"] == {"revoked": False}
refreshed = await client.post("/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]})
assert refreshed.status_code == 401
assert refreshed.json()["code"] == 40105
async def test_forged_refresh_token_is_rejected(client: httpx.AsyncClient) -> None:
resp = await client.post("/v1/auth/refresh", json={"refresh_token": "forged-" + "x" * 32})
assert resp.status_code == 401
assert resp.json()["code"] == 40103
async def test_expired_refresh_token_reports_40104() -> None:
"""TTL=0 注入过期路径:签发即过期,refresh 必须走 40104 而非 40103/40105。"""
from apps.api.main import create_app
from packages.shared.config import get_settings
with pytest.MonkeyPatch.context() as mp:
mp.setenv("REFRESH_TOKEN_TTL_SECONDS", "0")
get_settings.cache_clear()
app = create_app(get_settings())
try:
transport = httpx.ASGITransport(app=app)
async with (
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
app.router.lifespan_context(app),
):
pair = await _dev_token(http)
assert pair["refresh_expires_in"] == 0
resp = await http.post(
"/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]}
)
assert resp.status_code == 401
assert resp.json()["code"] == 40104
finally:
# 恢复进程级配置缓存:打补丁的 Settings 不能泄漏给后续用例
get_settings.cache_clear()
+58
View File
@@ -0,0 +1,58 @@
"""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()
)