b5e5bed7dd
- Uniform auth responses remove the session-enumeration oracle: unknown session and wrong password now return an identical 401 at the API layer and an identical WS handshake/close; desktop-status and the 503 "not connected" signal are only exposed after successful auth. - Session-count cap re-checked after bcrypt.hash so concurrent creates can't overshoot maxSessions. - Relay web client reconnect backoff resets only after a successful auth response (an accept-then-close server no longer defeats the backoff). - idGenerator comment corrected to match the rejection-sampling; drop unused recordFailure return value. - DEPLOY.md: document ALLOWED_ORIGINS for reverse-proxy deployments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
229 lines
6.6 KiB
TypeScript
229 lines
6.6 KiB
TypeScript
// Session Manager - handles session storage and authentication
|
|
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import bcrypt from 'bcrypt';
|
|
import { config } from '../config';
|
|
import { generateSessionId } from '../utils/idGenerator';
|
|
import { logger } from '../utils/logger';
|
|
|
|
export interface Session {
|
|
id: string;
|
|
passwordHash: string;
|
|
createdAt: string;
|
|
lastConnected: string;
|
|
}
|
|
|
|
interface SessionStore {
|
|
sessions: Record<string, Session>;
|
|
}
|
|
|
|
// Flush pending (debounced) writes at most this often.
|
|
const FLUSH_INTERVAL_MS = 60 * 1000;
|
|
// Prune expired sessions on this cadence.
|
|
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
|
|
|
|
export class SessionManager {
|
|
private sessionsFile: string;
|
|
private sessions: Map<string, Session> = new Map();
|
|
private dirty = false;
|
|
private flushTimer: NodeJS.Timeout | null = null;
|
|
private pruneTimer: NodeJS.Timeout | null = null;
|
|
|
|
constructor() {
|
|
this.sessionsFile = path.join(config.dataDir, 'sessions.json');
|
|
this.load();
|
|
this.pruneExpired();
|
|
this.startBackgroundTasks();
|
|
}
|
|
|
|
private startBackgroundTasks(): void {
|
|
this.flushTimer = setInterval(() => {
|
|
if (this.dirty) {
|
|
this.save();
|
|
this.dirty = false;
|
|
}
|
|
}, FLUSH_INTERVAL_MS);
|
|
if (typeof this.flushTimer.unref === 'function') this.flushTimer.unref();
|
|
|
|
this.pruneTimer = setInterval(() => {
|
|
this.pruneExpired();
|
|
}, PRUNE_INTERVAL_MS);
|
|
if (typeof this.pruneTimer.unref === 'function') this.pruneTimer.unref();
|
|
}
|
|
|
|
private load(): void {
|
|
try {
|
|
if (fs.existsSync(this.sessionsFile)) {
|
|
const data = JSON.parse(fs.readFileSync(this.sessionsFile, 'utf-8')) as SessionStore;
|
|
for (const [id, session] of Object.entries(data.sessions || {})) {
|
|
this.sessions.set(id, session);
|
|
}
|
|
logger.info(`Loaded ${this.sessions.size} sessions`);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to load sessions:', error);
|
|
}
|
|
}
|
|
|
|
// Atomic write: write to a temp file in the same directory, then rename.
|
|
private save(): void {
|
|
try {
|
|
const store: SessionStore = {
|
|
sessions: Object.fromEntries(this.sessions)
|
|
};
|
|
const dir = path.dirname(this.sessionsFile);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
|
|
const tmpFile = path.join(
|
|
dir,
|
|
`.sessions.${process.pid}.${Date.now()}.tmp`
|
|
);
|
|
fs.writeFileSync(tmpFile, JSON.stringify(store, null, 2));
|
|
fs.renameSync(tmpFile, this.sessionsFile);
|
|
} catch (error) {
|
|
logger.error('Failed to save sessions:', error);
|
|
}
|
|
}
|
|
|
|
// Prune sessions that have been unused past the configured TTL.
|
|
private pruneExpired(): void {
|
|
const ttlMs = config.sessionTtlDays * 24 * 60 * 60 * 1000;
|
|
if (ttlMs <= 0) return;
|
|
|
|
const now = Date.now();
|
|
let pruned = 0;
|
|
for (const [id, session] of this.sessions) {
|
|
const last = Date.parse(session.lastConnected || session.createdAt);
|
|
if (!Number.isNaN(last) && now - last > ttlMs) {
|
|
this.sessions.delete(id);
|
|
pruned++;
|
|
}
|
|
}
|
|
|
|
if (pruned > 0) {
|
|
logger.info(`Pruned ${pruned} expired session(s)`);
|
|
this.save();
|
|
this.dirty = false;
|
|
}
|
|
}
|
|
|
|
async exists(sessionId: string): Promise<boolean> {
|
|
return this.sessions.has(sessionId);
|
|
}
|
|
|
|
async createSession(password: string): Promise<Session> {
|
|
// Reject empty / short passwords before doing any hashing.
|
|
if (!password || password.length < config.minPasswordLength) {
|
|
throw new Error(
|
|
`Password must be at least ${config.minPasswordLength} characters`
|
|
);
|
|
}
|
|
|
|
// Cap total sessions to prevent unbounded resource exhaustion.
|
|
if (this.sessions.size >= config.maxSessions) {
|
|
// Try to reclaim space by pruning expired sessions first.
|
|
this.pruneExpired();
|
|
if (this.sessions.size >= config.maxSessions) {
|
|
throw new Error('Session limit reached');
|
|
}
|
|
}
|
|
|
|
// Generate unique session ID
|
|
let id: string;
|
|
do {
|
|
id = generateSessionId(config.sessionIdLength);
|
|
} while (this.sessions.has(id));
|
|
|
|
const passwordHash = await bcrypt.hash(password, config.bcryptRounds);
|
|
|
|
// Re-check the cap AFTER the (awaited) hash. bcrypt.hash is the only
|
|
// suspension point between the first cap check and the set() below, so
|
|
// concurrent createSession calls could each have passed the earlier check
|
|
// and then filled the map while we were hashing. Re-checking here — with
|
|
// no await between this check and the synchronous set() — makes the
|
|
// check-and-insert atomic under Node's single-threaded model, so the cap
|
|
// cannot be overshot.
|
|
if (this.sessions.size >= config.maxSessions) {
|
|
this.pruneExpired();
|
|
if (this.sessions.size >= config.maxSessions) {
|
|
throw new Error('Session limit reached');
|
|
}
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
const session: Session = {
|
|
id,
|
|
passwordHash,
|
|
createdAt: now,
|
|
lastConnected: now
|
|
};
|
|
|
|
this.sessions.set(id, session);
|
|
this.save(); // creation is important, persist immediately
|
|
|
|
logger.info(`Created new session: ${id}`);
|
|
return session;
|
|
}
|
|
|
|
async validatePassword(sessionId: string, password: string): Promise<boolean> {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) {
|
|
return false;
|
|
}
|
|
|
|
const valid = await bcrypt.compare(password, session.passwordHash);
|
|
|
|
if (valid) {
|
|
// Update last-connected time, but only mark dirty (debounced flush)
|
|
// if it moved by more than a minute to avoid writing on every auth.
|
|
const now = Date.now();
|
|
const prev = Date.parse(session.lastConnected);
|
|
if (Number.isNaN(prev) || now - prev > 60 * 1000) {
|
|
session.lastConnected = new Date(now).toISOString();
|
|
this.dirty = true;
|
|
}
|
|
}
|
|
|
|
return valid;
|
|
}
|
|
|
|
getSession(sessionId: string): Session | undefined {
|
|
return this.sessions.get(sessionId);
|
|
}
|
|
|
|
updateLastConnected(sessionId: string): void {
|
|
const session = this.sessions.get(sessionId);
|
|
if (session) {
|
|
session.lastConnected = new Date().toISOString();
|
|
this.dirty = true;
|
|
}
|
|
}
|
|
|
|
deleteSession(sessionId: string): boolean {
|
|
const deleted = this.sessions.delete(sessionId);
|
|
if (deleted) {
|
|
this.save();
|
|
logger.info(`Deleted session: ${sessionId}`);
|
|
}
|
|
return deleted;
|
|
}
|
|
|
|
// Flush any pending writes and stop background timers.
|
|
shutdown(): void {
|
|
if (this.flushTimer) {
|
|
clearInterval(this.flushTimer);
|
|
this.flushTimer = null;
|
|
}
|
|
if (this.pruneTimer) {
|
|
clearInterval(this.pruneTimer);
|
|
this.pruneTimer = null;
|
|
}
|
|
if (this.dirty) {
|
|
this.save();
|
|
this.dirty = false;
|
|
}
|
|
}
|
|
}
|