198 lines
4.9 KiB
TypeScript
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}`);
|
|
}
|
|
}
|