149 lines
6.0 KiB
Python
Executable File
149 lines
6.0 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)已实现(``/auth/huawei``):Account Kit id_token 的
|
||
OIDC 验签与 UnionID 映射在 :mod:`packages.identity.huawei`,代码先行——真实
|
||
联调待 AGC 配置(见该模块 docstring)。明确不做(防止后人误判「漏了」):
|
||
游客/合并/注销。dev-token 端点不是正式鉴权设计,仅为纵向切片与联调服务:
|
||
``DEV_AUTH_ENABLED`` + 本地环境双开关才放行。
|
||
|
||
短信验证码通道已由邮箱验证码通道替代(FR-ACC-14 v2 变更,TD-32 短信实现
|
||
仅作参照保留):正式兜底登录走 ``auth_email`` 路由的
|
||
``/auth/email/code`` + ``/auth/email/login``。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, Depends, Request
|
||
|
||
from apps.api.deps import CurrentUserId, IdentityServiceDep, SettingsDep
|
||
from apps.api.schemas import DevTokenRequest
|
||
from packages.identity.huawei import HuaweiIdTokenVerifier, HuaweiLoginRequest
|
||
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})
|
||
|
||
|
||
def get_huawei_verifier(request: Request, settings: SettingsDep) -> HuaweiIdTokenVerifier:
|
||
"""从配置构造华为验签器,缓存到 app.state(JWKS 缓存跨请求复用)。
|
||
|
||
依赖建在路由模块内而非 deps.py——deps.py 是本批共享禁改文件。
|
||
预注入 ``app.state.huawei_verifier`` 可整体替换(集成测试借这条缝
|
||
注入 FakeFetcher,全链路零外网)。
|
||
"""
|
||
verifier: HuaweiIdTokenVerifier | None = getattr(request.app.state, "huawei_verifier", None)
|
||
if verifier is None:
|
||
verifier = HuaweiIdTokenVerifier(
|
||
jwks_url=settings.huawei_jwks_url,
|
||
audience=settings.huawei_audience,
|
||
issuer=settings.huawei_issuer,
|
||
cache_seconds=settings.huawei_jwks_cache_seconds,
|
||
)
|
||
request.app.state.huawei_verifier = verifier
|
||
return verifier
|
||
|
||
|
||
HuaweiVerifierDep = Annotated[HuaweiIdTokenVerifier, Depends(get_huawei_verifier)]
|
||
|
||
|
||
@router.post("/huawei", summary="华为账号授权登录(FR-ACC-01)")
|
||
async def huawei_login(
|
||
payload: HuaweiLoginRequest,
|
||
settings: SettingsDep,
|
||
identity: IdentityServiceDep,
|
||
verifier: HuaweiVerifierDep,
|
||
) -> dict[str, object]:
|
||
"""Account Kit id_token → JWKS 验签 → UnionID 映射 → T4 双令牌。
|
||
|
||
audience 未配置(AGC 未就绪)时 verify 直接 40102,端点天然不可用——
|
||
这是有意的:联调前放行等于允许任意自签 token 登录。
|
||
"""
|
||
claims = await verifier.verify(payload.id_token)
|
||
user = await identity.get_or_create_by_external_identity(
|
||
provider="huawei",
|
||
union_id=claims.union_id,
|
||
open_id=claims.open_id,
|
||
register_source="huawei",
|
||
)
|
||
issued = await identity.issue_session(user.user_id)
|
||
return ok(_token_pair(settings, _access_token(settings, user.user_id), issued))
|