Security: per-username login throttling, constant-time unknown-user check, Sec-Fetch-Site CSRF guard for writes, session rotation on login and password change, absolute session lifetime, sessions ended on reset and deactivation, last-admin protection, input length caps, ASCII usernames, clickjacking headers. Covered by tests/authentication.test.ts. Filter panel: toggle, fields and count share one row to save space. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
105 lines
3.3 KiB
TypeScript
105 lines
3.3 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
|
|
interface Session {
|
|
id: string;
|
|
userId: string;
|
|
createdAt: number;
|
|
expiresAt: number;
|
|
}
|
|
|
|
const MAX_SESSIONS_PER_USER = 20;
|
|
const SWEEP_INTERVAL_MS = 10 * 60 * 1000;
|
|
|
|
/**
|
|
* In-memory session store. The API runs as a single container/process, so a
|
|
* restart clearing every session is an acceptable trade-off for the
|
|
* simplicity of not needing a persisted session table.
|
|
*
|
|
* Sessions slide forward on activity (`idleTtlMs`) but never outlive
|
|
* `absoluteTtlMs` - otherwise a stolen cookie could be kept alive forever.
|
|
*/
|
|
export class SessionStore {
|
|
private readonly sessions = new Map<string, Session>();
|
|
|
|
constructor(
|
|
private readonly idleTtlMs: number,
|
|
private readonly absoluteTtlMs: number,
|
|
private readonly now: () => number = Date.now
|
|
) {}
|
|
|
|
/** Removes expired sessions; unreferenced ones would otherwise pile up. */
|
|
startSweeping(): void {
|
|
setInterval(() => this.sweep(), SWEEP_INTERVAL_MS).unref();
|
|
}
|
|
|
|
sweep(): void {
|
|
const now = this.now();
|
|
for (const [id, session] of this.sessions) {
|
|
if (this.isExpired(session, now)) this.sessions.delete(id);
|
|
}
|
|
}
|
|
|
|
create(userId: string): Session {
|
|
this.enforcePerUserLimit(userId);
|
|
const now = this.now();
|
|
const session: Session = {
|
|
id: randomUUID(),
|
|
userId,
|
|
createdAt: now,
|
|
expiresAt: Math.min(now + this.idleTtlMs, now + this.absoluteTtlMs),
|
|
};
|
|
this.sessions.set(session.id, session);
|
|
return session;
|
|
}
|
|
|
|
/** Returns the session and slides its expiry forward, or undefined if gone/expired. */
|
|
get(sessionId: string | undefined, slideExpiry = true): Session | undefined {
|
|
if (!sessionId) return undefined;
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) return undefined;
|
|
const now = this.now();
|
|
if (this.isExpired(session, now)) {
|
|
this.sessions.delete(sessionId);
|
|
return undefined;
|
|
}
|
|
if (slideExpiry) {
|
|
session.expiresAt = Math.min(now + this.idleTtlMs, session.createdAt + this.absoluteTtlMs);
|
|
}
|
|
return session;
|
|
}
|
|
|
|
/** Remaining lifetime, used as cookie max-age so browser and server agree. */
|
|
remainingMs(session: Session): number {
|
|
return Math.max(0, session.expiresAt - this.now());
|
|
}
|
|
|
|
destroy(sessionId: string | undefined): void {
|
|
if (!sessionId) return;
|
|
this.sessions.delete(sessionId);
|
|
}
|
|
|
|
/** Ends every session of a user, optionally except one (e.g. the caller's own). */
|
|
destroyAllForUser(userId: string, exceptSessionId?: string): void {
|
|
for (const [id, session] of this.sessions) {
|
|
if (session.userId === userId && id !== exceptSessionId) {
|
|
this.sessions.delete(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
private isExpired(session: Session, now: number): boolean {
|
|
return session.expiresAt <= now || session.createdAt + this.absoluteTtlMs <= now;
|
|
}
|
|
|
|
// Oldest sessions go first, so a legitimate user logging in on a new
|
|
// device is never refused - only memory growth is bounded.
|
|
private enforcePerUserLimit(userId: string): void {
|
|
const own = [...this.sessions.values()]
|
|
.filter((session) => session.userId === userId)
|
|
.sort((a, b) => a.createdAt - b.createdAt);
|
|
while (own.length >= MAX_SESSIONS_PER_USER) {
|
|
const oldest = own.shift();
|
|
if (oldest) this.sessions.delete(oldest.id);
|
|
}
|
|
}
|
|
}
|