321 lines
13 KiB
Python
321 lines
13 KiB
Python
"""T-01 JWT 鉴权(auth_service 验签/吊销 + deps 工厂 + Agent 准入矩阵)。
|
||
|
||
通道矩阵:JWT(全环境,强制 X-Agent-Type 交叉校验)/ dev debug 头兜底 /
|
||
非 dev 无 Bearer 401 留痕。401/403 全部经 deps 审计(P-05),sqlite 断言。
|
||
RS256 用 cryptography 现场生成密钥对(生产形态走通);HS256 走 dev secret。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from jose import jwt as jose_jwt
|
||
from sqlalchemy import text
|
||
|
||
from _ddl import create_sqlite_engine
|
||
|
||
from app.api import deps as deps_mod
|
||
from app.api import risk as risk_api
|
||
from app.config.settings import settings
|
||
from app.main import app
|
||
from app.repository.risk_repository import RiskRepository
|
||
from app.service import auth_service
|
||
from app.service.risk import redis_gateway
|
||
|
||
|
||
class FakeGateway:
|
||
"""Redis fake:吊销黑名单语义(exists/set_ex 记录)。"""
|
||
|
||
def __init__(self):
|
||
self.revoked: set[str] = set()
|
||
self.messages: list = []
|
||
self.deletes: list = []
|
||
|
||
def publish(self, channel, payload):
|
||
self.messages.append((channel, payload))
|
||
|
||
def delete(self, *keys):
|
||
self.deletes.append(keys)
|
||
|
||
def exists(self, key: str) -> bool:
|
||
return key in self.revoked
|
||
|
||
def set_ex(self, key, value, ttl):
|
||
self.revoked.add(key)
|
||
|
||
|
||
@pytest.fixture()
|
||
def env(monkeypatch):
|
||
engine = create_sqlite_engine()
|
||
repo = RiskRepository(engine=engine)
|
||
monkeypatch.setattr(risk_api, "_repo", lambda: repo)
|
||
monkeypatch.setattr(deps_mod, "RiskRepository", lambda: repo)
|
||
monkeypatch.setattr(redis_gateway, "_gateway", FakeGateway())
|
||
yield {"client": TestClient(app, raise_server_exceptions=False), "repo": repo, "engine": engine}
|
||
engine.dispose()
|
||
|
||
|
||
def _bearer(tok: str, agent: str = "risk") -> dict:
|
||
return {"Authorization": f"Bearer {tok}", "X-Agent-Type": agent}
|
||
|
||
|
||
def _audit_rows(engine, decision: str) -> list:
|
||
with engine.connect() as conn:
|
||
return conn.execute(
|
||
text("SELECT event_type, actor_id, decision, input_summary FROM audit_log WHERE decision = :d"),
|
||
{"d": decision},
|
||
).mappings().all()
|
||
|
||
|
||
# ---------- JWT 通道基本链路 ----------
|
||
|
||
|
||
def test_jwt_risk_officer_access_alerts(env):
|
||
tok = auth_service.issue_dev_token(sub="STAFF-30001", roles=["risk_officer"])
|
||
r = env["client"].get("/api/risk/alerts", headers=_bearer(tok))
|
||
assert r.status_code == 200
|
||
assert r.json()["disclaimer"]
|
||
|
||
|
||
def test_jwt_missing_agent_type_401_with_audit(env):
|
||
tok = auth_service.issue_dev_token(sub="STAFF-30001", roles=["risk_officer"])
|
||
r = env["client"].get("/api/risk/alerts", headers={"Authorization": f"Bearer {tok}"})
|
||
assert r.status_code == 401
|
||
assert r.json()["error_code"] == "AUTH_401_MISSING_AGENT_TYPE"
|
||
assert _audit_rows(env["engine"], "unauthenticated")
|
||
|
||
|
||
def test_jwt_invalid_agent_type_400(env):
|
||
tok = auth_service.issue_dev_token(sub="STAFF-30001", roles=["risk_officer"])
|
||
r = env["client"].get("/api/risk/alerts", headers=_bearer(tok, agent="backoffice"))
|
||
assert r.status_code == 400 and r.json()["error_code"] == "BAD_REQUEST"
|
||
|
||
|
||
def test_jwt_customer_token_cannot_enter_risk_agent(env):
|
||
"""手册 §5.4:customer token 只能进 customer agent(交叉校验 403 + 审计)。"""
|
||
tok = auth_service.issue_dev_token(
|
||
sub="CUST-9527", roles=["customer"], token_type="customer", customer_id="CUST-9527"
|
||
)
|
||
r = env["client"].get("/api/risk/alerts", headers=_bearer(tok))
|
||
assert r.status_code == 403
|
||
assert r.json()["error_code"] == "AUTH_403_AGENT_MISMATCH"
|
||
assert _audit_rows(env["engine"], "forbidden")
|
||
|
||
|
||
def test_jwt_advisor_cannot_enter_risk_agent(env):
|
||
tok = auth_service.issue_dev_token(sub="STAFF-10086", roles=["advisor"])
|
||
r = env["client"].get("/api/risk/alerts", headers=_bearer(tok))
|
||
assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_AGENT_MISMATCH"
|
||
|
||
|
||
# ---------- 验签失败矩阵(统一 401 INVALID_TOKEN + 留痕) ----------
|
||
|
||
|
||
def test_jwt_tampered_signature_rejected(env):
|
||
tok = auth_service.issue_dev_token(sub="STAFF-30001", roles=["risk_officer"])
|
||
head, payload, sig = tok.split(".")
|
||
r = env["client"].get("/api/risk/alerts", headers=_bearer(f"{head}.{payload}.AAAA{sig[4:]}"))
|
||
assert r.status_code == 401 and r.json()["error_code"] == "AUTH_401_INVALID_TOKEN"
|
||
assert _audit_rows(env["engine"], "unauthenticated")
|
||
|
||
|
||
def test_jwt_expired_rejected(env):
|
||
now = int(time.time())
|
||
payload = {
|
||
"iss": settings.jwt_issuer, "sub": "STAFF-30001", "aud": settings.jwt_audience,
|
||
"exp": now - 10, "iat": now - 100, "jti": "jti-exp", "token_type": "staff",
|
||
"roles": ["risk_officer"], "tenant_id": "TENANT-001",
|
||
}
|
||
tok = jose_jwt.encode(payload, settings.jwt_dev_secret, algorithm="HS256")
|
||
r = env["client"].get("/api/risk/alerts", headers=_bearer(tok))
|
||
assert r.status_code == 401 and r.json()["error_code"] == "AUTH_401_INVALID_TOKEN"
|
||
|
||
|
||
def test_jwt_wrong_issuer_rejected(env):
|
||
now = int(time.time())
|
||
payload = {
|
||
"iss": "https://evil.example", "sub": "STAFF-30001", "aud": settings.jwt_audience,
|
||
"exp": now + 600, "iat": now, "jti": "jti-iss", "token_type": "staff",
|
||
"roles": ["risk_officer"], "tenant_id": "TENANT-001",
|
||
}
|
||
tok = jose_jwt.encode(payload, settings.jwt_dev_secret, algorithm="HS256")
|
||
r = env["client"].get("/api/risk/alerts", headers=_bearer(tok))
|
||
assert r.status_code == 401
|
||
|
||
|
||
def test_jwt_customer_id_mismatch_rejected(env):
|
||
"""手册 §4.2:customer token 的 customer_id 必须等于 sub(防冒名)。"""
|
||
tok = auth_service.issue_dev_token(
|
||
sub="CUST-9527", roles=["customer"], token_type="customer", customer_id="CUST-1001"
|
||
)
|
||
r = env["client"].get("/api/risk/alerts", headers=_bearer(tok))
|
||
assert r.status_code == 401 and r.json()["error_code"] == "AUTH_401_INVALID_TOKEN"
|
||
|
||
|
||
@pytest.mark.parametrize("missing", ["sub", "jti", "token_type", "roles", "tenant_id", "exp", "iat"])
|
||
def test_jwt_missing_required_claim_rejected(env, missing):
|
||
"""手册 §4.2 必填 claims 逐一缺失 → 401(显式校验,评审 P3-14 参数化)。"""
|
||
now = int(time.time())
|
||
payload = {
|
||
"iss": settings.jwt_issuer, "sub": "STAFF-30001", "aud": settings.jwt_audience,
|
||
"exp": now + 600, "iat": now, "jti": "jti-noclaim", "token_type": "staff",
|
||
"roles": ["risk_officer"], "tenant_id": "TENANT-001",
|
||
}
|
||
payload.pop(missing)
|
||
tok = jose_jwt.encode(payload, settings.jwt_dev_secret, algorithm="HS256")
|
||
r = env["client"].get("/api/risk/alerts", headers=_bearer(tok))
|
||
assert r.status_code == 401 and r.json()["error_code"] == "AUTH_401_INVALID_TOKEN"
|
||
|
||
|
||
def test_jwt_none_algorithm_rejected(env):
|
||
"""alg=none 手工构造 unsigned JWT → 401(jose 白名单库级防护 + 显式测试固化)。"""
|
||
import base64
|
||
import json as _json
|
||
|
||
now = int(time.time())
|
||
header = base64.urlsafe_b64encode(_json.dumps({"alg": "none", "typ": "JWT"}).encode()).rstrip(b"=")
|
||
payload = base64.urlsafe_b64encode(_json.dumps({
|
||
"iss": settings.jwt_issuer, "sub": "STAFF-30001", "aud": settings.jwt_audience,
|
||
"exp": now + 600, "iat": now, "jti": "jti-none", "token_type": "staff",
|
||
"roles": ["risk_officer"], "tenant_id": "TENANT-001",
|
||
}).encode()).rstrip(b"=")
|
||
tok = f"{header.decode()}.{payload.decode()}." # 空签名
|
||
r = env["client"].get("/api/risk/alerts", headers=_bearer(tok))
|
||
assert r.status_code == 401
|
||
|
||
|
||
def test_auth_audit_db_failure_keeps_401(env, monkeypatch):
|
||
"""P2-5 显式化:留痕写库失败降级(拒绝语义不漂移为 500)。"""
|
||
def _boom():
|
||
raise RuntimeError("audit db down")
|
||
|
||
monkeypatch.setattr(deps_mod, "RiskRepository", _boom)
|
||
r = env["client"].get("/api/risk/alerts")
|
||
assert r.status_code == 401
|
||
assert r.json()["error_code"] == "AUTH_401_MISSING_DEBUG_HEADERS"
|
||
|
||
|
||
# ---------- 吊销(jti 黑名单) ----------
|
||
|
||
|
||
def test_jwt_revoked_jti_rejected(env):
|
||
tok = auth_service.issue_dev_token(sub="STAFF-30001", roles=["risk_officer"])
|
||
claims = auth_service.verify_token(tok) # 先取 jti
|
||
redis_gateway._gateway.revoked.add(f"auth:revoked:{claims.jti}")
|
||
r = env["client"].get("/api/risk/alerts", headers=_bearer(tok))
|
||
assert r.status_code == 401 and r.json()["error_code"] == "AUTH_401_REVOKED"
|
||
|
||
|
||
def test_revocation_fail_open_when_redis_down(monkeypatch):
|
||
"""Redis 不可用 fail-open(redis_gateway 总口径;TTL 为权威兜底)。"""
|
||
monkeypatch.setattr(redis_gateway, "_gateway", None)
|
||
monkeypatch.setattr(
|
||
redis_gateway.RedisGateway, "exists", lambda self, key: (_ for _ in ()).throw(ConnectionError())
|
||
)
|
||
assert auth_service.is_revoked("jti-x") is False
|
||
|
||
|
||
# ---------- debug 头兜底(dev)与非 dev 行为 ----------
|
||
|
||
|
||
def test_dev_debug_headers_still_work(env):
|
||
r = env["client"].get(
|
||
"/api/risk/alerts", headers={"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-30001"}
|
||
)
|
||
assert r.status_code == 200 # B6 演示 SOP 通道不回归
|
||
|
||
|
||
def test_non_dev_without_bearer_401(env, monkeypatch):
|
||
monkeypatch.setattr(settings, "app_env", "production")
|
||
r = env["client"].get(
|
||
"/api/risk/alerts", headers={"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-30001"}
|
||
)
|
||
assert r.status_code == 401 and r.json()["error_code"] == "AUTH_401_MISSING_BEARER"
|
||
assert _audit_rows(env["engine"], "unauthenticated")
|
||
|
||
|
||
def test_non_dev_jwt_still_accepted(env, monkeypatch):
|
||
"""JWT 是唯一生产通道:非 dev 下有效 JWT + 交叉头照常放行。"""
|
||
monkeypatch.setattr(settings, "app_env", "production")
|
||
tok = auth_service.issue_dev_token(sub="STAFF-30001", roles=["risk_officer"])
|
||
r = env["client"].get("/api/risk/alerts", headers=_bearer(tok))
|
||
assert r.status_code == 200
|
||
|
||
|
||
def test_non_dev_hs256_secret_refused_inline(env, monkeypatch):
|
||
"""HS256 上生产防护:jwt_ready 必须给出拒绝原因(lifespan 消费)。"""
|
||
monkeypatch.setattr(settings, "app_env", "production")
|
||
monkeypatch.setattr(settings, "jwt_public_key_path", "")
|
||
assert auth_service.jwt_ready() is not None
|
||
|
||
|
||
# ---------- RS256(生产形态) ----------
|
||
|
||
_RSA_CACHE: dict = {}
|
||
|
||
|
||
def _rsa_keypair() -> tuple[bytes, bytes]:
|
||
if not _RSA_CACHE:
|
||
from cryptography.hazmat.primitives import serialization
|
||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||
|
||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||
_RSA_CACHE["priv"] = key.private_bytes(
|
||
serialization.Encoding.PEM,
|
||
serialization.PrivateFormat.PKCS8,
|
||
serialization.NoEncryption(),
|
||
)
|
||
_RSA_CACHE["pub"] = key.public_key().public_bytes(
|
||
serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo
|
||
)
|
||
return _RSA_CACHE["priv"], _RSA_CACHE["pub"]
|
||
|
||
|
||
def test_rs256_public_key_roundtrip(env, monkeypatch, tmp_path: Path):
|
||
priv, pub = _rsa_keypair()
|
||
key_file = tmp_path / "idp_pub.pem"
|
||
key_file.write_bytes(pub)
|
||
monkeypatch.setattr(settings, "jwt_public_key_path", str(key_file))
|
||
assert auth_service.jwt_ready() is None
|
||
|
||
now = int(time.time())
|
||
payload = {
|
||
"iss": settings.jwt_issuer, "sub": "STAFF-30001", "aud": settings.jwt_audience,
|
||
"exp": now + 600, "iat": now, "jti": "jti-rs256", "token_type": "staff",
|
||
"roles": ["risk_officer"], "tenant_id": "TENANT-001",
|
||
"permissions": ["risk:alert:write"],
|
||
}
|
||
tok = jose_jwt.encode(payload, priv, algorithm="RS256", headers={"kid": "2026-09-key-1"})
|
||
r = env["client"].get("/api/risk/alerts", headers=_bearer(tok))
|
||
assert r.status_code == 200
|
||
|
||
# HS256 token 在 RS256 模式下必须被拒(算法白名单,防 alg 混淆)
|
||
hs_tok = auth_service.issue_dev_token(sub="STAFF-30001", roles=["risk_officer"])
|
||
r2 = env["client"].get("/api/risk/alerts", headers=_bearer(hs_tok))
|
||
assert r2.status_code == 401
|
||
|
||
|
||
# ---------- assert_agent_access 纯函数矩阵 ----------
|
||
|
||
|
||
def test_agent_access_matrix():
|
||
from app.api.deps import AGENT_ACCESS_MATRIX, assert_agent_access
|
||
|
||
for agent, rule in AGENT_ACCESS_MATRIX.items():
|
||
for tt in rule["token_types"]:
|
||
for role in rule["roles"]:
|
||
assert_agent_access(
|
||
deps_mod.AuthContext(actor_id="X", roles=[role], token_type=tt), agent
|
||
)
|
||
with pytest.raises(deps_mod.PermissionDenied):
|
||
assert_agent_access(
|
||
deps_mod.AuthContext(actor_id="X", roles=["ops"], token_type="staff"), "analyst"
|
||
)
|
||
with pytest.raises(deps_mod.PermissionDenied):
|
||
assert_agent_access(
|
||
deps_mod.AuthContext(actor_id="X", roles=["risk_officer"], token_type="customer"), "risk"
|
||
)
|