import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import type { DcRouter } from '../classes.dcrouter.js'; import { AuthenticationEventDoc } from '../db/documents/classes.authentication-event.doc.js'; import type { IAuthenticationEvent, TAuthenticationFailureReason, TAuthenticationSource, TResolvedAuthenticationSource, } from '../../ts_interfaces/data/stats.js'; import { SecurityEventType, SecurityLogLevel, SecurityLogger, } from './classes.securitylogger.js'; const MAX_IN_MEMORY_EVENTS = 10_000; const MAX_PERSIST_BATCH_SIZE = 500; export interface IAuthenticationWindowStats { windowStart: number; successes: number; failures: number; events: IAuthenticationEvent[]; } export class AuthenticationEventManager { private pendingEvents = new Map(); private recentEvents = new Map(); private flushInFlight?: Promise; private pruneInFlight?: Promise; private windowStatsInFlight = new Map>(); constructor(private dcRouterRef: DcRouter) {} private canPersist(): boolean { return this.dcRouterRef.options.dbConfig?.enabled !== false && Boolean(this.dcRouterRef.dcRouterDb?.isReady()); } private rememberEvent(eventArg: IAuthenticationEvent): void { this.pendingEvents.set(eventArg.id, eventArg); this.recentEvents.set(eventArg.id, eventArg); while (this.pendingEvents.size > MAX_IN_MEMORY_EVENTS) { const oldestId = this.pendingEvents.keys().next().value as string | undefined; if (!oldestId) break; this.pendingEvents.delete(oldestId); logger.log('error', 'Authentication event persistence buffer reached its limit; oldest event dropped'); } while (this.recentEvents.size > MAX_IN_MEMORY_EVENTS) { const oldestId = this.recentEvents.keys().next().value as string | undefined; if (!oldestId) break; this.recentEvents.delete(oldestId); } } private async flushBufferedUnlocked(): Promise { if (!this.canPersist() || this.pendingEvents.size === 0) return; const events = [...this.pendingEvents.values()]; for (let offset = 0; offset < events.length; offset += MAX_PERSIST_BATCH_SIZE) { const batch = events.slice(offset, offset + MAX_PERSIST_BATCH_SIZE); await AuthenticationEventDoc.upsertMany(batch); for (const event of batch) { if (this.pendingEvents.get(event.id) === event) { this.pendingEvents.delete(event.id); } } } } public async recordAttempt(optionsArg: { username: string; userId?: string; success: boolean; requestedAuthSource: TAuthenticationSource; resolvedAuthSource?: TResolvedAuthenticationSource; failureReason?: TAuthenticationFailureReason; }): Promise { const event: IAuthenticationEvent = { id: `auth-${plugins.uuid.v4()}`, timestamp: Date.now(), username: String(optionsArg.username || '').trim().toLowerCase() || 'unknown', success: optionsArg.success, requestedAuthSource: optionsArg.requestedAuthSource, ...(optionsArg.userId ? { userId: optionsArg.userId } : {}), ...(optionsArg.resolvedAuthSource ? { resolvedAuthSource: optionsArg.resolvedAuthSource } : {}), ...(optionsArg.failureReason ? { failureReason: optionsArg.failureReason } : {}), }; // Buffer before any asynchronous persistence so authentication cannot be // held behind a slow database and the bounded drop policy remains effective. this.rememberEvent(event); try { SecurityLogger.getInstance().logEvent({ level: event.success ? SecurityLogLevel.INFO : SecurityLogLevel.WARN, type: SecurityEventType.AUTHENTICATION, message: event.success ? 'Authentication succeeded' : 'Authentication failed', userId: event.userId, success: event.success, details: { authenticationEvent: event }, }); } catch (error) { logger.log('warn', `Unable to mirror authentication event to the security log: ${(error as Error).message}`); } return event; } public flushBuffered(): Promise { if (this.flushInFlight) return this.flushInFlight; const run = this.flushBufferedUnlocked(); this.flushInFlight = run; run.then( () => { if (this.flushInFlight === run) this.flushInFlight = undefined; }, () => { if (this.flushInFlight === run) this.flushInFlight = undefined; }, ); return run; } public pruneBefore(cutoffArg: number): Promise { if (this.pruneInFlight) return this.pruneInFlight; const run = this.canPersist() ? AuthenticationEventDoc.pruneBefore(cutoffArg) : Promise.resolve(0); this.pruneInFlight = run; run.then( () => { if (this.pruneInFlight === run) this.pruneInFlight = undefined; }, () => { if (this.pruneInFlight === run) this.pruneInFlight = undefined; }, ); return run; } private getInMemoryWindow(cutoffArg: number, limitArg: number): IAuthenticationWindowStats { const events = [...this.recentEvents.values()] .filter((event) => event.timestamp >= cutoffArg) .sort((a, b) => b.timestamp - a.timestamp || b.id.localeCompare(a.id)); return { windowStart: cutoffArg, successes: events.filter((event) => event.success).length, failures: events.filter((event) => !event.success).length, events: events.slice(0, limitArg), }; } private async getWindowStatsUnlocked( windowMsArg = 24 * 60 * 60 * 1000, limitArg = 100, ): Promise { const cutoff = Date.now() - windowMsArg; if (!this.canPersist()) { return this.getInMemoryWindow(cutoff, limitArg); } try { await this.flushBuffered(); } catch (error) { logger.log('warn', `Unable to flush authentication event buffer: ${(error as Error).message}`); } try { const durable = await AuthenticationEventDoc.getWindowSummary(cutoff, limitArg); const buffered = [...this.pendingEvents.values()] .filter((event) => event.timestamp >= cutoff); const existingIds = new Set(); const bufferedIds = buffered.map((event) => event.id); for (let offset = 0; offset < bufferedIds.length; offset += MAX_PERSIST_BATCH_SIZE) { const existingBatch = await AuthenticationEventDoc.findExistingIds( bufferedIds.slice(offset, offset + MAX_PERSIST_BATCH_SIZE), ); existingBatch.forEach((id) => existingIds.add(id)); } const unpersisted = buffered.filter((event) => !existingIds.has(event.id)); const mergedById = new Map(); for (const event of durable.events) mergedById.set(event.id, event); for (const event of unpersisted) { if (!mergedById.has(event.id)) mergedById.set(event.id, event); } const mergedEvents = [...mergedById.values()] .sort((a, b) => b.timestamp - a.timestamp || b.id.localeCompare(a.id)) .slice(0, limitArg); return { windowStart: cutoff, successes: durable.successes + unpersisted.filter((event) => event.success).length, failures: durable.failures + unpersisted.filter((event) => !event.success).length, events: mergedEvents, }; } catch (error) { logger.log('warn', `Unable to query durable authentication events: ${(error as Error).message}`); return this.getInMemoryWindow(cutoff, limitArg); } } public getWindowStats( windowMsArg = 24 * 60 * 60 * 1000, limitArg = 100, ): Promise { if (!Number.isSafeInteger(windowMsArg) || windowMsArg <= 0) { return Promise.reject(new Error('Authentication window must be a positive integer')); } if (!Number.isSafeInteger(limitArg) || limitArg <= 0 || limitArg > 500) { return Promise.reject(new Error('Authentication event limit must be from 1 to 500')); } const key = `${windowMsArg}:${limitArg}`; const existing = this.windowStatsInFlight.get(key); if (existing) return existing; const run = this.getWindowStatsUnlocked(windowMsArg, limitArg); this.windowStatsInFlight.set(key, run); run.then( () => { if (this.windowStatsInFlight.get(key) === run) this.windowStatsInFlight.delete(key); }, () => { if (this.windowStatsInFlight.get(key) === run) this.windowStatsInFlight.delete(key); }, ); return run; } /** Test-only reset for the DcRouter-owned in-process buffers. */ public async resetForTests(): Promise { await this.flushInFlight?.catch(() => undefined); this.pendingEvents.clear(); this.recentEvents.clear(); } }