"""Auth SDK:JWT 验签与吊销检查(T-01 · JWT 手册 §4 / §8.2 JwtVerifier)。 四 Agent 共用一套身份源(手册 P-01/P-06):本模块是唯一验签入口,业务服务 不自行解析裸 JWT(deps.get_auth_context 工厂内部调用,AuthContext 转换在 api 层完成——分层禁止 service 反向依赖 api)。 算法策略(手册 §4.1/§11): - 配置 jwt_public_key_path → RS256 公钥验签(生产形态,私钥仅在 IdP); - 未配置 → HS256 + jwt_dev_secret(仅 development;非 dev 由 main.lifespan jwt_ready() 检查拒绝启动,防止对称密钥上生产)。 吊销(手册 §11):jti 黑名单 Redis `auth:revoked:{jti}`;Redis 不可用时 fail-open 放行 + warning(与 redis_gateway「失败降级不阻塞」总口径一致, 签发侧 TTL 过期为权威兜底;生产接 IdP 吊销推送时可切 fail-closed)。 审计与 HTTP 状态不在本层:验签失败抛 TokenInvalid(带手册 §10 错误码), 由 deps 统一 401 留痕后转 ApiError(P-05:401 必留痕)。 """ from __future__ import annotations import logging import time from pathlib import Path from uuid import uuid4 from jose import JWTError, jwt from pydantic import BaseModel, Field from app.config.settings import settings from app.utils.exceptions import ApiError logger = logging.getLogger(__name__) REVOKED_KEY_TEMPLATE = "auth:revoked:{jti}" # 手册 §4.2 必填 claims;缺失一律 AUTH_401_INVALID_TOKEN _REQUIRED_CLAIMS = ("exp", "iat", "sub", "jti", "token_type", "roles", "tenant_id") class TokenInvalid(Exception): """验签失败(deps 转 401 + 审计;code 对齐手册 §10)。""" def __init__(self, code: str, message: str) -> None: super().__init__(message) self.code = code self.message = message class Claims(BaseModel): """验签通过的 JWT claims(手册 §4.2 标准 claims 子集)。""" sub: str token_type: str roles: list[str] = Field(default_factory=list) permissions: list[str] = Field(default_factory=list) tenant_id: str | None = None customer_id: str | None = None advisor_id: str | None = None jti: str def _load_public_key() -> bytes | None: """RS256 公钥(启动期由 jwt_ready() 保证存在;此处失败视为配置漂移)。""" path = settings.jwt_public_key_path if not path: return None return Path(path).read_bytes() def jwt_ready() -> str | None: """非 dev 启动前检查(main.lifespan):返回 None=就绪,否则拒绝原因。""" if settings.jwt_public_key_path: if not Path(settings.jwt_public_key_path).is_file(): return f"JWT_PUBLIC_KEY_PATH not found: {settings.jwt_public_key_path}" return None if settings.app_env != "development": return ( "HS256 dev secret is not allowed outside development; " "set JWT_PUBLIC_KEY_PATH (RS256) or APP_ENV=development" ) return None def issue_dev_token( *, sub: str, roles: list[str], token_type: str = "staff", exp_minutes: int = 60, tenant_id: str = "TENANT-001", customer_id: str | None = None, advisor_id: str | None = None, permissions: list[str] | None = None, ) -> str: """HS256 签发(development 联调/测试用;生产由 IdP 签 RS256,手册 §3)。 scripts/dev/issue_dev_token.py 的 CLI 与单测共用本实现;不得在生产签发 (jwt_ready 校验由调用方环境保证,签发函数本身无环境判断——dev secret 仅存在于 development 配置)。 """ now = int(time.time()) payload = { "iss": settings.jwt_issuer, "sub": sub, "aud": settings.jwt_audience, "exp": now + exp_minutes * 60, "iat": now, # uuid 片段防同秒同 sub 的 jti 撞车(吊销误伤,评审 P3-6) "jti": f"jti-{now:x}-{sub}-{uuid4().hex[:8]}", "token_type": token_type, "roles": roles, "tenant_id": tenant_id, } if customer_id is not None: payload["customer_id"] = customer_id if advisor_id is not None: payload["advisor_id"] = advisor_id if permissions is not None: payload["permissions"] = permissions return jwt.encode(payload, settings.jwt_dev_secret, algorithm="HS256", headers={"kid": "dev-hs256"}) def verify_token(token: str) -> Claims: """验签 + 标准 claims 校验 + jti 吊销检查(手册 §8.2 JwtVerifier.verify)。 必填 claims 显式检查(jose 的 options.require 仅覆盖 JWT 标准 claim, 对 token_type/roles/tenant_id 等自定义 claim 不生效——实测 3.5.0,留痕)。 """ try: public_key = _load_public_key() if public_key is not None: payload = jwt.decode( token, public_key, algorithms=["RS256"], audience=settings.jwt_audience, issuer=settings.jwt_issuer, ) else: payload = jwt.decode( token, settings.jwt_dev_secret, algorithms=["HS256"], audience=settings.jwt_audience, issuer=settings.jwt_issuer, ) except JWTError as exc: # 过期/签名错误/格式错误统一 AUTH_401_INVALID_TOKEN(手册 §10 不区分原因,防探测) raise TokenInvalid("AUTH_401_INVALID_TOKEN", f"invalid token: {type(exc).__name__}") from exc missing = [c for c in _REQUIRED_CLAIMS if c not in payload] if missing: raise TokenInvalid("AUTH_401_INVALID_TOKEN", f"missing required claims: {missing}") if payload.get("token_type") == "customer" and payload.get("customer_id") != payload.get("sub"): # 手册 §4.2:customer token 的 customer_id 必须等于 sub raise TokenInvalid("AUTH_401_INVALID_TOKEN", "customer_id does not match sub") claims = Claims.model_validate(payload) if is_revoked(claims.jti): raise TokenInvalid("AUTH_401_REVOKED", "token has been revoked") return claims def is_revoked(jti: str) -> bool: """jti 黑名单检查(Redis `auth:revoked:{jti}`);Redis 失败 fail-open。""" try: from app.service.risk import redis_gateway return redis_gateway.key_exists(REVOKED_KEY_TEMPLATE.format(jti=jti)) except Exception: logger.warning("revocation check failed, fail-open (jti=%s...)", jti[:8], exc_info=True) return False def revoke_jti(jti: str, ttl_seconds: int | None = None) -> None: """吊销(IdP/登出调用):写黑名单;失败抛出(调用方决定语义),不静默。""" from app.service.risk import redis_gateway ttl = ttl_seconds or 8 * 3600 # 员工 token 最长有效期(手册 §11),过期自然失效 redis_gateway.get_gateway().set_ex(REVOKED_KEY_TEMPLATE.format(jti=jti), "1", ttl) def auth_error(status_code: int, code: str, message: str) -> ApiError: """统一 401/403 出口(保持错误码集中在手册 §10 语义)。""" return ApiError(status_code, code, message)