/** * Brute Force Protection Service * * Implements exponential backoff for failed login attempts */ import { Injectable, Logger } from '@nestjs/common'; import { bruteForceConfig } from '../../infrastructure/security/security.config'; interface LoginAttempt { count: number; firstAttempt: Date; lastAttempt: Date; blockedUntil?: Date; } @Injectable() export class BruteForceProtectionService { private readonly logger = new Logger(BruteForceProtectionService.name); private readonly attempts = new Map(); private readonly cleanupInterval = 60 * 60 * 1000; // 1 hour constructor() { // Periodically clean up old attempts setInterval(() => this.cleanup(), this.cleanupInterval); } /** * Record a failed login attempt */ recordFailedAttempt(identifier: string): void { const now = new Date(); const existing = this.attempts.get(identifier); if (existing) { existing.count++; existing.lastAttempt = now; // Calculate block time with exponential backoff if (existing.count > bruteForceConfig.freeRetries) { const waitTime = this.calculateWaitTime(existing.count - bruteForceConfig.freeRetries); existing.blockedUntil = new Date(now.getTime() + waitTime); this.logger.warn( `Brute force detected for ${identifier}. Blocked until ${existing.blockedUntil.toISOString()}` ); } this.attempts.set(identifier, existing); } else { this.attempts.set(identifier, { count: 1, firstAttempt: now, lastAttempt: now, }); } } /** * Record a successful login (clears attempts) */ recordSuccessfulAttempt(identifier: string): void { this.attempts.delete(identifier); this.logger.log(`Cleared failed attempts for ${identifier}`); } /** * Check if identifier is currently blocked */ isBlocked(identifier: string): boolean { const attempt = this.attempts.get(identifier); if (!attempt || !attempt.blockedUntil) { return false; } const now = new Date(); if (now < attempt.blockedUntil) { return true; } // Block expired, reset this.attempts.delete(identifier); return false; } /** * Get remaining block time in seconds */ getRemainingBlockTime(identifier: string): number { const attempt = this.attempts.get(identifier); if (!attempt || !attempt.blockedUntil) { return 0; } const now = new Date(); const remaining = Math.max( 0, Math.floor((attempt.blockedUntil.getTime() - now.getTime()) / 1000) ); return remaining; } /** * Get failed attempt count */ getAttemptCount(identifier: string): number { return this.attempts.get(identifier)?.count || 0; } /** * Calculate wait time with exponential backoff */ private calculateWaitTime(failedAttempts: number): number { const waitTime = bruteForceConfig.minWait * Math.pow(2, failedAttempts - 1); return Math.min(waitTime, bruteForceConfig.maxWait); } /** * Clean up old attempts */ private cleanup(): void { const now = new Date(); const lifetimeMs = bruteForceConfig.lifetime * 1000; let cleaned = 0; for (const [identifier, attempt] of this.attempts.entries()) { const age = now.getTime() - attempt.firstAttempt.getTime(); if (age > lifetimeMs) { this.attempts.delete(identifier); cleaned++; } } if (cleaned > 0) { this.logger.log(`Cleaned up ${cleaned} old brute force attempts`); } } /** * Get statistics */ getStats(): { totalAttempts: number; currentlyBlocked: number; averageAttempts: number; } { let totalAttempts = 0; let currentlyBlocked = 0; for (const [identifier, attempt] of this.attempts.entries()) { totalAttempts += attempt.count; if (this.isBlocked(identifier)) { currentlyBlocked++; } } return { totalAttempts, currentlyBlocked, averageAttempts: this.attempts.size > 0 ? Math.round(totalAttempts / this.attempts.size) : 0, }; } /** * Manually block an identifier (admin action) */ manualBlock(identifier: string, durationMs: number): void { const now = new Date(); const existing = this.attempts.get(identifier); if (existing) { existing.blockedUntil = new Date(now.getTime() + durationMs); existing.count = 999; // High count to indicate manual block this.attempts.set(identifier, existing); } else { this.attempts.set(identifier, { count: 999, firstAttempt: now, lastAttempt: now, blockedUntil: new Date(now.getTime() + durationMs), }); } this.logger.warn(`Manually blocked ${identifier} for ${durationMs / 1000} seconds`); } /** * Manually unblock an identifier (admin action) */ manualUnblock(identifier: string): void { this.attempts.delete(identifier); this.logger.log(`Manually unblocked ${identifier}`); } }