feat: 完成 issue #134 登录认证+用户/角色管理(web/auth,RBAC 三级,OIDC 预留,接入配置台)

This commit is contained in:
2026-08-05 10:14:25 +08:00
parent faddf24d11
commit 7cf783e7c6
7 changed files with 548 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
/* 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
};
})();