188 lines
7.0 KiB
Python
188 lines
7.0 KiB
Python
"""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)
|