194 lines
8.6 KiB
JavaScript
194 lines
8.6 KiB
JavaScript
/* iAOP 认证鉴权共享库(issue #134 / PRD 8.2)。
|
||
*
|
||
* 纯前端 Demo 级实现(无构建链、无后端 HTTP 服务),语义对齐
|
||
* core/template-console/rbac.py(readonly/engineer/admin 三级角色):
|
||
* - 本地账号:密码加盐 SHA-256 哈希存储(Web Crypto),落 localStorage
|
||
* (数据模型对齐 core/data-bus postgres.template.yaml 的 users/permissions 表,
|
||
* 后端 HTTP 服务就绪后仅需替换 UserStore 三处读写);
|
||
* - 会话管理:登录签发会话 token(8h 过期),各页面 Auth.requireAuth() 门控;
|
||
* - 权限矩阵:Auth.can(role, action),action ∈ view/edit/publish/manage;
|
||
* - 审计日志:写操作落 Auth.audit()(复用 template_registry._log 风格:
|
||
* 时间/操作人/动作/资源/原因),users.html 可查看;
|
||
* - 预留企业 IAM/OAuth2(OIDC SSO):Auth.configureOidc({issuer, client_id,
|
||
* redirect_uri}),配置后登录页出现「SSO 登录」按钮并跳转 authorize URL;
|
||
* 未配置走本地账号(PRD 8.2 双轨)。
|
||
*/
|
||
"use strict";
|
||
|
||
var Auth = (function () {
|
||
var USERS_KEY = "iaop.users.v1";
|
||
var SESSION_KEY = "iaop.session.v1";
|
||
var AUDIT_KEY = "iaop.audit.v1";
|
||
var OIDC_KEY = "iaop.oidc.v1";
|
||
var SESSION_TTL_MS = 8 * 3600 * 1000; // 会话 8 小时
|
||
|
||
/* 权限矩阵:对齐 rbac.py(VIEW < EDIT < PUBLISH < MANAGE,角色继承向上) */
|
||
var ROLE_PERMS = {
|
||
readonly: ["view"],
|
||
engineer: ["view", "edit"],
|
||
admin: ["view", "edit", "publish", "manage"]
|
||
};
|
||
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)); }
|
||
|
||
/* 密码加盐 SHA-256(Web Crypto;落库字段:salt + hash,不存明文) */
|
||
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("");
|
||
});
|
||
}
|
||
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("");
|
||
}
|
||
|
||
/* 首次运行播种内置管理员(admin / admin123,登录后请立即改密) */
|
||
function ensureSeed() {
|
||
var users = load(USERS_KEY, null);
|
||
if (users) return Promise.resolve();
|
||
var salt = randomHex(16);
|
||
return hashPassword("admin123", salt).then(function (hash) {
|
||
save(USERS_KEY, [{ username: "admin", role: "admin", enabled: true,
|
||
salt: salt, hash: hash,
|
||
created_at: new Date().toISOString() }]);
|
||
});
|
||
}
|
||
|
||
return {
|
||
ROLES: Object.keys(ROLE_PERMS),
|
||
ROLE_LABELS: ROLE_LABELS,
|
||
|
||
/* ---------- 用户 CRUD(users 表语义:username/role/enabled/salt/hash) ---------- */
|
||
listUsers: function () { return load(USERS_KEY, []); },
|
||
findUser: function (username) {
|
||
return this.listUsers().find(function (u) { return u.username === username; }) || null;
|
||
},
|
||
createUser: 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.findUser(username)) return Promise.reject(new Error("用户名已存在"));
|
||
if (ROLE_PERMS[role] === undefined) 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.listUsers();
|
||
users.push({ username: username, role: role, enabled: true,
|
||
salt: salt, hash: hash, created_at: new Date().toISOString() });
|
||
save(USERS_KEY, users);
|
||
self.audit("create", "user", username + " 角色=" + role);
|
||
});
|
||
},
|
||
updateUser: function (username, patch) {
|
||
var users = this.listUsers();
|
||
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 (ROLE_PERMS[patch.role] === undefined) return Promise.reject(new Error("非法角色"));
|
||
u.role = patch.role;
|
||
}
|
||
if (patch.enabled !== undefined) u.enabled = !!patch.enabled;
|
||
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, enabled: patch.enabled, password: !!patch.password }));
|
||
});
|
||
},
|
||
deleteUser: function (username) {
|
||
var users = this.listUsers().filter(function (u) { return u.username !== username; });
|
||
save(USERS_KEY, users);
|
||
this.audit("delete", "user", username);
|
||
return Promise.resolve();
|
||
},
|
||
|
||
/* ---------- 会话 ---------- */
|
||
login: function (username, password) {
|
||
var u = this.findUser(username);
|
||
if (!u) return Promise.reject(new Error("用户不存在"));
|
||
if (!u.enabled) return Promise.reject(new Error("账号已禁用,请联系管理员"));
|
||
var self = this;
|
||
return hashPassword(password, u.salt).then(function (hash) {
|
||
if (hash !== u.hash) throw new Error("密码错误");
|
||
var session = { username: u.username, role: u.role, token: randomHex(24),
|
||
expires: Date.now() + SESSION_TTL_MS };
|
||
save(SESSION_KEY, session);
|
||
self.audit("login", "session", u.username);
|
||
return session;
|
||
});
|
||
},
|
||
logout: function () {
|
||
var s = this.session();
|
||
if (s) this.audit("logout", "session", s.username);
|
||
localStorage.removeItem(SESSION_KEY);
|
||
},
|
||
session: function () {
|
||
var s = load(SESSION_KEY, null);
|
||
if (!s || !s.expires || s.expires < Date.now()) return null;
|
||
var u = this.findUser(s.username);
|
||
if (!u || !u.enabled) return null; // 禁用/删除即时失效
|
||
return s;
|
||
},
|
||
/* 页面门控:未登录 → 跳登录页(带 next 回跳);登录后角色变化以会话为准 */
|
||
requireAuth: function (loginPage) {
|
||
var s = this.session();
|
||
if (!s) {
|
||
var next = encodeURIComponent(location.pathname.split("/").pop() || "index.html");
|
||
location.href = (loginPage || "../auth/login.html") + "?next=" + next;
|
||
return null;
|
||
}
|
||
return s;
|
||
},
|
||
can: function (role, action) {
|
||
return (ROLE_PERMS[role] || []).indexOf(action) >= 0;
|
||
},
|
||
|
||
/* ---------- 审计日志(template_registry._log 风格:时间/操作人/动作/资源/原因) ---------- */
|
||
audit: function (action, resource, reason) {
|
||
var log = load(AUDIT_KEY, []);
|
||
var s = this.session();
|
||
log.push({ time: new Date().toISOString(),
|
||
actor: s ? s.username : "anonymous",
|
||
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) { save(OIDC_KEY, cfg); this.audit("update", "oidc_config", cfg.issuer || ""); },
|
||
oidcConfig: function () { return load(OIDC_KEY, null); },
|
||
/* 构造 OIDC authorize URL(标准授权码流程;企业 IAM 就绪后直接可用) */
|
||
oidcAuthorizeUrl: function () {
|
||
var cfg = this.oidcConfig();
|
||
if (!cfg || !cfg.issuer || !cfg.client_id) return null;
|
||
var redirect = cfg.redirect_uri ||
|
||
(location.origin + location.pathname.replace(/[^/]*$/, "login.html"));
|
||
var state = randomHex(8);
|
||
sessionStorage.setItem("iaop.oidc.state", state);
|
||
return cfg.issuer.replace(/\/$/, "") + "/authorize?response_type=code" +
|
||
"&client_id=" + encodeURIComponent(cfg.client_id) +
|
||
"&redirect_uri=" + encodeURIComponent(redirect) +
|
||
"&scope=openid%20profile&state=" + state;
|
||
},
|
||
|
||
_ensureSeed: ensureSeed
|
||
};
|
||
})();
|