From 5b673fcdc7a13ef15c8a7396f7f2cc3867e0eb13 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Thu, 6 Aug 2026 09:46:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20=E5=89=8D=E7=AB=AF=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E6=8E=A5=E5=85=A5=20FBA=20JWT=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=B8=89=E8=BD=A8=E4=BC=9A=E8=AF=9D=20+=20=E5=9B=BE=E5=BD=A2?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=A0=81=EF=BC=88Epic=20#159=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/auth/login.html | 79 ++++++++++- web/shared/session.js | 316 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 337 insertions(+), 58 deletions(-) diff --git a/web/auth/login.html b/web/auth/login.html index 107f0a0..9455f29 100644 --- a/web/auth/login.html +++ b/web/auth/login.html @@ -138,6 +138,24 @@ } .field .pwd-toggle:hover { color: var(--ant-primary); } + /* ---- FBA 图形验证码(Epic #159 Phase 1) -------------------------------- */ + .captcha-row { + display: flex; + gap: 8px; + align-items: stretch; + } + .captcha-row .input-wrap { flex: 1; } + .captcha-img { + height: 40px; + min-width: 110px; + border: 1px solid var(--ant-border); + border-radius: var(--ant-radius); + cursor: pointer; + object-fit: cover; + background: #fafafa; + } + .captcha-img:hover { border-color: var(--ant-primary-hover); } + .btn-login { width: 100%; height: 40px; @@ -210,11 +228,13 @@
@@ -250,6 +270,20 @@
+ + + @@ -299,6 +333,30 @@ // 首次访问播种本地演示账号(幂等;后端模式不影响) UserStore.seedDefaults().catch(function () {}); + /* Epic #159 Phase 1:FBA 可用且开启图形验证码时显示验证码字段 */ + var captchaState = { enabled: false, uuid: null }; + function refreshCaptcha() { + IAOP_SESSION.getCaptcha().then(function (c) { + if (c) { + captchaState.uuid = c.uuid; + $("captchaImg").src = c.imageSrc; + $("captcha").value = ""; + } + }); + } + IAOP_SESSION.fbaStatus().then(function (st) { + if (st.available && st.captchaEnabled) { + captchaState.enabled = true; + $("captchaField").hidden = false; + refreshCaptcha(); + } + if (st.available) { + // FBA 模式下隐藏本地演示账号提示,避免误导 + document.querySelector(".demo-accounts").hidden = true; + } + }); + $("captchaImg").addEventListener("click", refreshCaptcha); + // 已登录直接跳转 IAOP_SESSION.currentUser().then(function (u) { if (u) location.href = nextUrl(); @@ -310,6 +368,7 @@ var p = $("password").value; $("username").removeAttribute("aria-invalid"); $("password").removeAttribute("aria-invalid"); + $("captcha").removeAttribute("aria-invalid"); if (!u) { $("username").setAttribute("aria-invalid", "true"); showAlert("请输入用户名", "error"); @@ -320,15 +379,27 @@ showAlert("密码不少于 8 位", "error"); return; } + var captchaOpt = null; + if (captchaState.enabled) { + var c = $("captcha").value.trim(); + if (!c) { + $("captcha").setAttribute("aria-invalid", "true"); + showAlert("请输入图形验证码", "error"); + return; + } + captchaOpt = { uuid: captchaState.uuid, captcha: c }; + } $("loginBtn").disabled = true; clearAlert(); - IAOP_SESSION.login(u, p).then(function (user) { + IAOP_SESSION.login(u, p, captchaOpt).then(function (user) { showAlert("登录成功,欢迎 " + user.username + "(" + (IAOP_SESSION.ROLE_LABELS[user.role] || user.role) + ")", "ok"); setTimeout(function () { location.href = nextUrl(); }, 400); }).catch(function (err) { $("password").setAttribute("aria-invalid", "true"); showAlert(err.message || "登录失败", "error"); + // FBA 验证码一次性有效,失败后必须刷新 + if (captchaState.enabled) refreshCaptcha(); }).finally(function () { $("loginBtn").disabled = false; }); diff --git a/web/shared/session.js b/web/shared/session.js index 01ea68d..c810b74 100644 --- a/web/shared/session.js +++ b/web/shared/session.js @@ -1,27 +1,39 @@ -/* iAOP 全站统一会话层(Ant Design Pro 风格重构,2026-08)。 +/* iAOP 全站统一会话层(FBA 接入版,Epic #159 · Phase 1,2026-08)。 * - * 背景:#150 的 auth.js 只对接 core/auth 后端(/auth/login|logout|me), - * 后端未部署时登录态完全不可用,门户只能裸奔展示全部模块。 - * 本文件提供「后端优先 + 本地降级」双轨会话: - * 1. 后端可达(同源 /auth/*)→ 走后端 HMAC 会话(HttpOnly cookie); - * 2. 后端不可达(纯静态部署,如 nginx :8090)→ 走 UserStore 本地账号 - * (PBKDF2 降级为 salt+SHA-256 的 Demo 级校验,见 user_store.js), - * 会话落 localStorage(iaop.session.v1)。 - * 两种模式下 currentUser() 返回结构一致:{username, role, active}。 + * 认证轨道优先级: + * 1. FBA(fastapi-best-architecture,统一认证/RBAC 基座) + * · 部署形态:同源 nginx 以 /fba/ 前缀反代到 fba_server(见 deploy/fba/) + * · 登录 POST /fba/api/v1/auth/login {username,password,uuid,captcha} + * · 用户 GET /fba/api/v1/sys/users/me(Bearer JWT) + * · 刷新 POST /fba/api/v1/auth/refresh(refresh cookie) + * · 登出 POST /fba/api/v1/auth/logout + * · JWT 落 localStorage(iaop.fba.token.v1),401 时尝试 refresh 一次 + * 2. core/auth(iAOP 旧后端 /auth/*,HMAC 会话 HttpOnly cookie)—— 保留过渡 + * 3. UserStore 本地账号(纯静态部署演示)—— 最终降级 * - * 角色→模块映射(PRD 8.2 三级 RBAC,门户按此过滤可见模块): - * readonly(Viewer) : 驾驶舱 / 对话助手 / 移动端 - * engineer(Editor) : + 模板配置台 - * admin(Publisher) : + 管理控制台 / 用户与角色(全部 6 模块) + * FBA 是否可用通过探测 /fba/api/v1/auth/captcha 判定(结果缓存于会话内)。 * - * 同时导出 window.IAOP_AUTH 兼容层(与 #150 auth.js 同签名), - * studio/admin/users 等既有页面无需修改业务代码即可获得本地降级能力。 + * FBA 角色名 → iAOP 角色映射(FBA 侧角色按此命名即可对上,见 FBA_ROLE_MAP): + * is_superuser → admin + * 角色名含 admin → admin + * 角色名含 engineer/editor → engineer + * 其他/无角色 → readonly + * + * 对外 API 保持与旧版一致(currentUser/login/logout/requireLoginElseRedirect/ + * modulesFor/canAccess),新增: + * fbaStatus() → Promise<{available, captchaEnabled}> 登录页据此显示验证码 + * getCaptcha() → Promise<{uuid, imageSrc}> 刷新图形验证码 + * getAuthHeaders() → {Authorization: 'Bearer ...'} | {} 业务请求带 JWT(Phase 2/4 用) + * login(username, password, captchaOpt) 第三参 {uuid, captcha} 仅在 FBA 轨道需要。 */ "use strict"; var IAOP_SESSION = (function () { - var SESSION_KEY = "iaop.session.v1"; - var BACKEND_TIMEOUT_MS = 1500; // /auth/* 探测超时,超时即降级本地 + var SESSION_KEY = "iaop.session.v1"; // 旧本地会话(降级轨道) + var FBA_TOKEN_KEY = "iaop.fba.token.v1"; // FBA JWT + var FBA_BASE = "/fba/api/v1"; // nginx 反代前缀(deploy/fba/nginx-fba.conf) + var BACKEND_TIMEOUT_MS = 1500; + var FBA_TIMEOUT_MS = 2500; /* ---- 模块注册表(门户卡片 + 访问控制单一数据源) ---------------------- */ var MODULES = [ @@ -61,30 +73,18 @@ var IAOP_SESSION = (function () { admin: "Publisher · 管理" }; var ROLE_TAG_COLORS = { readonly: "default", engineer: "green", admin: "gold" }; - /* ---- 本地会话存取 ------------------------------------------------------ */ - function saveSession(user) { - try { - localStorage.setItem(SESSION_KEY, JSON.stringify({ - username: user.username, role: user.role, - loginAt: new Date().toISOString() - })); - } catch (e) { /* 隐私模式等场景忽略 */ } - } - function readSession() { - try { - var v = localStorage.getItem(SESSION_KEY); - return v ? JSON.parse(v) : null; - } catch (e) { return null; } - } - function clearSession() { - try { localStorage.removeItem(SESSION_KEY); } catch (e) {} - } + /* FBA 角色名(小写)→ iAOP 角色。FBA 后台建角色时按此命名即可自动映射。 */ + var FBA_ROLE_MAP = { + admin: "admin", administrator: "admin", "超级管理员": "admin", "管理员": "admin", + engineer: "engineer", editor: "engineer", "工程师": "engineer", + viewer: "readonly", readonly: "readonly", "只读": "readonly" + }; - /* ---- 后端探测(带超时) ------------------------------------------------ */ - function fetchWithTimeout(url, opts) { + /* ---- 通用工具 ---------------------------------------------------------- */ + function fetchWithTimeout(url, opts, timeoutMs) { return new Promise(function (resolve, reject) { var timer = setTimeout(function () { reject(new Error("timeout")); }, - BACKEND_TIMEOUT_MS); + timeoutMs || BACKEND_TIMEOUT_MS); fetch(url, opts).then(function (r) { clearTimeout(timer); resolve(r); }, function (e) { @@ -93,8 +93,192 @@ var IAOP_SESSION = (function () { }); } - /* ---- 当前登录用户:后端优先,本地会话降级 ------------------------------ */ - function currentUser() { + function readJson(key) { + try { + var v = localStorage.getItem(key); + return v ? JSON.parse(v) : null; + } catch (e) { return null; } + } + function writeJson(key, obj) { + try { localStorage.setItem(key, JSON.stringify(obj)); } catch (e) {} + } + function removeKey(key) { + try { localStorage.removeItem(key); } catch (e) {} + } + + /* ======================================================================= + * 轨道 1:FBA + * ===================================================================== */ + var _fbaProbe = null; // 缓存探测 Promise,避免每个页面重复打探测请求 + + /* 探测 FBA 可用性 + 验证码开关。结果:{available, captchaEnabled} */ + function fbaStatus() { + if (_fbaProbe) return _fbaProbe; + _fbaProbe = fetchWithTimeout(FBA_BASE + "/auth/captcha", null, FBA_TIMEOUT_MS) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (d && d.code === 200 && d.data) { + return { available: true, captchaEnabled: !!d.data.is_enabled }; + } + return { available: false, captchaEnabled: false }; + }) + .catch(function () { return { available: false, captchaEnabled: false }; }); + return _fbaProbe; + } + + /* 拉取图形验证码:{uuid, imageSrc};FBA 不可用或已关闭验证码时 resolve(null) */ + function getCaptcha() { + return fetchWithTimeout(FBA_BASE + "/auth/captcha", null, FBA_TIMEOUT_MS) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (d && d.code === 200 && d.data && d.data.is_enabled) { + return { + uuid: d.data.uuid, + imageSrc: "data:image/jpeg;base64," + d.data.image + }; + } + return null; + }) + .catch(function () { return null; }); + } + + function saveFbaToken(data) { + writeJson(FBA_TOKEN_KEY, { + access_token: data.access_token, + session_uuid: data.session_uuid, + expire: data.access_token_expire_time || null, + loginAt: new Date().toISOString() + }); + } + function readFbaToken() { + var t = readJson(FBA_TOKEN_KEY); + return t && t.access_token ? t : null; + } + function clearFbaToken() { removeKey(FBA_TOKEN_KEY); } + + function getAuthHeaders() { + var t = readFbaToken(); + return t ? { Authorization: "Bearer " + t.access_token } : {}; + } + + /* FBA 用户详情 → iAOP 统一用户结构 {username, role, active} */ + function mapFbaUser(u) { + if (!u) return null; + var role = "readonly"; + if (u.is_superuser) { + role = "admin"; + } else if (Array.isArray(u.roles)) { + for (var i = 0; i < u.roles.length; i++) { + var name = String(u.roles[i] || "").toLowerCase(); + if (FBA_ROLE_MAP[name]) { role = FBA_ROLE_MAP[name]; break; } + // 部分匹配兜底:角色名包含关键字 + if (name.indexOf("admin") >= 0) { role = "admin"; break; } + if (name.indexOf("engineer") >= 0 || name.indexOf("editor") >= 0) { + role = "engineer"; + } + } + } + return { username: u.username, nickname: u.nickname || u.username, + role: role, active: u.status === 1 || u.status === "1" || u.active !== false, + is_superuser: !!u.is_superuser, source: "fba" }; + } + + /* 带 JWT 调 FBA 接口;401 时先 refresh 一次再重试 */ + function fbaFetch(path, opts, _retried) { + opts = opts || {}; + opts.headers = Object.assign({}, getAuthHeaders(), opts.headers || {}); + return fetch(FBA_BASE + path, opts).then(function (r) { + if (r.status === 401 && !_retried) { + return fbaRefresh().then(function (ok) { + if (!ok) throw Object.assign(new Error("fba-unauthorized"), { fba401: true }); + return fbaFetch(path, opts, true); + }); + } + return r; + }); + } + + /* 刷新 access token(refresh token 走 HttpOnly cookie,由登录响应种下) */ + function fbaRefresh() { + return fetch(FBA_BASE + "/auth/refresh", { + method: "POST", credentials: "include" + }).then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (d && d.code === 200 && d.data && d.data.access_token) { + saveFbaToken(d.data); + return true; + } + clearFbaToken(); + return false; + }) + .catch(function () { return false; }); + } + + /* FBA 当前用户:无 token 直接 null;401/网络错 → null(交给降级轨道) */ + function fbaCurrentUser() { + if (!readFbaToken()) return Promise.resolve(null); + return fbaFetch("/sys/users/me") + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (d && d.code === 200 && d.data) return mapFbaUser(d.data); + return null; + }) + .catch(function () { return null; }); + } + + /* FBA 登录。captchaOpt = {uuid, captcha}(FBA 默认强制图形验证码) */ + function fbaLogin(username, password, captchaOpt) { + var body = { username: username, password: password }; + if (captchaOpt && captchaOpt.uuid) { + body.uuid = captchaOpt.uuid; + body.captcha = captchaOpt.captcha || ""; + } + return fetch(FBA_BASE + "/auth/login", { + method: "POST", + credentials: "include", // 种下 refresh cookie + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }).then(function (r) { + return r.json().then(function (d) { + if (r.ok && d && d.code === 200 && d.data && d.data.access_token) { + saveFbaToken(d.data); + var user = mapFbaUser(d.data.user) || { username: username, role: "readonly", active: true, source: "fba" }; + return user; + } + // FBA 在线且明确拒绝(密码错/验证码错/账号锁定等):不降级,直接抛给登录页 + var msg = (d && d.msg) || "用户名或密码错误"; + throw Object.assign(new Error(msg), { authReject: true, fba: true }); + }, function () { + throw new Error("fba-bad-response"); + }); + }); + } + + function fbaLogout() { + var t = readFbaToken(); + clearFbaToken(); + if (!t) return; + try { + fetch(FBA_BASE + "/auth/logout", { + method: "POST", credentials: "include", + headers: { Authorization: "Bearer " + t.access_token } + }).catch(function () {}); + } catch (e) {} + } + + /* ======================================================================= + * 轨道 2/3:旧 core/auth + UserStore 本地账号(原逻辑保留,作为降级) + * ===================================================================== */ + function saveSession(user) { + writeJson(SESSION_KEY, { + username: user.username, role: user.role, + loginAt: new Date().toISOString() + }); + } + function readSession() { return readJson(SESSION_KEY); } + function clearSession() { removeKey(SESSION_KEY); } + + function legacyCurrentUser() { return fetchWithTimeout("/auth/me", { credentials: "include" }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { @@ -102,18 +286,16 @@ var IAOP_SESSION = (function () { throw new Error("no backend session"); }) .catch(function () { - // 后端不可达或无后端会话 → 本地会话降级,并用 UserStore 复核用户仍有效 var s = readSession(); if (!s || !s.username) return null; - if (typeof UserStore === "undefined") return s; // 无存储层时信任会话 + if (typeof UserStore === "undefined") return s; var u = UserStore.find(s.username); if (!u || !u.active) { clearSession(); return null; } return { username: u.username, role: u.role, active: u.active }; }); } - /* ---- 登录:后端优先,本地账号降级 -------------------------------------- */ - function login(username, password) { + function legacyLogin(username, password) { return fetchWithTimeout("/auth/login", { method: "POST", credentials: "include", @@ -124,20 +306,16 @@ var IAOP_SESSION = (function () { saveSession(d.user); return d.user; }); - // 4xx = 后端在线且拒绝(真实鉴权失败);5xx/网关错误 = 后端不可用,降级本地 if (r.status >= 400 && r.status < 500) { return r.text().then(function (t) { var msg = "用户名或密码错误"; try { msg = (JSON.parse(t) || {}).error || msg; } catch (e) {} - var err = new Error(msg); - err.authReject = true; - throw err; + throw Object.assign(new Error(msg), { authReject: true }); }); } throw new Error("backend-http-" + r.status); }).catch(function (e) { - if (e && e.authReject) throw e; // 后端鉴权失败,不降级 - // 超时 / 网络错误 / 5xx / 响应非 JSON 等一切后端不可用情形 → 本地账号校验 + if (e && e.authReject) throw e; if (typeof UserStore === "undefined") { throw new Error("认证服务不可用,且本地账号存储未加载"); } @@ -151,15 +329,43 @@ var IAOP_SESSION = (function () { }); } + /* ======================================================================= + * 对外统一 API + * ===================================================================== */ + + /* 当前用户:FBA(有 token 时)→ 旧后端 → 本地会话 */ + function currentUser() { + return fbaCurrentUser().then(function (u) { + if (u) return u; + return legacyCurrentUser(); + }); + } + + /* 登录:FBA 可用走 FBA(拒绝不降级);FBA 不可达 → 旧双轨 */ + function login(username, password, captchaOpt) { + return fbaStatus().then(function (st) { + if (st.available) { + return fbaLogin(username, password, captchaOpt).catch(function (e) { + if (e && (e.authReject || e.fba401)) throw e; + // FBA 探测可用但登录请求网络层失败 → 允许降级旧轨道 + if (e && e.message && e.message.indexOf("fba-") === 0) throw e; + return legacyLogin(username, password); + }); + } + return legacyLogin(username, password); + }); + } + /* 被 iframe 嵌入时(SPA 外壳)重定向要作用于顶层窗口 */ function topWindow() { try { return window.self !== window.top ? window.top : window; } catch (e) { return window; } } - /* ---- 登出:双轨都清 ----------------------------------------------------- */ + /* 登出:三条轨道都清 */ function logout(loginUrl) { clearSession(); + fbaLogout(); try { fetch("/auth/logout", { method: "POST", credentials: "include" }) .catch(function () {}); @@ -168,11 +374,9 @@ var IAOP_SESSION = (function () { topWindow().location.href = (loginUrl || "../auth/login.html") + "?next=" + next; } - /* ---- 路由守卫(与 #150 IAOP_AUTH 同语义) ------------------------------- */ function requireLoginElseRedirect(loginUrl) { return currentUser().then(function (u) { if (!u) { - // 回跳地址取顶层窗口(SPA 外壳 hash 内含模块/子页路径) var top = topWindow(); var next = encodeURIComponent( top.location.pathname + top.location.search + top.location.hash); @@ -201,7 +405,11 @@ var IAOP_SESSION = (function () { logout: logout, requireLoginElseRedirect: requireLoginElseRedirect, modulesFor: modulesFor, - canAccess: canAccess + canAccess: canAccess, + /* FBA 新增 */ + fbaStatus: fbaStatus, + getCaptcha: getCaptcha, + getAuthHeaders: getAuthHeaders }; })();