From 7cf783e7c6e3a1a9c569105d59ff40c012a82462 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 10:14:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20issue=20#134=20?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E8=AE=A4=E8=AF=81+=E7=94=A8=E6=88=B7/?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E7=AE=A1=E7=90=86=EF=BC=88web/auth=EF=BC=8CR?= =?UTF-8?q?BAC=20=E4=B8=89=E7=BA=A7=EF=BC=8COIDC=20=E9=A2=84=E7=95=99?= =?UTF-8?q?=EF=BC=8C=E6=8E=A5=E5=85=A5=E9=85=8D=E7=BD=AE=E5=8F=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/auth/README.md | 56 ++++++++++++ web/auth/auth.js | 193 ++++++++++++++++++++++++++++++++++++++++++ web/auth/login.html | 68 +++++++++++++++ web/auth/users.html | 90 ++++++++++++++++++++ web/auth/users.js | 118 ++++++++++++++++++++++++++ web/studio/index.html | 1 + web/studio/studio.js | 22 +++++ 7 files changed, 548 insertions(+) create mode 100644 web/auth/README.md create mode 100644 web/auth/auth.js create mode 100644 web/auth/login.html create mode 100644 web/auth/users.html create mode 100644 web/auth/users.js diff --git a/web/auth/README.md b/web/auth/README.md new file mode 100644 index 0000000..d0c81a3 --- /dev/null +++ b/web/auth/README.md @@ -0,0 +1,56 @@ +# web/auth — iAOP 登录认证 + 用户/角色管理 + +issue #134 / PRD 8.2 认证鉴权。纯静态(无构建链)Demo 级实现: +登录页 + 会话管理 + 用户/角色管理 + 三级 RBAC 门控 + OIDC SSO 预留。 + +## 文件 + +``` +web/auth/ +├── auth.js # 共享库:用户 CRUD(加盐 SHA-256)/ 会话 / 权限矩阵 / 审计 / OIDC 配置点 +├── login.html # 登录页(本地账号;配置 OIDC 后出现 SSO 入口) +├── users.html # 用户/角色管理页(仅 admin)+ OIDC 配置 + 审计日志查看 +├── users.js # 管理页逻辑 +└── README.md +``` + +## 使用 + +```bash +# 从 web/ 根目录起服务(studio 集成依赖 ../auth 相对路径) +cd web && python -m http.server 8083 +# 打开 http://127.0.0.1:8083/auth/login.html +``` + +首次运行内置管理员 **admin / admin123**(登录后请立即在用户管理页改密)。 +未登录访问 `web/studio/`(模板配置台)会自动跳转登录页,登录后按会话角色 +应用三级 RBAC(Viewer 只读 / Editor 配置 / Publisher 发布+回滚)。 + +## 语义对齐 + +| 能力 | 对齐后端 | +|------|----------| +| 角色 readonly / engineer / admin,动作 view / edit / publish / manage | `core/template-console/rbac.py`(Resource/Action/RoleKind) | +| 用户数据(username/role/enabled/salt/hash) | `core/data-bus/config/postgres.template.yaml` users/permissions 表(Demo 落 localStorage,后端 HTTP 就绪后替换 `UserStore` 读写即可) | +| 写操作审计(时间/操作人/动作/资源/原因) | `template_registry._log` 风格 + `prompts.drain_audit` 语义 | +| OIDC 配置点(issuer/client_id/redirect_uri) | PRD 8.2 双轨:未配置走本地账号,配置后登录页出现 SSO 入口(标准授权码流程 authorize URL 已可生成;令牌换会话的后端端点待企业 IAM 就绪后对接) | + +## 各前端页面接入方式 + +```html + + +``` + +`web/studio` 已接入(未登录不可进入,写操作按会话角色门控,发布/回滚落审计); +auth.js 缺失时 studio 降级为手动角色切换演示,向后兼容。 + +## 安全说明(Demo 边界) + +- 密码加盐 SHA-256(Web Crypto)存储,不落明文;会话 token 8 小时过期, + 账号禁用/删除即时失效; +- localStorage 存储仅用于无后端 Demo;生产部署需由后端接管 users 表 + (PostgreSQL)与会话签发,本库 API(login/session/can/audit)保持不变。 diff --git a/web/auth/auth.js b/web/auth/auth.js new file mode 100644 index 0000000..328919a --- /dev/null +++ b/web/auth/auth.js @@ -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 + }; +})(); diff --git a/web/auth/login.html b/web/auth/login.html new file mode 100644 index 0000000..856a434 --- /dev/null +++ b/web/auth/login.html @@ -0,0 +1,68 @@ + + + + + +iAOP 登录 + + + +
+

