- 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 误判为密码错误)
215 lines
9.8 KiB
JavaScript
215 lines
9.8 KiB
JavaScript
/* 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;
|
||
}
|
||
};
|
||
})();
|