2026-08-05 10:20:09 +08:00
|
|
|
|
/* 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("");
|
|
|
|
|
|
}
|
|
|
|
|
|
function hashPassword(password, salt) {
|
|
|
|
|
|
var data = new TextEncoder().encode(salt + ":" + password);
|
|
|
|
|
|
return crypto.subtle.digest("SHA-256", data).then(function (buf) {
|
|
|
|
|
|
return Array.from(new Uint8Array(buf))
|
|
|
|
|
|
.map(function (b) { return b.toString(16).padStart(2, "0"); }).join("");
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 14:17:37 +08:00
|
|
|
|
/* 默认演示账号(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" }
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-08-05 10:20:09 +08:00
|
|
|
|
return {
|
|
|
|
|
|
ROLES: ROLES,
|
|
|
|
|
|
ROLE_LABELS: ROLE_LABELS,
|
2026-08-05 14:17:37 +08:00
|
|
|
|
DEFAULT_ACCOUNTS: DEFAULT_ACCOUNTS,
|
2026-08-05 10:20:09 +08:00
|
|
|
|
|
|
|
|
|
|
/* ---------- 用户 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();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-08-05 14:17:37 +08:00
|
|
|
|
/* ---------- 登录校验 + 默认账号播种(纯静态部署降级,见 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; });
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-08-05 10:20:09 +08:00
|
|
|
|
/* ---------- 写操作审计(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;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
})();
|