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 下类型 不可用。
95 lines
3.0 KiB
Python
Executable File
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
|