Files
iAOP/web/auth/auth.js
T

194 lines
8.6 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 认证鉴权共享库(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
};
})();