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
+57 -1
View File
@@ -2,10 +2,12 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import select
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:
@@ -36,3 +38,57 @@ def add_identity(session: AsyncSession, identity: UserIdentity) -> UserIdentity:
def add_consent(session: AsyncSession, consent: Consent) -> Consent:
session.add(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").
V1 scope still missing here: 华为账号登录 (FR-ACC-01)、手机号验证码 (FR-ACC-04)、
双令牌与撤销 (FR-ACC-05)、游客合并 (FR-ACC-03)、注销 (FR-ACC-09)、未成年判定
(FR-ACC-11). See packages/identity/README.md.
游客合并 (FR-ACC-03)、注销 (FR-ACC-09)、未成年判定 (FR-ACC-11). See
packages/identity/README.md. 双令牌与撤销 (FR-ACC-05) 已由 refresh 会话补齐
(`issue_session`/`rotate`/`revoke`)。
"""
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 packages.domain.ids import next_id
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.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.
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:
def __init__(self, session: AsyncSession) -> None:
self._session = session
@@ -98,3 +120,81 @@ class IdentityService:
)
await self._session.flush()
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()