mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(auth): add SessionStore with rolling renewal and at-rest token hashing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
68a2fb2943
commit
5914f41ea1
2 files changed
+190
No files matched your search
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { createDatabase, type BotDatabase } from "./database.js";
|
||||||
|
import { createUserStore, type UserStore } from "./users.js";
|
||||||
|
import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS } from "./sessions.js";
|
||||||
|
|
||||||
|
function sha256(token: string) {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SessionStore", () => {
|
||||||
|
let botDb: BotDatabase;
|
||||||
|
let users: UserStore;
|
||||||
|
let sessions: SessionStore;
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
botDb = createDatabase(":memory:");
|
||||||
|
users = createUserStore(botDb.db);
|
||||||
|
sessions = createSessionStore(botDb.db);
|
||||||
|
const u = await users.createUser("alice", "pw");
|
||||||
|
userId = u.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
botDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createSession returns a raw token whose sha256 matches the DB row id", () => {
|
||||||
|
const { token } = sessions.createSession(userId);
|
||||||
|
const row = botDb.db.prepare("SELECT id FROM sessions").get() as { id: string };
|
||||||
|
expect(row.id).toBe(sha256(token));
|
||||||
|
expect(row.id).not.toBe(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validateAndTouch returns the user for a fresh token", () => {
|
||||||
|
const { token } = sessions.createSession(userId);
|
||||||
|
const result = sessions.validateAndTouch(token);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.userId).toBe(userId);
|
||||||
|
expect(result!.username).toBe("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validateAndTouch returns null and deletes the row for an expired session", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
const { token } = sessions.createSession(userId);
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000);
|
||||||
|
expect(sessions.validateAndTouch(token)).toBeNull();
|
||||||
|
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
||||||
|
expect(remaining).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validateAndTouch does not write the DB if called again within the touch interval", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
const { token } = sessions.createSession(userId);
|
||||||
|
const before = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number };
|
||||||
|
vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS - 1000);
|
||||||
|
sessions.validateAndTouch(token);
|
||||||
|
const after = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number };
|
||||||
|
expect(after.lastSeenAt).toBe(before.lastSeenAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validateAndTouch writes lastSeenAt and extends expiresAt past the touch interval", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
const { token, expiresAt: initialExpiry } = sessions.createSession(userId);
|
||||||
|
vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS + 1000);
|
||||||
|
sessions.validateAndTouch(token);
|
||||||
|
const row = botDb.db.prepare("SELECT lastSeenAt, expiresAt FROM sessions").get() as { lastSeenAt: number; expiresAt: number };
|
||||||
|
expect(row.lastSeenAt).toBe(Date.now());
|
||||||
|
expect(row.expiresAt).toBeGreaterThan(initialExpiry);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteSession removes the row", () => {
|
||||||
|
const { token } = sessions.createSession(userId);
|
||||||
|
sessions.deleteSession(token);
|
||||||
|
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
||||||
|
expect(remaining).toBe(0);
|
||||||
|
expect(sessions.validateAndTouch(token)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteAllForUser keeps the exceptToken session", () => {
|
||||||
|
const a = sessions.createSession(userId);
|
||||||
|
const b = sessions.createSession(userId);
|
||||||
|
sessions.deleteAllForUser(userId, a.token);
|
||||||
|
expect(sessions.validateAndTouch(a.token)).not.toBeNull();
|
||||||
|
expect(sessions.validateAndTouch(b.token)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleanupExpired removes only expired rows", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
sessions.createSession(userId); // expires later
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000);
|
||||||
|
sessions.createSession(userId); // fresh
|
||||||
|
sessions.cleanupExpired();
|
||||||
|
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
||||||
|
expect(remaining).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import type Database from "better-sqlite3";
|
||||||
|
|
||||||
|
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
|
export interface SessionValidation {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionStore {
|
||||||
|
createSession(userId: string): { token: string; expiresAt: number };
|
||||||
|
validateAndTouch(rawToken: string): SessionValidation | null;
|
||||||
|
deleteSession(rawToken: string): void;
|
||||||
|
deleteAllForUser(userId: string, exceptToken?: string): void;
|
||||||
|
cleanupExpired(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashToken(token: string): string {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionStore(db: Database.Database): SessionStore {
|
||||||
|
const insertStmt = db.prepare(
|
||||||
|
"INSERT INTO sessions (id, userId, createdAt, expiresAt, lastSeenAt) VALUES (?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
const selectStmt = db.prepare(`
|
||||||
|
SELECT s.id, s.userId, s.expiresAt, s.lastSeenAt, u.username
|
||||||
|
FROM sessions s INNER JOIN users u ON u.id = s.userId
|
||||||
|
WHERE s.id = ?
|
||||||
|
`);
|
||||||
|
const deleteByIdStmt = db.prepare("DELETE FROM sessions WHERE id = ?");
|
||||||
|
const touchStmt = db.prepare(
|
||||||
|
"UPDATE sessions SET lastSeenAt = ?, expiresAt = ? WHERE id = ?"
|
||||||
|
);
|
||||||
|
const deleteAllForUserStmt = db.prepare("DELETE FROM sessions WHERE userId = ?");
|
||||||
|
const deleteAllForUserExceptStmt = db.prepare(
|
||||||
|
"DELETE FROM sessions WHERE userId = ? AND id != ?"
|
||||||
|
);
|
||||||
|
const cleanupStmt = db.prepare("DELETE FROM sessions WHERE expiresAt < ?");
|
||||||
|
|
||||||
|
return {
|
||||||
|
createSession(userId) {
|
||||||
|
const token = randomBytes(32).toString("base64url");
|
||||||
|
const id = hashToken(token);
|
||||||
|
const now = Date.now();
|
||||||
|
const expiresAt = now + SESSION_TTL_MS;
|
||||||
|
insertStmt.run(id, userId, now, expiresAt, now);
|
||||||
|
return { token, expiresAt };
|
||||||
|
},
|
||||||
|
|
||||||
|
validateAndTouch(rawToken) {
|
||||||
|
if (!rawToken) return null;
|
||||||
|
const id = hashToken(rawToken);
|
||||||
|
const row = selectStmt.get(id) as
|
||||||
|
| { id: string; userId: string; expiresAt: number; lastSeenAt: number; username: string }
|
||||||
|
| undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
const now = Date.now();
|
||||||
|
if (row.expiresAt < now) {
|
||||||
|
deleteByIdStmt.run(id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) {
|
||||||
|
touchStmt.run(now, now + SESSION_TTL_MS, id);
|
||||||
|
}
|
||||||
|
return { userId: row.userId, username: row.username };
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteSession(rawToken) {
|
||||||
|
deleteByIdStmt.run(hashToken(rawToken));
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAllForUser(userId, exceptToken) {
|
||||||
|
if (exceptToken) {
|
||||||
|
deleteAllForUserExceptStmt.run(userId, hashToken(exceptToken));
|
||||||
|
} else {
|
||||||
|
deleteAllForUserStmt.run(userId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cleanupExpired() {
|
||||||
|
cleanupStmt.run(Date.now());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user