Files
iAOP/web/shared/session.js
T
bot_dev1 e9787ecb22 fix: 修复 http 纯静态部署下本地账号登录不可用(issue #131)
- user_store.js:crypto.subtle 仅安全上下文可用,http://:8090 下为 undefined
  导致播种/校验静默失败;新增纯 JS SHA-256 降级实现(已对照 Node crypto
  验证一致,含非 ASCII),WebCrypto 可用时仍优先
- session.js:/auth/login 返回 5xx/非 JSON 时判定为后端不可用并降级本地
  账号,仅 4xx 视为真实鉴权失败(原逻辑把 nginx 500 HTML 误判为密码错误)
2026-08-05 16:52:45 +08:00

206 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* iAOP 全站统一会话层(Ant Design Pro 风格重构,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}。
*
* 角色→模块映射(PRD 8.2 三级 RBAC,门户按此过滤可见模块):
* readonly(Viewer) : 驾驶舱 / 对话助手 / 移动端
* engineer(Editor) : + 模板配置台
* admin(Publisher) : + 管理控制台 / 用户与角色(全部 6 模块)
*
* 同时导出 window.IAOP_AUTH 兼容层(与 #150 auth.js 同签名),
* studio/admin/users 等既有页面无需修改业务代码即可获得本地降级能力。
*/
"use strict";
var IAOP_SESSION = (function () {
var SESSION_KEY = "iaop.session.v1";
var BACKEND_TIMEOUT_MS = 1500; // /auth/* 探测超时,超时即降级本地
/* ---- 模块注册表(门户卡片 + 访问控制单一数据源) ---------------------- */
var MODULES = [
{ key: "cockpit", title: "配置化驾驶舱", icon: "🚀", url: "cockpit/",
desc: "四状态工艺流程视图、实时趋势、KPI 卡片、告警面板、NL 查询(PRD 5.5)",
roles: ["readonly", "engineer", "admin"], tag: "只读", tagColor: "default" },
{ key: "chat", title: "对话助手", icon: "💬", url: "chat/assistant.html",
desc: "NL 工艺查询 / 报警智能解释 / 交接班报告生成(PRD 5.4)",
roles: ["readonly", "engineer", "admin"], tag: "AI 助手", tagColor: "blue" },
{ key: "studio", title: "模板配置台", icon: "🧩", url: "studio/",
desc: "点位字典导入 / 模型超参 / RAG 知识库 / 布局编排 / 版本发布(PRD 5.7)",
roles: ["engineer", "admin"], tag: "可配置", tagColor: "green",
children: [
{ key: "import", title: "点位导入", hash: "#import" },
{ key: "hyper", title: "模型超参", hash: "#hyper" },
{ key: "layout", title: "驾驶舱编排", hash: "#layout" },
{ key: "version", title: "版本发布", hash: "#version" }
] },
{ key: "admin", title: "管理控制台", icon: "⚙️", url: "admin/",
desc: "模型管理(版本/阶段/回滚)、知识库管理、告警确认、审计查询",
roles: ["admin"], tag: "管理员", tagColor: "gold",
children: [
{ key: "models", title: "模型管理", hash: "#models" },
{ key: "kb", title: "知识库管理", hash: "#kb" },
{ key: "alerts", title: "告警确认", hash: "#alerts" },
{ key: "audit", title: "审计查询", hash: "#audit" }
] },
{ key: "users", title: "用户与角色", icon: "👤", url: "auth/users.html",
desc: "用户管理、角色分配(readonly / engineer / admin)、审计(PRD 8.2)",
roles: ["admin"], tag: "管理员", tagColor: "gold" },
{ key: "mobile", title: "移动端驾驶舱", icon: "📱", url: "mobile/",
desc: "移动端只读驾驶舱与交接班摘要(iOS14+ / Android 10+)",
roles: ["readonly", "engineer", "admin"], tag: "移动端", tagColor: "default" }
];
var ROLE_LABELS = { readonly: "Viewer · 只读", engineer: "Editor · 配置",
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) {}
}
/* ---- 后端探测(带超时) ------------------------------------------------ */
function fetchWithTimeout(url, opts) {
return new Promise(function (resolve, reject) {
var timer = setTimeout(function () { reject(new Error("timeout")); },
BACKEND_TIMEOUT_MS);
fetch(url, opts).then(function (r) {
clearTimeout(timer); resolve(r);
}, function (e) {
clearTimeout(timer); reject(e);
});
});
}
/* ---- 当前登录用户:后端优先,本地会话降级 ------------------------------ */
function currentUser() {
return fetchWithTimeout("/auth/me", { credentials: "include" })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (d && d.user) return d.user;
throw new Error("no backend session");
})
.catch(function () {
// 后端不可达或无后端会话 → 本地会话降级,并用 UserStore 复核用户仍有效
var s = readSession();
if (!s || !s.username) return null;
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) {
return fetchWithTimeout("/auth/login", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username, password: password })
}).then(function (r) {
if (r.ok) return r.json().then(function (d) {
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 new Error("backend-http-" + r.status);
}).catch(function (e) {
if (e && e.authReject) throw e; // 后端鉴权失败,不降级
// 超时 / 网络错误 / 5xx / 响应非 JSON 等一切后端不可用情形 → 本地账号校验
if (typeof UserStore === "undefined") {
throw new Error("认证服务不可用,且本地账号存储未加载");
}
return UserStore.seedDefaults().then(function () {
return UserStore.verify(username, password);
}).then(function (u) {
if (!u) throw new Error("用户名或密码错误");
saveSession(u);
return u;
});
});
}
/* ---- 登出:双轨都清 ----------------------------------------------------- */
function logout(loginUrl) {
clearSession();
try {
fetch("/auth/logout", { method: "POST", credentials: "include" })
.catch(function () {});
} catch (e) {}
var next = encodeURIComponent(location.pathname + location.search);
location.href = (loginUrl || "../auth/login.html") + "?next=" + next;
}
/* ---- 路由守卫(与 #150 IAOP_AUTH 同语义) ------------------------------- */
function requireLoginElseRedirect(loginUrl) {
return currentUser().then(function (u) {
if (!u) {
var next = encodeURIComponent(location.pathname + location.search);
location.href = (loginUrl || "../auth/login.html") + "?next=" + next;
return false;
}
return u;
});
}
/* ---- 角色→模块 ---------------------------------------------------------- */
function modulesFor(role) {
return MODULES.filter(function (m) { return m.roles.indexOf(role) >= 0; });
}
function canAccess(moduleKey, role) {
var m = MODULES.find(function (x) { return x.key === moduleKey; });
return !!m && m.roles.indexOf(role) >= 0;
}
return {
MODULES: MODULES,
ROLE_LABELS: ROLE_LABELS,
ROLE_TAG_COLORS: ROLE_TAG_COLORS,
currentUser: currentUser,
login: login,
logout: logout,
requireLoginElseRedirect: requireLoginElseRedirect,
modulesFor: modulesFor,
canAccess: canAccess
};
})();
/* 兼容层:与 #150 auth.js 导出同签名(studio/admin/users 直接复用)。
* 若页面同时加载了 auth.js,本兼容层在后加载时覆盖之,获得本地降级能力。 */
window.IAOP_AUTH = {
currentUser: IAOP_SESSION.currentUser,
requireLoginElseRedirect: IAOP_SESSION.requireLoginElseRedirect,
AUTH_BASE: ""
};