Files
group_xinghuo_jinrong/app/service/auth_service.py
T

188 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)