"""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})