From a441cba8fc0244b1dc8a0c068b577620c8a5cbd4 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 22:56:49 -0700 Subject: [PATCH] =?UTF-8?q?ZL-008-20260713=20=C2=B7=20Gatekeeper=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E8=B7=A8=E9=87=8D=E8=BD=BD=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E7=BB=AD=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core-channel/gatekeeper/DEPLOY-v3.2.md | 1 + .../core-channel/gatekeeper/engine-v3.js | 2 +- .../gatekeeper/session-manager.js | 33 +++++++++++++++++-- .../gatekeeper/session-manager.test.js | 20 +++++++++++ 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/zero-point/core-channel/gatekeeper/DEPLOY-v3.2.md b/zero-point/core-channel/gatekeeper/DEPLOY-v3.2.md index ba8975e..509a481 100644 --- a/zero-point/core-channel/gatekeeper/DEPLOY-v3.2.md +++ b/zero-point/core-channel/gatekeeper/DEPLOY-v3.2.md @@ -4,6 +4,7 @@ - 一次邮箱验证码开启最长 12 小时、空闲 1 小时自动失效的工作会话。 - 会话绑定人格体、服务器和操作范围。 +- 会话只以哈希形式持久化到服务器私有目录;Gatekeeper 重载后继续有效,明文会话凭证不落盘。 - `POST /auth/session/end` 对应冰朔“今天结束”,立即吊销会话。 - 删除客户端自填邮箱;验证码只能发往服务器白名单登记邮箱。 - 注册铸澜 `ICE-GL-ZL-001`。 diff --git a/zero-point/core-channel/gatekeeper/engine-v3.js b/zero-point/core-channel/gatekeeper/engine-v3.js index f83e12c..896840f 100644 --- a/zero-point/core-channel/gatekeeper/engine-v3.js +++ b/zero-point/core-channel/gatekeeper/engine-v3.js @@ -32,12 +32,12 @@ const CMD_TIMEOUT = 30000, MAX_OUTPUT = 100000; const CODE_TTL = 300; // 验证码 5 分钟过期 const RATE_LIMIT_WINDOW = 60; const MAX_REQUESTS = 3; -const sessions = new SessionManager({ absoluteTtl: 12 * 3600, idleTtl: 3600 }); // ═══════════════════════════════════════ // 初始化数据目录 // ═══════════════════════════════════════ if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 }); +const sessions = new SessionManager({ absoluteTtl: 12 * 3600, idleTtl: 3600, stateFile: path.join(DATA_DIR, 'sessions.json') }); // ═══════════════════════════════════════ // 密钥 — 兼容所有历史数据目录 diff --git a/zero-point/core-channel/gatekeeper/session-manager.js b/zero-point/core-channel/gatekeeper/session-manager.js index 8e216ce..854ab8b 100644 --- a/zero-point/core-channel/gatekeeper/session-manager.js +++ b/zero-point/core-channel/gatekeeper/session-manager.js @@ -1,25 +1,32 @@ "use strict"; const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); class SessionManager { - constructor({ absoluteTtl = 12 * 3600, idleTtl = 3600 } = {}) { + constructor({ absoluteTtl = 12 * 3600, idleTtl = 3600, stateFile = "" } = {}) { this.absoluteTtl = absoluteTtl; this.idleTtl = idleTtl; + this.stateFile = stateFile; this.sessions = new Map(); + this.load(); } create(identity, server, scopes, now = Date.now() / 1000) { const token = crypto.randomBytes(32).toString("hex"); const hash = this.hash(token); this.sessions.set(hash, { pid: identity.pid, name: identity.name, server, scopes: [...new Set(scopes)], created: now, last_used: now, expires: now + this.absoluteTtl }); + this.persist(); return { token, expires_in: this.absoluteTtl, idle_timeout: this.idleTtl }; } verify(token, identity, server, scope, now = Date.now() / 1000) { - const session = this.sessions.get(this.hash(token || "")); + const hash = this.hash(token || ""); + const session = this.sessions.get(hash); if (!session) return { ok: false, reason: "session_not_found" }; - if (now > session.expires || now - session.last_used > this.idleTtl) { this.sessions.delete(this.hash(token)); return { ok: false, reason: "session_expired" }; } + if (now > session.expires || now - session.last_used > this.idleTtl) { this.sessions.delete(hash); this.persist(); return { ok: false, reason: "session_expired" }; } if (session.pid !== identity.pid || session.server !== server) return { ok: false, reason: "session_binding_mismatch" }; if (!session.scopes.includes(scope)) return { ok: false, reason: "session_scope_denied" }; session.last_used = now; + this.persist(); return { ok: true, session }; } end(token, identity) { @@ -27,8 +34,28 @@ class SessionManager { const session = this.sessions.get(hash); if (!session || session.pid !== identity.pid) return false; this.sessions.delete(hash); + this.persist(); return true; } + load(now = Date.now() / 1000) { + if (!this.stateFile || !fs.existsSync(this.stateFile)) return; + try { + const state = JSON.parse(fs.readFileSync(this.stateFile, "utf8")); + for (const [hash, session] of Object.entries(state.sessions || {})) { + if (typeof hash !== "string" || !session || now > session.expires || now - session.last_used > this.idleTtl) continue; + this.sessions.set(hash, session); + } + } catch { + this.sessions.clear(); + } + } + persist() { + if (!this.stateFile) return; + fs.mkdirSync(path.dirname(this.stateFile), { recursive: true, mode: 0o700 }); + const temp = `${this.stateFile}.${process.pid}.tmp`; + fs.writeFileSync(temp, JSON.stringify({ version: 1, sessions: Object.fromEntries(this.sessions) }), { mode: 0o600 }); + fs.renameSync(temp, this.stateFile); + } hash(token) { return crypto.createHash("sha256").update(token).digest("hex"); } } module.exports = { SessionManager }; diff --git a/zero-point/core-channel/gatekeeper/session-manager.test.js b/zero-point/core-channel/gatekeeper/session-manager.test.js index fd9531f..d941eaf 100644 --- a/zero-point/core-channel/gatekeeper/session-manager.test.js +++ b/zero-point/core-channel/gatekeeper/session-manager.test.js @@ -1,6 +1,9 @@ "use strict"; const test = require("node:test"); const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); const { SessionManager } = require("./session-manager"); const zhulan = { pid: "ICE-GL-ZL-001", name: "铸澜" }; @@ -23,3 +26,20 @@ test("end signal revokes immediately", () => { assert.equal(m.end(token, zhulan), true); assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", 11).ok, false); }); + +test("session survives process reload without persisting plaintext token", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gatekeeper-session-")); + const stateFile = path.join(dir, "sessions.json"); + try { + const now = Date.now() / 1000; + const first = new SessionManager({ absoluteTtl: 100, idleTtl: 20, stateFile }); + const created = first.create(zhulan, "BS-SG-001", ["code-repo"], now); + const onDisk = fs.readFileSync(stateFile, "utf8"); + assert.equal(onDisk.includes(created.token), false); + + const reloaded = new SessionManager({ absoluteTtl: 100, idleTtl: 20, stateFile }); + assert.equal(reloaded.verify(created.token, zhulan, "BS-SG-001", "code-repo", now + 1).ok, true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +});