feat(identity): Huawei Account Kit login via OIDC JWKS verification

POST /auth/huawei:JWKS 验签(kid 缓存/轮换)+ claims 严格校验 +
UnionID 映射 + T4 双令牌签发(FR-ACC-01;真实联调待 AGC 配置)。

- packages/identity/huawei.py(新建):HuaweiIdTokenVerifier——RS256 验签,
  JWKS 内存缓存(TTL 可配 + 单调钟;kid 未命中强制刷新一次重试,锁内用
  拉取代数防止强制刷新被新鲜缓存短路);aud/iss/exp/iat 全查,audience
  空配置直接拒绝而非跳过;union_id 缺失拒绝(降级 open_id 策略不做,
  留联调期决定);错误映射 40102/40101/60000,无新错误码。
  HuaweiLoginRequest/HuaweiClaims 一并定义于此,未动 schemas.py。
- apps/api/routers/auth.py:追加 /huawei 端点;get_huawei_verifier 依赖
  建于路由模块内(缓存到 app.state,可预注入替换),未动 deps.py;
  复用 T4 的 get_or_create_by_external_identity + issue_session +
  TokenPairResponse 与既有 _token_pair/_access_token。
- tests/unit/test_huawei_verifier.py:cryptography 自造 RSA + 本地 JWKS
  (零外网),19 例覆盖合法 claims/错误签名/过期/aud/iss/iat 缺失/
  audience 未配置/union_id 缺失/HS256 混淆/JWKS 拉取失败/kid 轮换/缓存
  TTL。
- tests/integration/test_auth_huawei.py:FakeFetcher 预注入 app.state,
  真实 PG 验证首次建用户 + 双令牌、同 union_id 复登同 user_id、新 access
  调 /quota、refresh 可轮换、伪造 token 40102。

检查:ruff/format/mypy(T6 列表)/lint-imports 全绿;单测 257 passed
(基线 238 + 新增 19);集成 18 passed(含新增 2)。

偏差说明:无
This commit is contained in:
xsl
2026-08-14 19:59:15 +08:00
parent d5f366fa4c
commit 6c77ff4989
4 changed files with 726 additions and 4 deletions
+54 -4
View File
@@ -5,17 +5,22 @@
且接受的设计权衡(要即时失效就得引入黑名单或极短 TTL,V1 不做)——
「登出后旧令牌立即失效」的验收对象是 refresh 令牌与会话。
明确不做(防止后人误判「漏了」):华为账号登录(T-P1-12,等资质)、手机验证码
(T-P1-12b,等短信通道)、游客/合并/注销。dev-token 端点不是正式鉴权设计,
仅为纵向切片与联调服务:``DEV_AUTH_ENABLED`` + 本地环境双开关才放行。
华为账号登录(T-P1-12)已实现(``/auth/huawei``):Account Kit id_token 的
OIDC 验签与 UnionID 映射在 :mod:`packages.identity.huawei`,代码先行——真实
联调待 AGC 配置(见该模块 docstring)。明确不做(防止后人误判「漏了」):
手机验证码(T-P1-12b,等短信通道)、游客/合并/注销。dev-token 端点不是正式
鉴权设计,仅为纵向切片与联调服务:``DEV_AUTH_ENABLED`` + 本地环境双开关才放行。
"""
from __future__ import annotations
from fastapi import APIRouter
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
@@ -92,3 +97,48 @@ async def logout(
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))