feat: Phase 3 FBA UI 预构建产物 + Phase 4 按钮级权限(fba_jwt + perms)(Epic #159)
This commit is contained in:
@@ -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}"
|
||||
@@ -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
@@ -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.
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user