diff --git a/backend/services/shared/__tests__/accountLockoutService.test.ts b/backend/services/shared/__tests__/accountLockoutService.test.ts new file mode 100644 index 00000000..d55725c9 --- /dev/null +++ b/backend/services/shared/__tests__/accountLockoutService.test.ts @@ -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); + }); +}); diff --git a/backend/services/shared/accountLockoutService.ts b/backend/services/shared/accountLockoutService.ts new file mode 100644 index 00000000..74c16072 --- /dev/null +++ b/backend/services/shared/accountLockoutService.ts @@ -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(); + private readonly config: LockoutConfig; + + constructor(config: Partial = {}) { + 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 { + 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 { + 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 { + 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(); diff --git a/src/services/auth/__tests__/accountLockoutClient.test.ts b/src/services/auth/__tests__/accountLockoutClient.test.ts new file mode 100644 index 00000000..a5c7b625 --- /dev/null +++ b/src/services/auth/__tests__/accountLockoutClient.test.ts @@ -0,0 +1,118 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { AccountLockoutClient } from '../accountLockoutClient'; + +const store: Record = {}; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(async (key: string) => store[key] ?? null), + setItem: jest.fn(async (key: string, value: string) => { + store[key] = value; + }), + removeItem: jest.fn(async (key: string) => { + delete store[key]; + }), +})); + +beforeEach(() => Object.keys(store).forEach((k) => delete store[k])); + +describe('AccountLockoutClient', () => { + let client: AccountLockoutClient; + + beforeEach(() => { + // Clear the mock storage before each test + (AsyncStorage.getItem as jest.Mock).mockClear(); + (AsyncStorage.setItem as jest.Mock).mockClear(); + (AsyncStorage.removeItem as jest.Mock).mockClear(); + + // Use a custom configuration for faster thresholds in tests + client = new AccountLockoutClient({ + tiers: [ + { threshold: 3, lockoutMinutes: 5 }, + { threshold: 5, lockoutMinutes: 15 }, + ], + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('initially has no lockout', async () => { + const status = await client.checkLockout('user@example.com'); + expect(status.locked).toBe(false); + expect(status.failedAttempts).toBe(0); + expect(status.remainingMs).toBe(0); + }); + + it('records failures incrementally without locking prematurely', async () => { + let status = await client.recordFailure('user@example.com'); + expect(status.locked).toBe(false); + expect(status.failedAttempts).toBe(1); + + status = await client.recordFailure('user@example.com'); + expect(status.locked).toBe(false); + expect(status.failedAttempts).toBe(2); + }); + + it('locks out when the first threshold is reached', async () => { + await client.recordFailure('user2@example.com'); + await client.recordFailure('user2@example.com'); + const status = await client.recordFailure('user2@example.com'); // 3rd failure + + expect(status.locked).toBe(true); + expect(status.failedAttempts).toBe(3); + expect(status.remainingMs).toBeGreaterThan(0); + expect(status.remainingMs).toBeLessThanOrEqual(5 * 60 * 1000); + }); + + it('does not increase failure counts while locked out', async () => { + await client.recordFailure('user3@example.com'); + await client.recordFailure('user3@example.com'); + await client.recordFailure('user3@example.com'); // locked out + + const status = await client.recordFailure('user3@example.com'); + expect(status.locked).toBe(true); + expect(status.failedAttempts).toBe(3); // still 3 + }); + + it('resets failures properly', async () => { + await client.recordFailure('user4@example.com'); + await client.recordFailure('user4@example.com'); + + await client.resetFailures('user4@example.com'); + + const status = await client.checkLockout('user4@example.com'); + expect(status.locked).toBe(false); + expect(status.failedAttempts).toBe(0); + }); + + it('progresses to the next tier when failures continue after a lockout', async () => { + let currentTime = Date.now(); + jest.spyOn(Date, 'now').mockImplementation(() => currentTime); + + await client.recordFailure('user5@example.com'); + await client.recordFailure('user5@example.com'); + await client.recordFailure('user5@example.com'); // locked (3) + + // Advance time past the 5-minute lockout + currentTime += 5 * 60 * 1000 + 1000; + + // Next failure shouldn't trigger the second tier yet (needs 5) + const status4 = await client.recordFailure('user5@example.com'); + // Actually, wait, when failures hit 4, tier threshold 3 applies again because 4 >= 3, + // so it locks out for 5 minutes again, UNLESS we specifically configure it otherwise. + // In our implementation, `applyLockoutMinutes` checks `data.failedAttempts >= tier.threshold`. + // For 4, 4 >= 3, so it WILL apply 5 mins lockout again. + 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 client.recordFailure('user5@example.com'); // 5th failure + expect(status5.locked).toBe(true); + expect(status5.failedAttempts).toBe(5); + expect(status5.remainingMs).toBe(15 * 60 * 1000); // next tier! + }); +}); diff --git a/src/services/auth/accountLockoutClient.ts b/src/services/auth/accountLockoutClient.ts new file mode 100644 index 00000000..a024410f --- /dev/null +++ b/src/services/auth/accountLockoutClient.ts @@ -0,0 +1,132 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const STORAGE_KEY_PREFIX = '@subtrackr_account_lockout_'; + +export interface LockoutTier { + threshold: number; + lockoutMinutes: number; +} + +export interface LockoutStatus { + locked: boolean; + remainingMs: number; + failedAttempts: number; +} + +export interface AccountLockoutClientConfig { + tiers: LockoutTier[]; +} + +const DEFAULT_CONFIG: AccountLockoutClientConfig = { + tiers: [ + { threshold: 3, lockoutMinutes: 5 }, + { threshold: 6, lockoutMinutes: 15 }, + { threshold: 9, lockoutMinutes: 60 }, + ], +}; + +interface StoredLockoutData { + failedAttempts: number; + lockoutUntil: number; + lastUpdated: number; +} + +export class AccountLockoutClient { + private readonly config: AccountLockoutClientConfig; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + this.config.tiers = [...this.config.tiers].sort((a, b) => b.threshold - a.threshold); + } + + private getStorageKey(identifier: string): string { + return `${STORAGE_KEY_PREFIX}${identifier}`; + } + + private async getStoredData(identifier: string): Promise { + const raw = await AsyncStorage.getItem(this.getStorageKey(identifier)); + if (!raw) { + return { failedAttempts: 0, lockoutUntil: 0, lastUpdated: Date.now() }; + } + try { + return JSON.parse(raw) as StoredLockoutData; + } catch { + return { failedAttempts: 0, lockoutUntil: 0, lastUpdated: Date.now() }; + } + } + + private async saveStoredData(identifier: string, data: StoredLockoutData): Promise { + await AsyncStorage.setItem(this.getStorageKey(identifier), JSON.stringify(data)); + } + + /** + * Checks if the account identifier is currently locked out on this device. + */ + async checkLockout(identifier: string): Promise { + const data = await this.getStoredData(identifier); + const now = Date.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 locally to update lockout status. + */ + async recordFailure(identifier: string): Promise { + const now = Date.now(); + const data = await this.getStoredData(identifier); + + if (data.lockoutUntil > now) { + return { + locked: true, + remainingMs: data.lockoutUntil - now, + failedAttempts: data.failedAttempts, + }; + } + + data.failedAttempts += 1; + data.lastUpdated = now; + + let applyLockoutMinutes = 0; + for (const tier of this.config.tiers) { + if (data.failedAttempts >= tier.threshold) { + applyLockoutMinutes = tier.lockoutMinutes; + break; + } + } + + if (applyLockoutMinutes > 0) { + data.lockoutUntil = now + applyLockoutMinutes * 60 * 1000; + } + + await this.saveStoredData(identifier, data); + + const locked = applyLockoutMinutes > 0; + return { + locked, + remainingMs: locked ? applyLockoutMinutes * 60 * 1000 : 0, + failedAttempts: data.failedAttempts, + }; + } + + /** + * Resets local lockout tracking on successful login. + */ + async resetFailures(identifier: string): Promise { + await AsyncStorage.removeItem(this.getStorageKey(identifier)); + } +} + +export const accountLockoutClient = new AccountLockoutClient();