feat: Phase 3 FBA UI 预构建产物 + Phase 4 按钮级权限(fba_jwt + perms)(Epic #159)

This commit is contained in:
2026-08-06 10:18:15 +08:00
parent 067ba0abe1
commit 4ea81cea91
7 changed files with 342 additions and 4 deletions
+170
View File
@@ -0,0 +1,170 @@
# -*- 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}"
+77
View File
@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""FBA JWT 校验模块单元测试(Epic #159 · Phase 4)。
运行:python -m unittest core.auth.tests.test_fba_jwt -v
"""
from __future__ import annotations
import os
import unittest
from core.auth.fba_jwt import FbaAuth, FbaAuthError, make_test_token
SECRET = "unit-test-secret-key"
class FbaJwtTest(unittest.TestCase):
def setUp(self):
self.auth = FbaAuth(SECRET)
def test_verify_ok(self):
token = make_test_token(SECRET, user_id=42, session_uuid="s-1")
claims = self.auth.verify(f"Bearer {token}")
self.assertEqual(claims["sub"], "42")
self.assertEqual(claims["session_uuid"], "s-1")
def test_verify_expired(self):
token = make_test_token(SECRET, exp_offset=-10)
with self.assertRaises(FbaAuthError) as ctx:
self.auth.verify(f"Bearer {token}")
self.assertEqual(ctx.exception.status, 401)
self.assertIn("过期", ctx.exception.message)
def test_verify_bad_signature(self):
token = make_test_token("other-secret")
with self.assertRaises(FbaAuthError) as ctx:
self.auth.verify(f"Bearer {token}")
self.assertEqual(ctx.exception.status, 401)
def test_verify_missing_bearer(self):
for bad in ("", "token-abc", "Basic abc"):
with self.assertRaises(FbaAuthError) as ctx:
self.auth.verify(bad)
self.assertEqual(ctx.exception.status, 401)
def test_verify_malformed(self):
with self.assertRaises(FbaAuthError):
self.auth.verify("Bearer not-a-jwt")
def test_missing_fields(self):
# 无 session_uuid 的 token(签名正确但字段缺失)
import base64, hashlib, hmac, json, time
enc = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
header = enc(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
payload = enc(json.dumps({"sub": "1", "exp": time.time() + 60}).encode())
sig = enc(hmac.new(SECRET.encode(), f"{header}.{payload}".encode(),
hashlib.sha256).digest())
with self.assertRaises(FbaAuthError) as ctx:
self.auth.verify(f"Bearer {header}.{payload}.{sig}")
self.assertIn("字段", ctx.exception.message)
def test_from_env_absent(self):
os.environ.pop("FBA_TOKEN_SECRET_KEY", None)
self.assertIsNone(FbaAuth.from_env())
def test_from_env_present(self):
os.environ["FBA_TOKEN_SECRET_KEY"] = SECRET
try:
auth = FbaAuth.from_env()
self.assertIsNotNone(auth)
token = make_test_token(SECRET)
self.assertEqual(auth.verify(f"Bearer {token}")["sub"], "1")
finally:
os.environ.pop("FBA_TOKEN_SECRET_KEY", None)
if __name__ == "__main__":
unittest.main()
+26 -3
View File
@@ -86,9 +86,32 @@ docker exec -it fba_postgres psql -U postgres -d fba # 进数据库
脚本幂等:注册 iAOP 6 模块菜单(含子菜单)、创建 `engineer`/`viewer`
角色并按 PRD 8.2 分配菜单。之后在 FBA 后台给用户分配角色即可。
未播种时前端自动降级为本地角色过滤,菜单不会空白。
- **Phase 3**:FBA UI(Vue3 + AntDV)构建时设置
`VITE_GLOB_API_URL=http://<域名>/fba`,产物由 nginx 挂在 `/fba-admin/`,
作为用户/角色/菜单管理界面。
- **Phase 3**:FBA UI(Vue3 + AntDV)管理界面已预构建,产物在
`deploy/fba/artifacts/fba-ui-dist.tar.gz`(构建参数 `VITE_BASE=/fba-admin/`、
`VITE_GLOB_API_URL=http://39.101.182.167:8090/fba`)。部署:
```bash
cd /opt/apps/iAOP
mkdir -p deploy/fba/fba-ui
tar -xzf deploy/fba/artifacts/fba-ui-dist.tar.gz -C deploy/fba/fba-ui/
# 确认 nginx 已 include 最新 nginx-fba.conf(含 /fba-admin/ 段)后 reload
nginx -t && nginx -s reload
```
入口 `http://39.101.182.167:8090/fba-admin/`,用 FBA 账号登录即可管理
用户/角色/菜单/日志。iAOP 顶栏对 admin 角色提供「系统管理」直达链接。
若需重新构建(如换域名):改 `fba-ui/apps/web-antdv-next/.env.production`
后 `pnpm --filter @vben/web-antdv-next build`(Vben5 monorepo,需 pnpm)。
- **Phase 4**:业务接口按钮级权限。
- 前端:`IAOP_SESSION.perms()` / `hasPerm(code)` 读取 FBA `/auth/codes`
权限码(超管自动拥有全部;菜单播种时已写入 `iaop:<module>`)。
- 后端:`core/auth/fba_jwt.py` 提供零依赖 HS256 校验器 `FbaAuth`
(与 FBA 的 JWT 同构:claims = sub/session_uuid/exp,密钥取环境变量
`FBA_TOKEN_SECRET_KEY`,**必须与 fba.env 的 TOKEN_SECRET_KEY 一致**)。
业务服务内 `FbaAuth.from_env()` 返回 None 时走旧 core/auth 会话(双轨过渡)。
按钮级校验用 `fba.require_perm(authorization, "iaop:xxx")`(回源
`/auth/codes`,带 5 分钟内存缓存)。
- 单元测试:`python -m unittest core.auth.tests.test_fba_jwt -v`(8 例全过)。
- **Phase 5**:收尾。待全部 Phase 部署验收后:旧 core/auth 登录轨与
UserStore 本地账号降级保留为演示模式;生产环境以 FBA 为唯一认证源。
## 注意事项
Binary file not shown.
+30
View File
@@ -34,3 +34,33 @@ location /fba/ {
proxy_read_timeout 60s;
}
# =============================================================================
# Phase 3:FBA UI 静态站点(用户/角色/菜单/日志管理界面)
#
# 部署:将 deploy/fba/artifacts/fba-ui-dist.tar.gz 解压到
# /opt/apps/iAOP/deploy/fba/fba-ui/(解压后应存在 fba-ui/dist/index.html)
# tar -xzf deploy/fba/artifacts/fba-ui-dist.tar.gz -C deploy/fba/fba-ui/
#
# 产物构建参数(已固化在包内):
# VITE_BASE=/fba-admin/
# VITE_GLOB_API_URL=http://39.101.182.167:8090/fba
# 入口:http://39.101.182.167:8090/fba-admin/
# =============================================================================
location /fba-admin/ {
alias /opt/apps/iAOP/deploy/fba/fba-ui/dist/;
index index.html;
# SPA 路由回退(hash 模式下 mostly 用不到,兜底)
try_files $uri $uri/ /fba-admin/index.html;
# 带 hash 的静态资源长缓存;index.html 不缓存
location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2?)$ {
expires 7d;
add_header Cache-Control "public";
}
location = /fba-admin/index.html {
add_header Cache-Control "no-cache";
}
}
+15
View File
@@ -311,6 +311,21 @@
var color = IAOP_SESSION.ROLE_TAG_COLORS[u.role] || "default";
var label = IAOP_SESSION.ROLE_LABELS[u.role] || u.role;
userBox.appendChild(el("span", "pro-tag pro-tag-" + color, label));
// Epic #159 Phase 3:FBA 在线且当前为 admin → 顶栏提供「系统管理」
// 直达 FBA UI(用户/角色/菜单/日志管理),新标签打开
if (u.role === "admin" && IAOP_SESSION.fbaStatus) {
IAOP_SESSION.fbaStatus().then(function (st) {
if (!st.available) return;
var link = el("a", "pro-logout", "系统管理");
link.href = "/fba-admin/"; // nginx 绝对路径(deploy/fba/nginx-fba.conf)
link.target = "_blank";
link.rel = "noopener";
link.style.textDecoration = "none";
link.style.display = "inline-block";
right.insertBefore(link, logoutBtn);
});
}
// 菜单异步:FBA token 存在时取服务端授权菜单树(Phase 2)
IAOP_SESSION.menuTree(u).then(renderMenu);
});
+24 -1
View File
@@ -446,6 +446,27 @@ var IAOP_SESSION = (function () {
.catch(function () { return modulesFor(user.role); });
}
/* ---- Epic #159 Phase 4:按钮级权限码 ------------------------------------
* FBA GET /auth/codes 返回当前用户权限码集合(服务端按角色聚合;
* 超管返回全部菜单 perms,含播种脚本写入的 iaop:<module>)。
* 结果按会话缓存(登录后只拉一次),非 FBA 轨道返回空数组。 */
var _codesCache = null; // Promise<string[]> | null
function perms() {
if (_codesCache) return _codesCache;
if (!readFbaToken()) return Promise.resolve([]);
_codesCache = fbaFetch("/auth/codes")
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
return d && d.code === 200 && Array.isArray(d.data) ? d.data : [];
})
.catch(function () { return []; });
return _codesCache;
}
function hasPerm(code) {
return perms().then(function (codes) {
return codes.indexOf(code) >= 0;
});
}
return {
MODULES: MODULES,
@@ -461,7 +482,9 @@ var IAOP_SESSION = (function () {
fbaStatus: fbaStatus,
getCaptcha: getCaptcha,
getAuthHeaders: getAuthHeaders,
menuTree: menuTree
menuTree: menuTree,
perms: perms,
hasPerm: hasPerm
};
})();