Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 93 additions & 36 deletions backend/src/auth/brute-force.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,117 @@
import { BruteForceGuard } from './brute-force.guard';
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

const store = new Map<string, string>();

jest.mock('ioredis', () => ({
default: jest.fn().mockImplementation(() => ({
get: jest.fn((key: string) => Promise.resolve(store.get(key) || null)),
setex: jest.fn((key: string, _ttl: number, value: string) => {
store.set(key, value);
return Promise.resolve('OK');
}),
incr: jest.fn((key: string) => {
const current = parseInt(store.get(key) || '0', 10);
store.set(key, String(current + 1));
return Promise.resolve(current + 1);
}),
expire: jest.fn(() => Promise.resolve(1)),
del: jest.fn((...keys: string[]) => {
keys.forEach((k) => store.delete(k));
return Promise.resolve(keys.length);
}),
quit: jest.fn(() => Promise.resolve('OK')),
flushall: jest.fn(() => {
store.clear();
return Promise.resolve('OK');
}),
})),
}));

import { BruteForceGuard } from './brute-force.guard';

const mockContext = (body: any): ExecutionContext => ({
switchToHttp: () => ({
getRequest: () => ({ body }),
}),
getHandler: () => jest.fn(),
getClass: () => jest.fn(),
} as any);

describe('BruteForceGuard', () => {
let guard: BruteForceGuard;
let context: ExecutionContext;

beforeEach(() => {
guard = new BruteForceGuard();
context = {
switchToHttp: () => ({
getRequest: () => ({
body: { email: 'test@example.com' },
}),
}),
} as ExecutionContext;
beforeEach(async () => {
const { Test } = await import('@nestjs/testing');
const testModule = await Test.createTestingModule({
providers: [
BruteForceGuard,
{
provide: ConfigService,
useValue: {
get: (key: string) => {
if (key === 'REDIS_HOST') return '127.0.0.1';
if (key === 'REDIS_PORT') return '6379';
return undefined;
},
},
},
],
}).compile();

guard = testModule.get<BruteForceGuard>(BruteForceGuard);
await (guard as any).redis.flushall();
});

afterEach(async () => {
await guard.onModuleDestroy();
});

it('should be defined', () => {
expect(guard).toBeDefined();
});

it('should allow access if email is not present', () => {
context = {
switchToHttp: () => ({
getRequest: () => ({
body: {},
}),
}),
} as ExecutionContext;
expect(guard.canActivate(context)).toBe(true);
it('should allow access when no email in body', async () => {
const result = await guard.canActivate(mockContext({}));
expect(result).toBe(true);
});

it('should allow access if there are no previous failed attempts', () => {
expect(guard.canActivate(context)).toBe(true);
it('should allow access for non-locked account', async () => {
const result = await guard.canActivate(
mockContext({ email: 'test@example.com' }),
);
expect(result).toBe(true);
});

it('should lock the account after MAX_ATTEMPTS', () => {
const email = 'test@example.com';
it('should lock account after max failed attempts', async () => {
const email = 'brute@example.com';
for (let i = 0; i < 5; i++) {
guard.recordFailedLogin(email);
await guard.recordFailedLogin(email);
}
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);

await expect(
guard.canActivate(mockContext({ email })),
).rejects.toThrow(UnauthorizedException);
});

it('should deny access if the account is locked', () => {
const email = 'test@example.com';
for (let i = 0; i < 5; i++) {
guard.recordFailedLogin(email);
it('should allow access for email not yet at threshold', async () => {
const email = 'partial@example.com';
for (let i = 0; i < 4; i++) {
await guard.recordFailedLogin(email);
}
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);

const result = await guard.canActivate(mockContext({ email }));
expect(result).toBe(true);
});

it('should reset attempts after a successful login', () => {
const email = 'test@example.com';
guard.recordFailedLogin(email);
guard.resetAttempts(email);
expect(guard.canActivate(context)).toBe(true);
it('should reset attempts after clearing', async () => {
const email = 'reset@example.com';
for (let i = 0; i < 3; i++) {
await guard.recordFailedLogin(email);
}
await guard.resetAttempts(email);

const result = await guard.canActivate(mockContext({ email }));
expect(result).toBe(true);
});
});
53 changes: 35 additions & 18 deletions backend/src/auth/brute-force.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,35 @@ import {
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';

const LOCKOUT_PREFIX = 'brute:lock:';
const ATTEMPTS_PREFIX = 'brute:attempts:';
const MAX_ATTEMPTS = 5;
const LOCK_TIME_SECONDS = 15 * 60; // 15 minutes

@Injectable()
export class BruteForceGuard implements CanActivate {
private failedAttempts = new Map<
string,
{ count: number; lockUntil?: Date }
>();
private readonly MAX_ATTEMPTS = 5;
private readonly LOCK_TIME_MS = 15 * 60 * 1000; // 15 mins

canActivate(context: ExecutionContext): boolean {
private readonly redis: Redis;

constructor(private readonly configService: ConfigService) {
const host = this.configService.get<string>('REDIS_HOST') || '127.0.0.1';
const port = Number(this.configService.get<string>('REDIS_PORT') || '6379');
const password =
this.configService.get<string>('REDIS_PASSWORD') || undefined;
this.redis = new Redis({ host, port, password });
}

async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const email = req.body?.email || req.body?.username;

if (!email) return true;

const record = this.failedAttempts.get(email);
if (record && record.lockUntil && record.lockUntil > new Date()) {
const lockKey = `${LOCKOUT_PREFIX}${email}`;
const isLocked = await this.redis.get(lockKey);
if (isLocked) {
throw new UnauthorizedException(
'Account locked due to multiple failed login attempts. Please try again later.',
);
Expand All @@ -30,16 +41,22 @@ export class BruteForceGuard implements CanActivate {
return true;
}

recordFailedLogin(email: string) {
const record = this.failedAttempts.get(email) || { count: 0 };
record.count += 1;
if (record.count >= this.MAX_ATTEMPTS) {
record.lockUntil = new Date(Date.now() + this.LOCK_TIME_MS);
async recordFailedLogin(email: string): Promise<void> {
const attemptsKey = `${ATTEMPTS_PREFIX}${email}`;
const count = await this.redis.incr(attemptsKey);
await this.redis.expire(attemptsKey, LOCK_TIME_SECONDS);

if (count >= MAX_ATTEMPTS) {
const lockKey = `${LOCKOUT_PREFIX}${email}`;
await this.redis.setex(lockKey, LOCK_TIME_SECONDS, 'locked');
}
this.failedAttempts.set(email, record);
}

resetAttempts(email: string) {
this.failedAttempts.delete(email);
async resetAttempts(email: string): Promise<void> {
await this.redis.del(`${ATTEMPTS_PREFIX}${email}`, `${LOCKOUT_PREFIX}${email}`);
}

async onModuleDestroy(): Promise<void> {
await this.redis.quit();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { BusinessRegistrationProvider } from './business-registration.provider';

describe('BusinessRegistrationProvider', () => {
let provider: BusinessRegistrationProvider;

beforeEach(() => {
provider = new BusinessRegistrationProvider();
});

it('should be defined', () => {
expect(provider).toBeDefined();
});

it('should return a valid response', async () => {
const result = await provider.validateDocument();
expect(result.success).toBe(true);
expect(result.result).toBe('VALID');
});

it('should report healthy when circuit is closed', async () => {
const healthy = await provider.healthCheck();
expect(healthy).toBe(true);
});

it('should open circuit after threshold failures', async () => {
for (let i = 0; i < 3; i++) {
(provider as any).failureCount++;
}
(provider as any).circuitOpenUntil = new Date(Date.now() + 60000);

await expect(provider.validateDocument()).rejects.toThrow(
ServiceUnavailableException,
);
});

it('should report unhealthy when circuit is open', async () => {
(provider as any).circuitOpenUntil = new Date(Date.now() + 60000);
const healthy = await provider.healthCheck();
expect(healthy).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -1,13 +1,68 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import {
IValidationProvider,
ValidationResponse,
} from '../interfaces/validation-provider.interface';
import { ValidationResult } from '../entities/validation-request.entity';

const REQUEST_TIMEOUT_MS = 10_000;
const CIRCUIT_BREAKER_THRESHOLD = 3;
const CIRCUIT_BREAKER_RESET_MS = 60_000;

@Injectable()
export class BusinessRegistrationProvider implements IValidationProvider {
private readonly logger = new Logger(BusinessRegistrationProvider.name);
private failureCount = 0;
private circuitOpenUntil: Date | null = null;

async validateDocument(): Promise<ValidationResponse> {
this.checkCircuit();

try {
const result = await this.withTimeout(
this.doValidate(),
REQUEST_TIMEOUT_MS,
);
this.failureCount = 0;
return result;
} catch (error) {
this.failureCount++;
if (this.failureCount >= CIRCUIT_BREAKER_THRESHOLD) {
this.circuitOpenUntil = new Date(
Date.now() + CIRCUIT_BREAKER_RESET_MS,
);
this.logger.warn(
`Circuit breaker opened for BusinessRegistrationProvider after ${this.failureCount} failures`,
);
}
throw error;
}
}

async healthCheck(): Promise<boolean> {
return !this.isCircuitOpen();
}

private checkCircuit(): void {
if (this.isCircuitOpen()) {
throw new ServiceUnavailableException(
'BusinessRegistrationProvider circuit breaker is open',
);
}
}

private isCircuitOpen(): boolean {
if (this.circuitOpenUntil && this.circuitOpenUntil > new Date()) {
return true;
}
if (this.circuitOpenUntil && this.circuitOpenUntil <= new Date()) {
this.circuitOpenUntil = null;
this.failureCount = 0;
}
return false;
}

private async doValidate(): Promise<ValidationResponse> {
return {
result: ValidationResult.VALID,
data: {},
Expand All @@ -21,7 +76,25 @@ export class BusinessRegistrationProvider implements IValidationProvider {
};
}

async healthCheck(): Promise<boolean> {
return true;
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(
new ServiceUnavailableException(
`BusinessRegistrationProvider request timed out after ${ms}ms`,
),
);
}, ms);

promise
.then((value) => {
clearTimeout(timer);
resolve(value);
})
.catch((err) => {
clearTimeout(timer);
reject(err);
});
});
}
}
Loading