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
133 changes: 133 additions & 0 deletions backend/services/shared/__tests__/accountLockoutService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { AccountLockoutService } from '../accountLockoutService';

describe('AccountLockoutService', () => {
let lockoutService: AccountLockoutService;

beforeEach(() => {
// Override Date.now for predictable time assertions where necessary,
// though here we mainly rely on real time with mocked advances if needed,
// or we just use realistic thresholds.
lockoutService = new AccountLockoutService({
tiers: [
{ threshold: 3, lockoutMinutes: 5 },
{ threshold: 5, lockoutMinutes: 15 },
],
retentionMs: 1000 * 60 * 60, // 1 hour
});
});

afterEach(() => {
lockoutService.clearStore();
jest.restoreAllMocks();
});

it('initially has no lockout', async () => {
const status = await lockoutService.checkLockout('user1@example.com');
expect(status.locked).toBe(false);
expect(status.remainingMs).toBe(0);
expect(status.failedAttempts).toBe(0);
});

it('records failures but does not lock until threshold', async () => {
const email = 'user2@example.com';
let status = await lockoutService.recordFailure(email);
expect(status.locked).toBe(false);
expect(status.failedAttempts).toBe(1);

status = await lockoutService.recordFailure(email);
expect(status.locked).toBe(false);
expect(status.failedAttempts).toBe(2);
});

it('locks out the account when the first threshold is reached', async () => {
const email = 'user3@example.com';
await lockoutService.recordFailure(email);
await lockoutService.recordFailure(email);
const status = await lockoutService.recordFailure(email); // 3rd failure

expect(status.locked).toBe(true);
expect(status.failedAttempts).toBe(3);
// 5 minutes in ms
expect(status.remainingMs).toBe(5 * 60 * 1000);

const checkStatus = await lockoutService.checkLockout(email);
expect(checkStatus.locked).toBe(true);
expect(checkStatus.failedAttempts).toBe(3);
expect(checkStatus.remainingMs).toBeLessThanOrEqual(5 * 60 * 1000);
expect(checkStatus.remainingMs).toBeGreaterThan(0);
});

it('does not increase failures while locked out', async () => {
const email = 'user4@example.com';
await lockoutService.recordFailure(email);
await lockoutService.recordFailure(email);
await lockoutService.recordFailure(email); // locks out

// attempting to record failure while locked
const status = await lockoutService.recordFailure(email);
expect(status.locked).toBe(true);
// should still be 3, not 4
expect(status.failedAttempts).toBe(3);
});

it('progresses to the next tier if failures continue after lockout expires', async () => {
const email = 'user5@example.com';

// Mock Date.now to control time
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);

// Trigger first lockout
await lockoutService.recordFailure(email);
await lockoutService.recordFailure(email);
await lockoutService.recordFailure(email); // 3 failures, locked for 5 mins

// Advance time past 5 minutes
currentTime += 5 * 60 * 1000 + 1000;

// Now unlocked
const unlockStatus = await lockoutService.checkLockout(email);
expect(unlockStatus.locked).toBe(false);

// Next failure (4th) will trigger the 3-failure tier again because 4 >= 3
const status4 = await lockoutService.recordFailure(email);
expect(status4.locked).toBe(true);
expect(status4.failedAttempts).toBe(4);
expect(status4.remainingMs).toBe(5 * 60 * 1000);

// Advance time past the new 5-minute lockout
currentTime += 5 * 60 * 1000 + 1000;

const status5 = await lockoutService.recordFailure(email); // 5 failures -> next tier (15 min)

expect(status5.locked).toBe(true);
expect(status5.failedAttempts).toBe(5);
expect(status5.remainingMs).toBe(15 * 60 * 1000);
});

it('resets failures on successful authentication', async () => {
const email = 'user6@example.com';
await lockoutService.recordFailure(email);
await lockoutService.recordFailure(email); // 2 failures

await lockoutService.resetFailures(email);

const checkStatus = await lockoutService.checkLockout(email);
expect(checkStatus.locked).toBe(false);
expect(checkStatus.failedAttempts).toBe(0);
});

it('clears expired retentions', async () => {
const email = 'user7@example.com';
let currentTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);

await lockoutService.recordFailure(email);

// Advance time past retentionMs (1 hour)
currentTime += 2 * 60 * 60 * 1000;

const checkStatus = await lockoutService.checkLockout(email);
expect(checkStatus.failedAttempts).toBe(0);
});
});
184 changes: 184 additions & 0 deletions backend/services/shared/accountLockoutService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { logger } from './logging';

/**
* Configuration for progressive delay tiers.
* Each tier specifies the number of consecutive failures required to trigger
* the associated lockout duration in minutes.
* Tiers should be ordered in ascending order of thresholds.
*/
export interface LockoutTier {
threshold: number;
lockoutMinutes: number;
}

