Files
meida_backend/packages/identity/repo.py
T
xsl 140a63effa 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 下类型
不可用。
2026-08-14 16:21:52 +08:00

95 lines
3.0 KiB
Python
Executable File

"""Data access for the identity domain. **Only this module may touch id_* tables.**"""
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, UserSession
async def get_user(session: AsyncSession, user_id: int) -> User | None:
return await session.get(User, user_id)
async def find_identity(
session: AsyncSession, *, provider: str, union_id: str
) -> UserIdentity | None:
result = await session.execute(
select(UserIdentity).where(
UserIdentity.provider == provider, UserIdentity.union_id == union_id
)
)
return result.scalar_one_or_none()
def add_user(session: AsyncSession, user: User) -> User:
session.add(user)
return user
def add_identity(session: AsyncSession, identity: UserIdentity) -> UserIdentity:
session.add(identity)
return identity
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