Files
meida_backend/apps/api/routers/auth.py
T

149 lines
6.0 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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))