mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
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:
1 parent
60c8a5c993
commit
0514162824
2 files changed
+47
-9
No files matched your search
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user