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