xpeditis2.0/apps/backend/src/application/services/brute-force-protection.service.ts
2025-10-27 20:54:01 +01:00

198 lines
4.9 KiB
TypeScript

/**
* 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<string, LoginAttempt>();
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}`);
}
}