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:
@@ -0,0 +1,140 @@
|
||||
"""POST /v1/auth/huawei 集成——真实 PostgreSQL + FakeFetcher 本地 JWKS,零外网。
|
||||
|
||||
覆盖(FR-ACC-01):首次登录自动建用户 + T4 双令牌、同 union_id 复登映射到
|
||||
同一 user_id、新 access 可调 /quota、伪造 id_token 被拒(40102)。
|
||||
验签细节(claims/kid 轮换)由 tests/unit/test_huawei_verifier.py 穷举。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from jwt.algorithms import RSAAlgorithm
|
||||
|
||||
from packages.identity.huawei import HuaweiIdTokenVerifier
|
||||
|
||||
AUDIENCE = "app-int-1008"
|
||||
ISSUER = "https://accounts.huawei.com"
|
||||
JWKS_URL = "https://jwks.local/certs"
|
||||
KID = "kid-int"
|
||||
|
||||
# 模块级固定密钥对:同一用例内多次登录共享 JWKS,避免每个 token 重新生成密钥。
|
||||
_SIGNING_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
_WRONG_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
def _jwk(kid: str, private_key: rsa.RSAPrivateKey) -> dict[str, Any]:
|
||||
jwk: dict[str, Any] = json.loads(RSAAlgorithm.to_jwk(private_key.public_key()))
|
||||
jwk.update({"kid": kid, "alg": "RS256", "use": "sig"})
|
||||
return jwk
|
||||
|
||||
|
||||
async def _local_jwks_fetch(url: str) -> str:
|
||||
assert url == JWKS_URL
|
||||
return json.dumps({"keys": [_jwk(KID, _SIGNING_KEY)]})
|
||||
|
||||
|
||||
def _id_token(*, union_id: str | None = None, key: rsa.RSAPrivateKey = _SIGNING_KEY) -> str:
|
||||
"""自签 id_token:union_id 缺省随机(新用户),指定则复现同一外部身份。"""
|
||||
now = int(time.time())
|
||||
payload: dict[str, Any] = {
|
||||
"iss": ISSUER,
|
||||
"aud": AUDIENCE,
|
||||
"iat": now - 60,
|
||||
"exp": now + 600,
|
||||
"union_id": union_id or f"union-{uuid.uuid4().hex[:12]}",
|
||||
"open_id": f"open-{uuid.uuid4().hex[:12]}",
|
||||
}
|
||||
return jwt.encode(payload, key, algorithm="RS256", headers={"kid": KID})
|
||||
|
||||
|
||||
def _infra_available() -> bool:
|
||||
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
|
||||
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(
|
||||
not _infra_available(),
|
||||
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncIterator[httpx.AsyncClient]:
|
||||
"""预注入 FakeFetcher 验签器到 app.state。
|
||||
|
||||
get_huawei_verifier 优先读 app.state 缓存,因此注入后整条链路不触网;
|
||||
verifier 自带 audience/issuer,也不依赖 .env 是否已配 AGC 凭证。
|
||||
"""
|
||||
from apps.api.main import create_app
|
||||
from packages.shared.config import get_settings
|
||||
|
||||
app = create_app(get_settings())
|
||||
app.state.huawei_verifier = HuaweiIdTokenVerifier(
|
||||
jwks_url=JWKS_URL,
|
||||
audience=AUDIENCE,
|
||||
issuer=ISSUER,
|
||||
cache_seconds=86400,
|
||||
http_fetch=_local_jwks_fetch,
|
||||
)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
|
||||
app.router.lifespan_context(app),
|
||||
):
|
||||
yield http
|
||||
|
||||
|
||||
async def _login(client: httpx.AsyncClient, id_token: str) -> dict[str, Any]:
|
||||
resp = await client.post("/v1/auth/huawei", json={"id_token": id_token})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
return body["data"]
|
||||
|
||||
|
||||
async def test_huawei_login_issues_pair_relogin_maps_same_user(client: httpx.AsyncClient) -> None:
|
||||
union_id = f"union-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
# 首次登录:自动建用户 + T4 双令牌(契约字段与 dev-token/refresh 一致)
|
||||
first = await _login(client, _id_token(union_id=union_id))
|
||||
assert first["access_token"]
|
||||
assert first["refresh_token"]
|
||||
assert first["user_id"]
|
||||
assert first["token_type"] == "Bearer"
|
||||
assert first["refresh_expires_in"] > 0
|
||||
|
||||
# 同 union_id 复登:映射唯一(FR-ACC-01),换发全新会话
|
||||
second = await _login(client, _id_token(union_id=union_id))
|
||||
assert second["user_id"] == first["user_id"]
|
||||
assert second["refresh_token"] != first["refresh_token"]
|
||||
|
||||
# 新 access 直接可调需鉴权接口
|
||||
quota = await client.get(
|
||||
"/v1/quota", headers={"Authorization": f"Bearer {first['access_token']}"}
|
||||
)
|
||||
assert quota.status_code == 200
|
||||
assert quota.json()["code"] == 0
|
||||
|
||||
# 华为签发的 refresh 同样进 T4 轮换链路
|
||||
rotated = await client.post("/v1/auth/refresh", json={"refresh_token": first["refresh_token"]})
|
||||
assert rotated.status_code == 200
|
||||
assert rotated.json()["data"]["refresh_token"] != first["refresh_token"]
|
||||
|
||||
|
||||
async def test_forged_id_token_rejected(client: httpx.AsyncClient) -> None:
|
||||
# 同 kid 但另一把私钥签发:JWKS 命中 kid、验签失败 → 40102
|
||||
resp = await client.post("/v1/auth/huawei", json={"id_token": _id_token(key=_WRONG_KEY)})
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 40102
|
||||
@@ -0,0 +1,296 @@
|
||||
"""HuaweiIdTokenVerifier 纯逻辑单测——自造 RSA 密钥对 + 本地 JWKS,零外网。
|
||||
|
||||
覆盖:合法 claims 解析、错误签名、过期、aud/iss 不符、audience 未配置、
|
||||
union_id 缺失、非 RS256 算法、JWKS 拉取失败、kid 轮换刷新与缓存 TTL。
|
||||
路由与 DB 路径由 tests/integration/test_auth_huawei.py 验证。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from jwt.algorithms import RSAAlgorithm
|
||||
|
||||
from packages.identity.huawei import HuaweiIdTokenVerifier
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
|
||||
AUDIENCE = "app-1008"
|
||||
ISSUER = "https://accounts.huawei.com"
|
||||
JWKS_URL = "https://jwks.local/certs"
|
||||
|
||||
Fetch = Callable[[str], Awaitable[str]]
|
||||
|
||||
|
||||
class FakeFetcher:
|
||||
"""本地 JWKS 源:可热替换内容(模拟轮换)、统计拉取次数、可抛错。"""
|
||||
|
||||
def __init__(self, jwks: dict[str, Any]) -> None:
|
||||
self.jwks = jwks
|
||||
self.calls = 0
|
||||
self.fail = False
|
||||
|
||||
async def __call__(self, url: str) -> str:
|
||||
assert url == JWKS_URL
|
||||
self.calls += 1
|
||||
if self.fail:
|
||||
msg = "network down"
|
||||
raise RuntimeError(msg)
|
||||
return json.dumps(self.jwks)
|
||||
|
||||
|
||||
def _keypair() -> rsa.RSAPrivateKey:
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
def _jwk(kid: str, private_key: rsa.RSAPrivateKey) -> dict[str, Any]:
|
||||
jwk: dict[str, Any] = json.loads(RSAAlgorithm.to_jwk(private_key.public_key()))
|
||||
jwk.update({"kid": kid, "alg": "RS256", "use": "sig"})
|
||||
return jwk
|
||||
|
||||
|
||||
def _token(
|
||||
private_key: rsa.RSAPrivateKey | str,
|
||||
*,
|
||||
kid: str = "kid-1",
|
||||
alg: str = "RS256",
|
||||
aud: str = AUDIENCE,
|
||||
iss: str = ISSUER,
|
||||
exp_in: int = 600,
|
||||
iat_in: int = -60,
|
||||
payload_overrides: dict[str, Any] | None = None,
|
||||
drop_claims: tuple[str, ...] = (),
|
||||
) -> str:
|
||||
"""自签 id_token。时间参数以当前时刻为基准(exp_in/iat_in 为相对秒)。"""
|
||||
now = int(time.time())
|
||||
payload: dict[str, Any] = {
|
||||
"iss": iss,
|
||||
"aud": aud,
|
||||
"iat": now + iat_in,
|
||||
"exp": now + exp_in,
|
||||
"union_id": "union-1",
|
||||
"open_id": "open-1",
|
||||
}
|
||||
payload.update(payload_overrides or {})
|
||||
for claim in drop_claims:
|
||||
payload.pop(claim, None)
|
||||
return jwt.encode(payload, private_key, algorithm=alg, headers={"kid": kid})
|
||||
|
||||
|
||||
def _verifier(
|
||||
fetcher: Fetch,
|
||||
*,
|
||||
audience: str = AUDIENCE,
|
||||
cache_seconds: int = 86400,
|
||||
) -> HuaweiIdTokenVerifier:
|
||||
return HuaweiIdTokenVerifier(
|
||||
jwks_url=JWKS_URL,
|
||||
audience=audience,
|
||||
issuer=ISSUER,
|
||||
cache_seconds=cache_seconds,
|
||||
http_fetch=fetcher,
|
||||
)
|
||||
|
||||
|
||||
def _assert_code(exc_info: pytest.ExceptionInfo[AppError], code: ErrorCode) -> None:
|
||||
assert exc_info.value.code == code, (
|
||||
f"期望 {code}({int(code)}),实际 {exc_info.value.code}({int(exc_info.value.code)})"
|
||||
)
|
||||
|
||||
|
||||
# --- 合法路径 ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_valid_token_parses_claims() -> None:
|
||||
key = _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
|
||||
before = int(time.time())
|
||||
claims = await _verifier(fetcher).verify(_token(key))
|
||||
after = int(time.time())
|
||||
|
||||
assert claims.union_id == "union-1"
|
||||
assert claims.open_id == "open-1"
|
||||
# 时间断言容忍秒边界(token 在 before/after 之间签发)
|
||||
assert before - 60 <= claims.iat <= after - 59
|
||||
assert before + 600 <= claims.exp <= after + 601
|
||||
|
||||
|
||||
async def test_valid_token_without_open_id_still_maps_by_union_id() -> None:
|
||||
# open_id 可缺(union_id 才是映射键),缺了不应拒绝登录
|
||||
key = _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
|
||||
claims = await _verifier(fetcher).verify(_token(key, drop_claims=("open_id",)))
|
||||
assert claims.union_id == "union-1"
|
||||
assert claims.open_id is None
|
||||
|
||||
|
||||
async def test_union_id_camel_case_claim_is_accepted() -> None:
|
||||
# 联调前对 snake/camel 两种拼法都收(见 huawei._first_str),此处锁行为
|
||||
key = _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
|
||||
token = _token(key, payload_overrides={"unionID": "union-c"}, drop_claims=("union_id",))
|
||||
claims = await _verifier(fetcher).verify(token)
|
||||
assert claims.union_id == "union-c"
|
||||
|
||||
|
||||
# --- token 侧拒绝(40102 / 40101)------------------------------------------
|
||||
|
||||
|
||||
async def test_wrong_signing_key_rejected() -> None:
|
||||
# kid 相同但用另一把私钥签 → JWKS 命中 kid 却验签失败 → 40102
|
||||
signing_key, jwks_key = _keypair(), _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", jwks_key)]})
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
await _verifier(fetcher).verify(_token(signing_key, kid="kid-1"))
|
||||
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
|
||||
|
||||
|
||||
async def test_expired_token_rejected() -> None:
|
||||
key = _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
await _verifier(fetcher).verify(_token(key, exp_in=-30, iat_in=-3600))
|
||||
_assert_code(exc_info, ErrorCode.TOKEN_EXPIRED)
|
||||
|
||||
|
||||
async def test_wrong_audience_rejected() -> None:
|
||||
key = _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
await _verifier(fetcher).verify(_token(key, aud="someone-else"))
|
||||
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
|
||||
|
||||
|
||||
async def test_missing_audience_claim_rejected() -> None:
|
||||
key = _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
await _verifier(fetcher).verify(_token(key, drop_claims=("aud",)))
|
||||
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
|
||||
|
||||
|
||||
async def test_wrong_issuer_rejected() -> None:
|
||||
key = _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
await _verifier(fetcher).verify(_token(key, iss="https://evil.example.com"))
|
||||
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
|
||||
|
||||
|
||||
async def test_missing_iat_rejected() -> None:
|
||||
key = _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
await _verifier(fetcher).verify(_token(key, drop_claims=("iat",)))
|
||||
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
|
||||
|
||||
|
||||
async def test_audience_unconfigured_rejected() -> None:
|
||||
# AGC 未配置(HUAWEI_AUDIENCE 空)时必须拒绝而不是跳过 aud 校验
|
||||
key = _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
await _verifier(fetcher, audience="").verify(_token(key))
|
||||
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
|
||||
# 且应在拉取 JWKS 之前就拒绝(未联调状态不该打上游)
|
||||
assert fetcher.calls == 0
|
||||
|
||||
|
||||
async def test_missing_union_id_rejected() -> None:
|
||||
key = _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
await _verifier(fetcher).verify(_token(key, drop_claims=("union_id",)))
|
||||
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
|
||||
|
||||
|
||||
async def test_hs256_token_rejected_before_verification() -> None:
|
||||
# alg 混淆攻击:拿对称密钥伪造 header,必须在取 JWKS 前拒绝
|
||||
# (密钥给足 32 字节,避免 PyJWT 的短密钥告警干扰输出)
|
||||
key = _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
await _verifier(fetcher).verify(
|
||||
_token("hmac-secret-0123456789abcdef0123456789abcdef", alg="HS256", kid="kid-1")
|
||||
)
|
||||
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
|
||||
assert fetcher.calls == 0
|
||||
|
||||
|
||||
async def test_garbage_token_rejected() -> None:
|
||||
fetcher = FakeFetcher({"keys": []})
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
await _verifier(fetcher).verify("not-a-jwt-at-all")
|
||||
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
|
||||
|
||||
|
||||
# --- JWKS 侧(60000 / kid 轮换 / 缓存)--------------------------------------
|
||||
|
||||
|
||||
async def test_jwks_fetch_failure_maps_upstream_error() -> None:
|
||||
key = _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
|
||||
fetcher.fail = True
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
await _verifier(fetcher).verify(_token(key))
|
||||
_assert_code(exc_info, ErrorCode.UPSTREAM_ERROR)
|
||||
|
||||
|
||||
async def test_jwks_payload_not_jwks_shape_maps_upstream_error() -> None:
|
||||
key = _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
|
||||
fetcher.jwks = {"unexpected": True}
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
await _verifier(fetcher).verify(_token(key))
|
||||
_assert_code(exc_info, ErrorCode.UPSTREAM_ERROR)
|
||||
|
||||
|
||||
async def test_unknown_kid_rejected_after_one_forced_refresh() -> None:
|
||||
key = _keypair()
|
||||
other = _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
await _verifier(fetcher).verify(_token(other, kid="kid-unknown"))
|
||||
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
|
||||
# 未命中 → 强制刷新一次 → 仍没有 → 拒绝:恰好两次拉取
|
||||
assert fetcher.calls == 2
|
||||
|
||||
|
||||
async def test_kid_rotation_refreshes_cache_and_retry_succeeds() -> None:
|
||||
old_key, new_key = _keypair(), _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", old_key)]})
|
||||
verifier = _verifier(fetcher)
|
||||
claims = await verifier.verify(_token(old_key, kid="kid-1"))
|
||||
assert claims.union_id == "union-1"
|
||||
assert fetcher.calls == 1 # TTL 内第二次验证应走缓存
|
||||
|
||||
# 上游轮换:JWKS 换成 kid-2,本地缓存仍是旧 keys
|
||||
fetcher.jwks = {"keys": [_jwk("kid-2", new_key)]}
|
||||
claims = await verifier.verify(
|
||||
_token(new_key, kid="kid-2", payload_overrides={"union_id": "union-2"})
|
||||
)
|
||||
assert claims.union_id == "union-2"
|
||||
assert fetcher.calls == 2 # kid 未命中触发一次强制刷新,随后验签成功
|
||||
|
||||
|
||||
async def test_cache_reuses_jwks_within_ttl() -> None:
|
||||
key = _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
|
||||
verifier = _verifier(fetcher)
|
||||
for _ in range(3):
|
||||
await verifier.verify(_token(key))
|
||||
assert fetcher.calls == 1
|
||||
|
||||
|
||||
async def test_cache_expires_when_ttl_is_zero() -> None:
|
||||
key = _keypair()
|
||||
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
|
||||
verifier = _verifier(fetcher, cache_seconds=0)
|
||||
await verifier.verify(_token(key))
|
||||
await verifier.verify(_token(key))
|
||||
assert fetcher.calls == 2
|
||||
Reference in New Issue
Block a user