feat(sessions): guest role + per-session TTL and cap bypass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-06-25 11:19:21 +08:00
1 parent 60c8a5c993
commit 0514162824
2 files changed
+47 -9

No files matched your search

+36 -1
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { createDatabase, type BotDatabase } from "./database.js"; import { createDatabase, type BotDatabase } from "./database.js";
import { createUserStore, type UserStore } from "./users.js"; import { createUserStore, type UserStore } from "./users.js";
import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS, MAX_SESSIONS_PER_USER } from "./sessions.js"; import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS, MAX_SESSIONS_PER_USER, GUEST_SESSION_TTL_MS } from "./sessions.js";
function sha256(token: string) { function sha256(token: string) {
return createHash("sha256").update(token).digest("hex"); return createHash("sha256").update(token).digest("hex");
@@ -126,3 +126,38 @@ describe("SessionStore", () => {
expect(count).toBe(MAX_SESSIONS_PER_USER); expect(count).toBe(MAX_SESSIONS_PER_USER);
}); });
}); });
describe("guest sessions", () => {
let botDb: BotDatabase;
let sessions: SessionStore;
beforeEach(() => {
botDb = createDatabase(":memory:");
sessions = createSessionStore(botDb.db);
// Create the synthetic guest user row to satisfy the sessions FK.
botDb.db
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES ('__guest__','游客','!',?,?, 'guest')")
.run(Date.now(), Date.now());
});
afterEach(() => {
vi.useRealTimers();
botDb.close();
});
it("skipCap lets more than MAX_SESSIONS_PER_USER coexist for one principal", () => {
const tokens: string[] = [];
for (let i = 0; i < MAX_SESSIONS_PER_USER + 3; i++) {
tokens.push(sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true }).token);
}
// The first token must STILL validate (not evicted).
expect(sessions.validateAndTouch(tokens[0])?.role).toBe("guest");
const n = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE userId='__guest__'").get() as { n: number }).n;
expect(n).toBe(MAX_SESSIONS_PER_USER + 3);
});
it("ttlMs sets a shorter expiry than the default", () => {
const { expiresAt } = sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true });
expect(expiresAt).toBeLessThanOrEqual(Date.now() + GUEST_SESSION_TTL_MS + 50);
});
});
+11 -8
View File
@@ -2,17 +2,18 @@ import { createHash, randomBytes } from "node:crypto";
import type Database from "better-sqlite3"; import type Database from "better-sqlite3";
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
export const GUEST_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 1 day — guests are short-lived
export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export const MAX_SESSIONS_PER_USER = 10; export const MAX_SESSIONS_PER_USER = 10;
export interface SessionValidation { export interface SessionValidation {
userId: string; userId: string;
username: string; username: string;
role: "admin" | "member"; role: "admin" | "member" | "guest";
} }
export interface SessionStore { export interface SessionStore {
createSession(userId: string): { token: string; expiresAt: number }; createSession(userId: string, opts?: { ttlMs?: number; skipCap?: boolean }): { token: string; expiresAt: number };
validateAndTouch(rawToken: string): SessionValidation | null; validateAndTouch(rawToken: string): SessionValidation | null;
deleteSession(rawToken: string): void; deleteSession(rawToken: string): void;
deleteAllForUser(userId: string, exceptToken?: string): void; deleteAllForUser(userId: string, exceptToken?: string): void;
@@ -47,7 +48,7 @@ export function createSessionStore(db: Database.Database): SessionStore {
); );
return { return {
createSession(userId) { createSession(userId, opts) {
// Cap concurrent sessions per user — oldest gets evicted on overflow. // Cap concurrent sessions per user — oldest gets evicted on overflow.
// Wrap the count → delete → insert in a transaction so concurrent logins // Wrap the count → delete → insert in a transaction so concurrent logins
// for the same user can't both pass the cap check and both insert, // for the same user can't both pass the cap check and both insert,
@@ -55,11 +56,13 @@ export function createSessionStore(db: Database.Database): SessionStore {
const token = randomBytes(32).toString("base64url"); const token = randomBytes(32).toString("base64url");
const id = hashToken(token); const id = hashToken(token);
const now = Date.now(); const now = Date.now();
const expiresAt = now + SESSION_TTL_MS; const expiresAt = now + (opts?.ttlMs ?? SESSION_TTL_MS);
const tx = db.transaction(() => { const tx = db.transaction(() => {
const existing = (countForUserStmt.get(userId) as { n: number }).n; if (!opts?.skipCap) {
if (existing >= MAX_SESSIONS_PER_USER) { const existing = (countForUserStmt.get(userId) as { n: number }).n;
deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1); if (existing >= MAX_SESSIONS_PER_USER) {
deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1);
}
} }
insertStmt.run(id, userId, now, expiresAt, now); insertStmt.run(id, userId, now, expiresAt, now);
}); });
@@ -82,7 +85,7 @@ export function createSessionStore(db: Database.Database): SessionStore {
if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) { if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) {
touchStmt.run(now, now + SESSION_TTL_MS, id); touchStmt.run(now, now + SESSION_TTL_MS, id);
} }
return { userId: row.userId, username: row.username, role: row.role as "admin" | "member" }; return { userId: row.userId, username: row.username, role: row.role as "admin" | "member" | "guest" };
}, },
deleteSession(rawToken) { deleteSession(rawToken) {