2026-09-07 02:23:33 +08:00
|
|
|
|
"""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
|
2026-09-07 03:03:12 +08:00
|
|
|
|
from uuid import uuid4
|
2026-09-07 02:23:33 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-09-07 03:03:12 +08:00
|
|
|
|
# uuid 片段防同秒同 sub 的 jti 撞车(吊销误伤,评审 P3-6)
|
|
|
|
|
|
"jti": f"jti-{now:x}-{sub}-{uuid4().hex[:8]}",
|
2026-09-07 02:23:33 +08:00
|
|
|
|
"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)
|