feat: Phase 1 前端认证接入 FBA JWT——三轨会话 + 图形验证码(Epic #159)

This commit is contained in:
2026-08-06 09:46:21 +08:00
parent 70c680a612
commit 5b673fcdc7
2 changed files with 337 additions and 58 deletions
+75 -4
View File
@@ -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 @@
</head>
<body>
<!--
iAOP 登录页(issue #150/#134 + 2026-08 Ant Design Pro 风格重构)。
认证双轨(shared/session.js):
· 后端 core/auth 可达 → POST /auth/login(HMAC 会话,HttpOnly cookie);
iAOP 登录页(issue #150/#134 + 2026-08 Ant Design Pro 风格重构 + Epic #159 Phase 1)。
认证三轨(shared/session.js):
· FBA 可用(/fba/api/v1 探测通过)→ FBA JWT 登录(默认强制图形验证码);
· 旧后端 core/auth 可达 → POST /auth/login(HMAC 会话,HttpOnly cookie);
· 纯静态部署 → UserStore 本地账号(首次访问自动播种演示账号)。
角色三级(PRD 8.2):readonly 只读 / engineer 可配置 / admin 可发布回滚,
FBA 侧角色按 session.js 的 FBA_ROLE_MAP 命名即可映射到三级角色,
登录后门户按角色过滤可见模块。
-->
<div class="brand">
@@ -250,6 +270,20 @@
</div>
</div>
<!-- FBA 图形验证码:仅当 FBA 认证可用且开启验证码时显示(session.js fbaStatus) -->
<div class="field" id="captchaField" hidden>
<label for="captcha">图形验证码</label>
<div class="captcha-row">
<div class="input-wrap">
<span class="input-icon">🛡</span>
<input id="captcha" name="captcha" type="text"
autocomplete="off" placeholder="请输入验证码" maxlength="8">
</div>
<img class="captcha-img" id="captchaImg" alt="点击刷新验证码"
title="看不清?点击刷新">
</div>
</div>
<button type="submit" class="btn-login" id="loginBtn">登 录</button>
<!-- issue #152:OIDC SSO 双轨入口——配置了 issuer/client_id 时显示 -->
@@ -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;
});
+262 -54
View File
@@ -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
};
})();