iAOP 平台登录

+
本地账号 / 企业 IAM(SSO) 双轨 · PRD 8.2
+ + + + + + +
+
首次运行内置管理员 admin / admin123,登录后请在用户管理页修改密码。
+ SSO 未配置时仅本地账号可用;配置 OIDC(issuer/client_id)后此处出现 SSO 入口。
+
+ + + + diff --git a/web/auth/users.html b/web/auth/users.html new file mode 100644 index 0000000..7617dae --- /dev/null +++ b/web/auth/users.html @@ -0,0 +1,90 @@ + + + + + +iAOP 用户/角色管理 + + + +
+

用户/角色管理 RBAC 三级:readonly / engineer / admin · PRD 8.2

+
+ + 返回配置台 + +
+
+
+
+

用户列表

+
+ + + + +
+
+ + + +
用户名角色状态创建时间操作
+
+
+
+

OIDC SSO 配置点(预留,PRD 8.2 双轨)

+
+
+ + + +
+
配置后登录页出现「企业 SSO 登录」入口(标准 OIDC 授权码流程); + 未配置走本地账号。令牌换会话的后端端点待 IAM 就绪后对接(见 README)。
+
+
+

审计日志(写操作落日志,最近 500 条)

+
+
+
+
+ + + + diff --git a/web/auth/users.js b/web/auth/users.js new file mode 100644 index 0000000..777c1bb --- /dev/null +++ b/web/auth/users.js @@ -0,0 +1,118 @@ +/* iAOP 用户/角色管理页逻辑(issue #134)。 + * 仅 admin(Publisher + MANAGE 权限)可访问;其余角色跳回登录/配置台。 + */ +"use strict"; + +(function () { + var session = Auth.requireAuth("login.html"); + if (!session) return; + if (!Auth.can(session.role, "manage")) { + alert("仅 Publisher(admin)可访问用户管理"); + location.href = "../studio/index.html"; + return; + } + document.getElementById("who").textContent = + session.username + " · " + Auth.ROLE_LABELS[session.role]; + + function el(tag, text) { + var n = document.createElement(tag); + if (text !== undefined) n.textContent = text; + return n; + } + + function renderUsers() { + var tbody = document.getElementById("user-tbody"); + tbody.innerHTML = ""; + Auth.listUsers().forEach(function (u) { + var tr = el("tr"); + if (!u.enabled) tr.className = "disabled-user"; + tr.appendChild(el("td", u.username)); + // 角色分配(下拉即改) + var tdRole = el("td"); + var sel = el("select"); + Auth.ROLES.forEach(function (r) { + var opt = el("option", Auth.ROLE_LABELS[r]); + opt.value = r; + if (r === u.role) opt.selected = true; + sel.appendChild(opt); + }); + sel.className = "role-" + u.role; + sel.onchange = function () { + Auth.updateUser(u.username, { role: sel.value }).then(renderUsers); + }; + tdRole.appendChild(sel); + tr.appendChild(tdRole); + tr.appendChild(el("td", u.enabled ? "启用" : "禁用")); + tr.appendChild(el("td", (u.created_at || "").slice(0, 10))); + var tdOps = el("td"); + var toggle = el("button", u.enabled ? "禁用" : "启用"); + toggle.onclick = function () { + Auth.updateUser(u.username, { enabled: !u.enabled }).then(renderUsers); + }; + var reset = el("button", "重置密码"); + reset.onclick = function () { + var pwd = prompt("为 " + u.username + " 设置新密码(≥6 位)"); + if (pwd) Auth.updateUser(u.username, { password: pwd }).then(renderUsers); + }; + var del = el("button", "删除"); + del.className = "danger"; + del.onclick = function () { + if (u.username === session.username) { alert("不能删除当前登录账号"); return; } + if (confirm("确认删除用户 " + u.username + "?")) { + Auth.deleteUser(u.username).then(renderUsers); + } + }; + [toggle, reset, del].forEach(function (b) { + b.style.marginRight = "4px"; tdOps.appendChild(b); + }); + tr.appendChild(tdOps); + tbody.appendChild(tr); + }); + renderAudit(); + } + + function renderAudit() { + var box = document.getElementById("audit-list"); + box.innerHTML = ""; + Auth.auditLog().slice().reverse().forEach(function (e) { + box.appendChild(el("div", + e.time.slice(0, 19).replace("T", " ") + " | " + e.actor + " | " + + e.action + " | " + e.resource + " | " + e.reason)); + }); + } + + document.getElementById("btn-add").onclick = function () { + var err = document.getElementById("form-err"); + err.textContent = ""; + Auth.createUser(document.getElementById("new-username").value.trim(), + document.getElementById("new-password").value, + document.getElementById("new-role").value) + .then(function () { + document.getElementById("new-username").value = ""; + document.getElementById("new-password").value = ""; + renderUsers(); + }) + .catch(function (e) { err.textContent = e.message; }); + }; + + // OIDC 配置点 + var cfg = Auth.oidcConfig() || {}; + document.getElementById("oidc-issuer").value = cfg.issuer || ""; + document.getElementById("oidc-client").value = cfg.client_id || ""; + document.getElementById("oidc-redirect").value = cfg.redirect_uri || ""; + document.getElementById("btn-oidc").onclick = function () { + Auth.configureOidc({ + issuer: document.getElementById("oidc-issuer").value.trim(), + client_id: document.getElementById("oidc-client").value.trim(), + redirect_uri: document.getElementById("oidc-redirect").value.trim() + }); + alert("OIDC 配置已保存;登录页将出现 SSO 入口"); + }; + + document.getElementById("logout-btn").onclick = function () { + Auth.logout(); + location.href = "login.html"; + }; + + renderUsers(); +})(); diff --git a/web/studio/index.html b/web/studio/index.html index faab1f9..ce881fa 100644 --- a/web/studio/index.html +++ b/web/studio/index.html @@ -145,6 +145,7 @@ + diff --git a/web/studio/studio.js b/web/studio/studio.js index 799f0b7..b7d69ec 100644 --- a/web/studio/studio.js +++ b/web/studio/studio.js @@ -458,6 +458,7 @@ function publish() { changelog: "发布模板资产包", snapshot: currentSnapshot() }); store("studio.releases.v1", studioState.releases); + if (typeof Auth !== "undefined") Auth.audit("publish", "release", version); // #134 写操作审计 renderVersions(); } @@ -480,6 +481,7 @@ function rollbackTo(index) { changelog: "回滚自 " + target.version, snapshot: currentSnapshot() }); store("studio.releases.v1", studioState.releases); + if (typeof Auth !== "undefined") Auth.audit("rollback", "release", "回滚自 " + target.version); renderVersions(); renderCanvas(); renderHyperForm(); } @@ -543,6 +545,26 @@ var studioState = { function persistConfig() { store("studio.config.v1", studioState.config); } (function init() { + // 认证门控(issue #134):从 web/ 根目录起服务时加载 ../auth/auth.js, + // 未登录跳登录页;登录后角色以会话为准(角色下拉锁定)。 + // auth.js 缺失(独立起服务于 web/studio)时降级为手动角色切换演示。 + if (typeof Auth !== "undefined") { + var session = Auth.requireAuth("../auth/login.html"); + if (!session) return; // 正在跳转登录页 + currentRole = session.role; + var roleSelect = document.getElementById("role-select"); + roleSelect.value = session.role; + roleSelect.disabled = true; + document.getElementById("role-hint").textContent = + session.username + " · " + RBAC[currentRole].label + + (Auth.can(session.role, "manage") ? " · 用户管理 →" : ""); + if (Auth.can(session.role, "manage")) { + document.getElementById("role-hint").style.cursor = "pointer"; + document.getElementById("role-hint").onclick = function () { + location.href = "../auth/users.html"; + }; + } + } // 页签切换 document.querySelectorAll(".tab").forEach(function (tab) { tab.onclick = function () {