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)。 偏差说明:无
297 lines
10 KiB
Python
297 lines
10 KiB
Python
"""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
|