"""服务身份 JWT 校验(ADR-023 §5)。 本模块是**唯一**的鉴权入口。三条纪律,每条都有测试钉住: 1. **强制校验 `aud` 与 `typ`。** 这是独立密钥对方案唯一的意义所在——Java 侧那边 现有的 `JwtVerifier` 只验签名、不校验 `aud`/`iss`,正是因为如此才必须用一对 独立密钥;如果本服务也只验签名,那对独立密钥就白搭了。 2. **用户 token 一律拒绝。** 它们由另一对密钥签发,签名校验本身就会失败;但即使 有人误把用户密钥配到这里,缺 `aud` 与 `typ=service` 也会被拦下——两道。 3. **缺公钥时拒绝所有请求,而不是放行。** 配置缺失必须是「全拒」而非「全过」: 前者立刻暴露,后者是敞开的门。这与 Java 侧 `InternalAuthFilter` 未配置 `internal-token` 时全拒 `/internal/**` 的既有选择一致。 """ from __future__ import annotations import logging import jwt from fastapi import Depends, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from app.config import ServiceAuthSettings from .errors import ServiceError, ServiceErrorCode log = logging.getLogger(__name__) #: auto_error=False:自己产生 401 以便统一走 service 错误信封, #: 而不是 FastAPI 默认的 `{"detail": ...}`——后者与本契约的两类错误形状都不符。 _bearer = HTTPBearer(auto_error=False) def verify_service_token( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), ) -> None: """校验服务票;不通过即抛 `ServiceError(unauthenticated)`。 所有失败都返回同一个笼统原因,**不告诉调用方是签名错、过期、还是 aud 不符**。 对方是我们自己的服务,真正的原因在本服务日志里;把细节回给调用方只会在将来 某天变成攻击者的提示。 """ settings: ServiceAuthSettings = request.app.state.settings.service_auth if not settings.public_key: # 配置缺失 → 全拒。绝不「未配置即放行」。 log.error("PATBOND_SERVICE_JWT_PUBLIC_KEY 未配置,/internal/** 全部拒绝") raise ServiceError(ServiceErrorCode.UNAUTHENTICATED, "服务端未配置服务票公钥") if credentials is None or not credentials.credentials: raise ServiceError(ServiceErrorCode.UNAUTHENTICATED, "缺少服务票") try: claims = jwt.decode( credentials.credentials, settings.public_key, algorithms=["RS256"], # audience 与 issuer 交给库强制校验,而不是解出来自己比—— # 自己比容易漏掉「claim 不存在」这一路。 audience=settings.audience, issuer=settings.issuer, options={"require": ["exp", "aud", "iss", "sub"]}, ) except jwt.InvalidTokenError as exc: # 具体原因只进日志。 log.warning("服务票校验失败:%s", exc) raise ServiceError( ServiceErrorCode.UNAUTHENTICATED, "服务票无效或已过期" ) from exc token_type = claims.get("typ") if token_type != settings.required_type: # 用户 token 走到这里的唯一可能是有人误配了用户密钥。拦住它。 log.warning( "服务票 typ 不符:期望 %s,实际 %r", settings.required_type, token_type ) raise ServiceError(ServiceErrorCode.UNAUTHENTICATED, "服务票类型不符")