Files
meida_backend/apps/api/routers/auth.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.7 KiB
Python
Executable File

"""Auth endpoints: dev-token / refresh / logout (FR-ACC-05 双令牌会话).
登出语义 = 撤销 ``id_session`` 会话,**refresh token 立即失效**。access JWT
无状态、15 分钟 TTL:登出后旧 access 在其 TTL 内仍有效的残留窗口是 V1 已知
且接受的设计权衡(要即时失效就得引入黑名单或极短 TTL,V1 不做)——
「登出后旧令牌立即失效」的验收对象是 refresh 令牌与会话。
明确不做(防止后人误判「漏了」):华为账号登录(T-P1-12,等资质)、手机验证码
(T-P1-12b,等短信通道)、游客/合并/注销。dev-token 端点不是正式鉴权设计,
仅为纵向切片与联调服务:``DEV_AUTH_ENABLED`` + 本地环境双开关才放行。
"""
from __future__ import annotations
from fastapi import APIRouter
from apps.api.deps import CurrentUserId, IdentityServiceDep, SettingsDep
from apps.api.schemas import DevTokenRequest
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.response import ok
from packages.shared.security import create_access_token
router = APIRouter(prefix="/auth", tags=["auth"])
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(
payload: DevTokenRequest,
settings: SettingsDep,
identity: IdentityServiceDep,
) -> dict[str, object]:
if not (settings.dev_auth_enabled and settings.is_local):
raise AppError(ErrorCode.NOT_FOUND)
user = await identity.get_or_create_by_external_identity(
provider=DEV_PROVIDER,
union_id=payload.device_key,
register_source="dev",
)
issued = await identity.issue_session(user.user_id)
return ok(_token_pair(settings, _access_token(settings, user.user_id), issued))
@router.post("/refresh", summary="Rotate a refresh token into a new token pair")
async def refresh(
payload: RefreshRequest,
settings: SettingsDep,
identity: IdentityServiceDep,
) -> dict[str, object]:
issued = await identity.rotate(payload.refresh_token)
return ok(_token_pair(settings, _access_token(settings, issued.user_id), issued))
@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})