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