diff --git a/core/auth/fba_jwt.py b/core/auth/fba_jwt.py new file mode 100644 index 0000000..6a52e33 --- /dev/null +++ b/core/auth/fba_jwt.py @@ -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:(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 ',返回 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 ") + 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}" diff --git a/core/auth/tests/test_fba_jwt.py b/core/auth/tests/test_fba_jwt.py new file mode 100644 index 0000000..8255dd0 --- /dev/null +++ b/core/auth/tests/test_fba_jwt.py @@ -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() diff --git a/deploy/fba/README.md b/deploy/fba/README.md index 8dea2a2..ae26f3c 100644 --- a/deploy/fba/README.md +++ b/deploy/fba/README.md @@ -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:`)。 + - 后端:`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 为唯一认证源。 ## 注意事项 diff --git a/deploy/fba/artifacts/fba-ui-dist.tar.gz b/deploy/fba/artifacts/fba-ui-dist.tar.gz new file mode 100644 index 0000000..bdfd730 Binary files /dev/null and b/deploy/fba/artifacts/fba-ui-dist.tar.gz differ diff --git a/deploy/fba/nginx-fba.conf b/deploy/fba/nginx-fba.conf index 085b6a6..89a3a69 100644 --- a/deploy/fba/nginx-fba.conf +++ b/deploy/fba/nginx-fba.conf @@ -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"; + } +} diff --git a/web/shared/pro-header.js b/web/shared/pro-header.js index 92f75db..356d14d 100644 --- a/web/shared/pro-header.js +++ b/web/shared/pro-header.js @@ -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); }); diff --git a/web/shared/session.js b/web/shared/session.js index f042ef5..c8f8680 100644 --- a/web/shared/session.js +++ b/web/shared/session.js @@ -446,6 +446,27 @@ var IAOP_SESSION = (function () { .catch(function () { return modulesFor(user.role); }); } + /* ---- Epic #159 Phase 4:按钮级权限码 ------------------------------------ + * FBA GET /auth/codes 返回当前用户权限码集合(服务端按角色聚合; + * 超管返回全部菜单 perms,含播种脚本写入的 iaop:)。 + * 结果按会话缓存(登录后只拉一次),非 FBA 轨道返回空数组。 */ + var _codesCache = null; // Promise | 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 }; })();