feat: 完成 issue #134 登录认证+用户/角色管理(web/auth,RBAC 三级,OIDC 预留,接入配置台)
This commit is contained in:
@@ -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
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user