Files
iAOP/web/auth/user_store.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

215 lines
9.8 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 用户/角色存储 + 审计 + OIDC 配置点(issue #134 / PRD 8.2)。
*
* 与 #150 的 auth.js 分工:
* - auth.js(#150)负责「登录会话」——对接 core/auth 后端 /auth/login|logout|me;
* - 本文件(#134)负责「用户/角色管理 + 写操作审计 + OIDC 配置点」——
* core/auth 目前只有 login/logout/me 端点,用户 CRUD 端点尚未提供,
* 故管理数据先落 localStorage(Demo 级,User 模型对齐 core/auth/users.py:
* username/role/active + 密码 PBKDF2 由后端接管;此处仅存salt+SHA-256 哈希),
* 后端补齐 /auth/users CRUD 后仅需替换本文件三处读写。
*/
"use strict";
var UserStore = (function () {
var USERS_KEY = "iaop.users.v1";
var AUDIT_KEY = "iaop.audit.v1";
var OIDC_KEY = "iaop.oidc.v1";
var ROLES = ["readonly", "engineer", "admin"]; // 对齐 rbac.py RoleKind
var ROLE_LABELS = { readonly: "Viewer(只读)", engineer: "Editor(配置)",
admin: "Publisher(发布+回滚)" };
function load(key, fallback) {
try { var v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; }
catch (e) { return fallback; }
}
function save(key, obj) { localStorage.setItem(key, JSON.stringify(obj)); }
function randomHex(n) {
var buf = new Uint8Array(n);
crypto.getRandomValues(buf);
return Array.from(buf).map(function (b) { return b.toString(16).padStart(2, "0"); }).join("");
}
/* 纯 JS SHA-256(geraintluff 实现,公有领域):crypto.subtle 仅在
* 安全上下文(https / localhost)可用,纯静态 http 部署(如 :8090)
* 必须降级到此实现,否则本地账号无法播种/校验。 */
function sha256Js(ascii) {
function rr(v, a) { return (v >>> a) | (v << (32 - a)); }
var mp = Math.pow, mw = mp(2, 32), result = "", words = [];
var bitLen = ascii.length * 8;
var hash = (sha256Js.h = sha256Js.h || []);
var k = (sha256Js.k = sha256Js.k || []);
var pc = k.length, comp = {};
for (var c = 2; pc < 64; c++) {
if (!comp[c]) {
for (var i = 0; i < 313; i += c) comp[i] = c;
hash[pc] = (mp(c, 0.5) * mw) | 0;
k[pc++] = (mp(c, 1 / 3) * mw) | 0;
}
}
ascii += "\x80";
while ((ascii.length % 64) - 56) ascii += "\x00";
for (var i = 0; i < ascii.length; i++) {
var j = ascii.charCodeAt(i);
if (j >> 8) throw new Error("sha256Js: 仅接受字节串");
words[i >> 2] |= j << (((3 - i) % 4) * 8);
}
words[words.length] = (bitLen / mw) | 0;
words[words.length] = bitLen;
for (var j = 0; j < words.length;) {
var w = words.slice(j, (j += 16));
var old = hash;
hash = hash.slice(0, 8);
for (var i = 0; i < 64; i++) {
var w15 = w[i - 15], w2 = w[i - 2];
var a = hash[0], e = hash[4];
var t1 = hash[7] + (rr(e, 6) ^ rr(e, 11) ^ rr(e, 25)) +
((e & hash[5]) ^ (~e & hash[6])) + k[i] +
(w[i] = i < 16 ? w[i] : (w[i - 16] +
(rr(w15, 7) ^ rr(w15, 18) ^ (w15 >>> 3)) + w[i - 7] +
(rr(w2, 17) ^ rr(w2, 19) ^ (w2 >>> 10))) | 0);
var t2 = (rr(a, 2) ^ rr(a, 13) ^ rr(a, 22)) +
((a & hash[1]) ^ (a & hash[2]) ^ (hash[1] & hash[2]));
hash = [(t1 + t2) | 0].concat(hash);
hash[4] = (hash[4] + t1) | 0;
}
for (var i = 0; i < 8; i++) hash[i] = (hash[i] + old[i]) | 0;
}
for (var i = 0; i < 8; i++) {
for (var j = 3; j + 1; j--) {
var b = (hash[i] >> (j * 8)) & 255;
result += (b < 16 ? "0" : "") + b.toString(16);
}
}
return result;
}
function utf8Bytes(str) { return unescape(encodeURIComponent(str)); }
function hashPassword(password, salt) {
var data = salt + ":" + password;
// 优先 WebCrypto(安全上下文);不可用时降级纯 JS SHA-256(http 部署)
if (window.crypto && crypto.subtle && crypto.subtle.digest) {
var encoded = new TextEncoder().encode(data);
return crypto.subtle.digest("SHA-256", encoded).then(function (buf) {
return Array.from(new Uint8Array(buf))
.map(function (b) { return b.toString(16).padStart(2, "0"); }).join("");
}).catch(function () { return sha256Js(utf8Bytes(data)); });
}
return Promise.resolve(sha256Js(utf8Bytes(data)));
}
/* 默认演示账号(Ant Design Pro 风格重构新增):纯静态部署(无 core/auth
* 后端)时的开箱登录账号,首次进入登录页自动播种。生产环境登录后请立即
* 在用户管理页改密或删除。密码均 ≥ 8 位(对齐 auth.js 前端校验)。 */
var DEFAULT_ACCOUNTS = [
{ username: "admin", password: "Admin@12345", role: "admin" },
{ username: "engineer", password: "Engineer@12", role: "engineer" },
{ username: "viewer", password: "Viewer@1234", role: "readonly" }
];
return {
ROLES: ROLES,
ROLE_LABELS: ROLE_LABELS,
DEFAULT_ACCOUNTS: DEFAULT_ACCOUNTS,
/* ---------- 用户 CRUD(对齐 core/auth/users.py 的 User 字段语义) ---------- */
list: function () { return load(USERS_KEY, []); },
find: function (username) {
return this.list().find(function (u) { return u.username === username; }) || null;
},
create: function (username, password, role) {
var self = this;
if (!/^[a-zA-Z0-9_.-]{2,32}$/.test(username)) {
return Promise.reject(new Error("用户名需 2-32 位字母数字._-"));
}
if (this.find(username)) return Promise.reject(new Error("用户名已存在"));
if (ROLES.indexOf(role) < 0) return Promise.reject(new Error("非法角色"));
if ((password || "").length < 6) return Promise.reject(new Error("密码至少 6 位"));
var salt = randomHex(16);
return hashPassword(password, salt).then(function (hash) {
var users = self.list();
users.push({ username: username, role: role, active: true,
salt: salt, hash: hash, created_at: new Date().toISOString() });
save(USERS_KEY, users);
self.audit("create", "user", username + " 角色=" + role);
});
},
update: function (username, patch, actor) {
var users = this.list();
var u = users.find(function (x) { return x.username === username; });
if (!u) return Promise.reject(new Error("用户不存在"));
var self = this;
var done = Promise.resolve();
if (patch.role) {
if (ROLES.indexOf(patch.role) < 0) return Promise.reject(new Error("非法角色"));
u.role = patch.role;
}
if (patch.active !== undefined) u.active = !!patch.active;
if (patch.password) {
if (patch.password.length < 6) return Promise.reject(new Error("密码至少 6 位"));
u.salt = randomHex(16);
done = hashPassword(patch.password, u.salt).then(function (h) { u.hash = h; });
}
return done.then(function () {
save(USERS_KEY, users);
self.audit("update", "user", username + " " + JSON.stringify(
{ role: patch.role, active: patch.active, password: !!patch.password }), actor);
});
},
remove: function (username, actor) {
save(USERS_KEY, this.list().filter(function (u) { return u.username !== username; }));
this.audit("delete", "user", username, actor);
return Promise.resolve();
},
/* ---------- 登录校验 + 默认账号播种(纯静态部署降级,见 shared/session.js) ---------- */
/* 校验用户名密码,成功返回 {username, role, active},失败返回 null。
* 不区分「用户不存在/密码错误/已禁用」,防用户名枚举。 */
verify: function (username, password) {
var u = this.find(username);
if (!u || !u.active) return Promise.resolve(null);
return hashPassword(password, u.salt).then(function (h) {
return h === u.hash ? { username: u.username, role: u.role,
active: u.active } : null;
});
},
/* 用户库为空时播种默认演示账号(幂等:已有任何用户则跳过)。 */
seedDefaults: function () {
var self = this;
if (this.list().length > 0) return Promise.resolve(false);
return DEFAULT_ACCOUNTS.reduce(function (p, acc) {
return p.then(function () {
return self.create(acc.username, acc.password, acc.role);
});
}, Promise.resolve()).then(function () { return true; });
},
/* ---------- 写操作审计(template_registry._log 风格:时间/操作人/动作/资源/原因) ---------- */
audit: function (action, resource, reason, actor) {
var log = load(AUDIT_KEY, []);
log.push({ time: new Date().toISOString(), actor: actor || "system",
action: action, resource: resource, reason: reason || "" });
if (log.length > 500) log = log.slice(-500);
save(AUDIT_KEY, log);
},
auditLog: function () { return load(AUDIT_KEY, []); },
/* ---------- OIDC SSO 配置点(PRD 8.2 双轨:未配置走本地账号) ---------- */
configureOidc: function (cfg, actor) {
save(OIDC_KEY, cfg);
this.audit("update", "oidc_config", cfg.issuer || "", actor);
},
oidcConfig: function () { return load(OIDC_KEY, null); },
/* 标准 OIDC 授权码流程 authorize URL;企业 IAM 就绪后直接可用 */
oidcAuthorizeUrl: function (redirectPath) {
var cfg = this.oidcConfig();
if (!cfg || !cfg.issuer || !cfg.client_id) return null;
var redirect = cfg.redirect_uri || (location.origin + (redirectPath || "/auth/login.html"));
var state = randomHex(8);
try { sessionStorage.setItem("iaop.oidc.state", state); } catch (e) {}
return cfg.issuer.replace(/\/$/, "") + "/authorize?response_type=code" +
"&client_id=" + encodeURIComponent(cfg.client_id) +
"&redirect_uri=" + encodeURIComponent(redirect) +
"&scope=openid%20profile&state=" + state;
}
};
})();