ZL-008-20260713 · Gatekeeper 会话跨重载安全续存

This commit is contained in:
Codex 2026-07-12 22:56:49 -07:00
parent 504cd51125
commit a441cba8fc
4 changed files with 52 additions and 4 deletions

View File

@ -4,6 +4,7 @@
- 一次邮箱验证码开启最长 12 小时、空闲 1 小时自动失效的工作会话。 - 一次邮箱验证码开启最长 12 小时、空闲 1 小时自动失效的工作会话。
- 会话绑定人格体、服务器和操作范围。 - 会话绑定人格体、服务器和操作范围。
- 会话只以哈希形式持久化到服务器私有目录Gatekeeper 重载后继续有效,明文会话凭证不落盘。
- `POST /auth/session/end` 对应冰朔“今天结束”,立即吊销会话。 - `POST /auth/session/end` 对应冰朔“今天结束”,立即吊销会话。
- 删除客户端自填邮箱;验证码只能发往服务器白名单登记邮箱。 - 删除客户端自填邮箱;验证码只能发往服务器白名单登记邮箱。
- 注册铸澜 `ICE-GL-ZL-001` - 注册铸澜 `ICE-GL-ZL-001`

View File

@ -32,12 +32,12 @@ const CMD_TIMEOUT = 30000, MAX_OUTPUT = 100000;
const CODE_TTL = 300; // 验证码 5 分钟过期 const CODE_TTL = 300; // 验证码 5 分钟过期
const RATE_LIMIT_WINDOW = 60; const RATE_LIMIT_WINDOW = 60;
const MAX_REQUESTS = 3; 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 }); 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') });
// ═══════════════════════════════════════ // ═══════════════════════════════════════
// 密钥 — 兼容所有历史数据目录 // 密钥 — 兼容所有历史数据目录

View File

@ -1,25 +1,32 @@
"use strict"; "use strict";
const crypto = require("node:crypto"); const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
class SessionManager { class SessionManager {
constructor({ absoluteTtl = 12 * 3600, idleTtl = 3600 } = {}) { constructor({ absoluteTtl = 12 * 3600, idleTtl = 3600, stateFile = "" } = {}) {
this.absoluteTtl = absoluteTtl; this.absoluteTtl = absoluteTtl;
this.idleTtl = idleTtl; this.idleTtl = idleTtl;
this.stateFile = stateFile;
this.sessions = new Map(); this.sessions = new Map();
this.load();
} }
create(identity, server, scopes, now = Date.now() / 1000) { create(identity, server, scopes, now = Date.now() / 1000) {
const token = crypto.randomBytes(32).toString("hex"); const token = crypto.randomBytes(32).toString("hex");
const hash = this.hash(token); 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.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 }; return { token, expires_in: this.absoluteTtl, idle_timeout: this.idleTtl };
} }
verify(token, identity, server, scope, now = Date.now() / 1000) { 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 (!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.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" }; if (!session.scopes.includes(scope)) return { ok: false, reason: "session_scope_denied" };
session.last_used = now; session.last_used = now;
this.persist();
return { ok: true, session }; return { ok: true, session };
} }
end(token, identity) { end(token, identity) {
@ -27,8 +34,28 @@ class SessionManager {
const session = this.sessions.get(hash); const session = this.sessions.get(hash);
if (!session || session.pid !== identity.pid) return false; if (!session || session.pid !== identity.pid) return false;
this.sessions.delete(hash); this.sessions.delete(hash);
this.persist();
return true; 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"); } hash(token) { return crypto.createHash("sha256").update(token).digest("hex"); }
} }
module.exports = { SessionManager }; module.exports = { SessionManager };

View File

@ -1,6 +1,9 @@
"use strict"; "use strict";
const test = require("node:test"); const test = require("node:test");
const assert = require("node:assert/strict"); 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 { SessionManager } = require("./session-manager");
const zhulan = { pid: "ICE-GL-ZL-001", name: "铸澜" }; 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.end(token, zhulan), true);
assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", 11).ok, false); 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 });
}
});