Files

171 lines
7.5 KiB
Python
Raw Permalink 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.
# -*- coding: utf-8 -*-
"""FBA JWT 校验(Epic #159 · Phase 4)—— iAOP 业务接口接入 FBA 统一认证。
背景:认证/RBAC 基座已迁移到 FBA(fastapi-best-architecture)。
FBA access token 为 HS256 JWT,claims:{sub: 用户ID, session_uuid, exp}。
iAOP 后端为零依赖标准库实现,本模块用 hmac/hashlib 手写 HS256 校验,
不引入 PyJWT。
用法(任意 iAOP HTTP 服务内):
from core.auth.fba_jwt import FbaAuth, FbaAuthError
fba = FbaAuth.from_env() # 读 FBA_TOKEN_SECRET_KEY / FBA_API_BASE
if fba is not None: # 未配置 → 调用方走旧 core/auth 会话
claims = fba.verify(authorization_header) # 无效抛 FbaAuthError(401)
fba.require_perm(authorization_header, "iaop:admin") # 无权抛 403
权限码(按钮级):
前端菜单播种时已写入 perms=iaop:<module>(deploy/fba/seed_iaop_menus.py)。
require_perm() 携用户 token 回源 FBA GET /auth/codes 校验(带 TTL 缓存,
避免每请求打满 FBA)。超管在 FBA 侧自动拥有全部权限码。
双轨过渡:FBA_TOKEN_SECRET_KEY 未配置时 from_env() 返回 None,
业务接口应继续接受旧 core/auth HMAC 会话(见 auth_api.require_auth),
待 Phase 5 收尾时再移除旧轨。
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
import urllib.request
import urllib.error
from http import HTTPStatus
from typing import Optional
class FbaAuthError(Exception):
"""FBA 认证/授权失败。status: 401 未认证,403 无权限,503 FBA 不可达。"""
def __init__(self, status: int, message: str) -> None:
super().__init__(message)
self.status = status
self.message = message
def _b64url_decode(seg: str) -> bytes:
pad = "=" * (-len(seg) % 4)
return base64.urlsafe_b64decode(seg + pad)
def _b64url_encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
class FbaAuth:
"""FBA JWT 校验器(HS256,与 FBA backend/common/security/jwt.py 对齐)。"""
def __init__(self, secret_key: str, api_base: str = "http://127.0.0.1:8001/api/v1",
perm_cache_ttl: int = 300) -> None:
if not secret_key:
raise ValueError("secret_key 不能为空")
self._secret = secret_key.encode()
self.api_base = api_base.rstrip("/")
self.perm_cache_ttl = perm_cache_ttl
# token → (expires_at, codes set);简单内存缓存,进程级
self._perm_cache: dict[str, tuple[float, set]] = {}
@classmethod
def from_env(cls) -> Optional["FbaAuth"]:
"""读环境变量构造;未配置 FBA_TOKEN_SECRET_KEY 时返回 None(降级旧轨)。"""
secret = os.environ.get("FBA_TOKEN_SECRET_KEY", "").strip()
if not secret:
return None
return cls(secret, os.environ.get(
"FBA_API_BASE", "http://127.0.0.1:8001/api/v1"))
# ------------------------------------------------------------------ JWT
def verify(self, authorization: str) -> dict:
"""校验 'Bearer <token>',返回 claims;失败抛 FbaAuthError(401)。"""
token = self._extract_bearer(authorization)
parts = token.split(".")
if len(parts) != 3:
raise FbaAuthError(HTTPStatus.UNAUTHORIZED, "Token 格式无效")
signing_input = (parts[0] + "." + parts[1]).encode()
expect = _b64url_encode(hmac.new(self._secret, signing_input,
hashlib.sha256).digest())
if not hmac.compare_digest(expect, parts[2]):
raise FbaAuthError(HTTPStatus.UNAUTHORIZED, "Token 签名无效")
try:
header = json.loads(_b64url_decode(parts[0]))
payload = json.loads(_b64url_decode(parts[1]))
except (ValueError, UnicodeDecodeError):
raise FbaAuthError(HTTPStatus.UNAUTHORIZED, "Token 解析失败")
if header.get("alg") != "HS256":
raise FbaAuthError(HTTPStatus.UNAUTHORIZED, "Token 算法不受支持")
if not payload.get("sub") or not payload.get("session_uuid"):
raise FbaAuthError(HTTPStatus.UNAUTHORIZED, "Token 缺少必要字段")
exp = payload.get("exp")
if not isinstance(exp, (int, float)) or exp < time.time():
raise FbaAuthError(HTTPStatus.UNAUTHORIZED, "Token 已过期")
return payload
@staticmethod
def _extract_bearer(authorization: str) -> str:
if not authorization:
raise FbaAuthError(HTTPStatus.UNAUTHORIZED, "未携带认证信息")
parts = authorization.split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
raise FbaAuthError(HTTPStatus.UNAUTHORIZED, "认证头格式应为 Bearer <token>")
return parts[1].strip()
# ------------------------------------------------------------- 权限码
def get_codes(self, authorization: str) -> set:
"""携 token 回源 FBA /auth/codes 取权限码集合(带 TTL 缓存)。"""
token = self._extract_bearer(authorization)
now = time.time()
hit = self._perm_cache.get(token)
if hit and hit[0] > now:
return hit[1]
req = urllib.request.Request(
self.api_base + "/auth/codes",
headers={"Authorization": f"Bearer {token}"})
try:
with urllib.request.urlopen(req, timeout=5) as r:
d = json.loads(r.read().decode())
except urllib.error.HTTPError as e:
if e.code in (401, 403):
raise FbaAuthError(HTTPStatus.UNAUTHORIZED, "Token 已失效")
raise FbaAuthError(HTTPStatus.SERVICE_UNAVAILABLE,
f"FBA 权限码查询失败:HTTP {e.code}")
except OSError:
raise FbaAuthError(HTTPStatus.SERVICE_UNAVAILABLE, "FBA 服务不可达")
if d.get("code") != 200 or not isinstance(d.get("data"), list):
raise FbaAuthError(HTTPStatus.SERVICE_UNAVAILABLE, "FBA 权限码响应异常")
codes = set(d["data"])
self._perm_cache[token] = (now + self.perm_cache_ttl, codes)
# 简单清理过期缓存项
for k in [k for k, v in self._perm_cache.items() if v[0] <= now]:
self._perm_cache.pop(k, None)
return codes
def require_perm(self, authorization: str, perm: str) -> dict:
"""校验 token + 权限码;返回 claims。401/403 抛 FbaAuthError。"""
claims = self.verify(authorization)
codes = self.get_codes(authorization)
if perm not in codes:
raise FbaAuthError(HTTPStatus.FORBIDDEN, f"缺少权限:{perm}")
return claims
# ---------------------------------------------------------------------------
# 自测试用:生成与 FBA 同构的 HS256 token(tests 使用)
# ---------------------------------------------------------------------------
def make_test_token(secret: str, user_id: int = 1,
session_uuid: str = "test-session",
exp_offset: int = 3600) -> str:
header = _b64url_encode(json.dumps(
{"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
payload = _b64url_encode(json.dumps({
"sub": str(user_id), "session_uuid": session_uuid,
"exp": time.time() + exp_offset,
}, separators=(",", ":")).encode())
sig = _b64url_encode(hmac.new(secret.encode(),
f"{header}.{payload}".encode(),
hashlib.sha256).digest())
return f"{header}.{payload}.{sig}"