export interface LockoutStatus {
locked: boolean;
remainingMs: number;
failedAttempts: number;
}

export interface LockoutConfig {
tiers: LockoutTier[];
/** Maximum time (in ms) to retain failure counts after the last activity before resetting */
retentionMs: number;
}

const DEFAULT_CONFIG: LockoutConfig = {
tiers: [
{ threshold: 3, lockoutMinutes: 5 },
{ threshold: 6, lockoutMinutes: 15 },
{ threshold: 9, lockoutMinutes: 60 },
],
retentionMs: 24 * 60 * 60 * 1000, // 24 hours
};

interface AccountLockoutData {
failedAttempts: number;
lockoutUntil: number;
lastUpdated: number;
}

/**
* Provides progressive account lockout mechanisms to mitigate brute force
* and credential stuffing attacks on authentication endpoints.
*/
export class AccountLockoutService {
// In-memory store for tracking lockouts.
// In a multi-node production setup, this would be backed by Redis or Memcached.
private store = new Map<string, AccountLockoutData>();
private readonly config: LockoutConfig;

constructor(config: Partial<LockoutConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
// Sort tiers in descending order to easily find the highest applicable tier
this.config.tiers = [...this.config.tiers].sort((a, b) => b.threshold - a.threshold);
}

/**
* Cleans up expired entries from the store.
*/
private cleanup(): void {
const now = Date.now();
for (const [key, data] of this.store.entries()) {
if (now - data.lastUpdated > this.config.retentionMs) {
this.store.delete(key);
}
}
}

/**
* Retrieves current data for an identifier, resetting if the retention period has passed.
*/
private getValidData(identifier: string, now: number): AccountLockoutData {
const data = this.store.get(identifier);
if (!data) {
return { failedAttempts: 0, lockoutUntil: 0, lastUpdated: now };
}

if (now - data.lastUpdated > this.config.retentionMs) {
this.store.delete(identifier);
return { failedAttempts: 0, lockoutUntil: 0, lastUpdated: now };
}

return data;
}

/**
* Checks the current lockout status for a given identifier (e.g. email or IP).
* @param identifier The account identifier to check.
*/
async checkLockout(identifier: string): Promise<LockoutStatus> {
const now = Date.now();
const data = this.getValidData(identifier, now);

if (data.lockoutUntil > now) {
return {
locked: true,
remainingMs: data.lockoutUntil - now,
failedAttempts: data.failedAttempts,
};
}

return {
locked: false,
remainingMs: 0,
failedAttempts: data.failedAttempts,
};
}

/**
* Records a failed authentication attempt and calculates progressive delays.
* @param identifier The account identifier.
*/
async recordFailure(identifier: string): Promise<LockoutStatus> {
const now = Date.now();
// Run cleanup periodically (approx 1 in 100 calls) to avoid memory leaks
if (Math.random() < 0.01) {
this.cleanup();
}

const data = this.getValidData(identifier, now);

// If currently locked out, we don't increase failures, we just return the active lockout
if (data.lockoutUntil > now) {
logger.warn('Lockout bypassed failure recording', { identifier, remainingMs: data.lockoutUntil - now });
return {
locked: true,
remainingMs: data.lockoutUntil - now,
failedAttempts: data.failedAttempts,
};
}

data.failedAttempts += 1;
data.lastUpdated = now;

// Determine if a lockout tier was reached
let applyLockoutMinutes = 0;
for (const tier of this.config.tiers) {
if (data.failedAttempts >= tier.threshold) {
applyLockoutMinutes = tier.lockoutMinutes;
break; // found the highest tier because tiers are sorted descending
}
}

if (applyLockoutMinutes > 0) {
data.lockoutUntil = now + applyLockoutMinutes * 60 * 1000;
logger.warn('Account locked out due to excessive failures', {
identifier,
failedAttempts: data.failedAttempts,
lockoutMinutes: applyLockoutMinutes,
});
}

this.store.set(identifier, data);

const locked = applyLockoutMinutes > 0;
return {
locked,
remainingMs: locked ? applyLockoutMinutes * 60 * 1000 : 0,
failedAttempts: data.failedAttempts,
};
}

/**
* Resets the failed attempts and lockout status for an identifier.
* Should be called upon successful authentication.
* @param identifier The account identifier.
*/
async resetFailures(identifier: string): Promise<void> {
const data = this.store.get(identifier);
if (data) {
this.store.delete(identifier);
logger.info('Account lockout reset', { identifier });
}
}

/**
* Manually clears the internal store (useful for testing).
*/
clearStore(): void {
this.store.clear();
}
}

export const accountLockoutService = new AccountLockoutService();
Loading
Loading