Files
iAOP/web/auth/user_store.js
T

215 lines
9.8 KiB
JavaScript
Raw Normal View History

/* 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;
}
};
})();