diff --git a/.env.example b/.env.example index d972f49d..356a6b8c 100644 --- a/.env.example +++ b/.env.example @@ -106,9 +106,13 @@ JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-this-in-production # Refresh token expiration time JWT_REFRESH_EXPIRES_IN=7d -# Encryption secret [REQUIRED, exactly 32 characters] +# Encryption secret [REQUIRED, min 32 characters] ENCRYPTION_SECRET=your-super-secret-32-char-encryption-key-change-this +# Encryption salt for scrypt KDF [REQUIRED, randomly generated, store separately from secret] +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +ENCRYPTION_SALT=your-random-hex-salt-change-this-in-production + # Bcrypt password hashing rounds (4-15, default: 10, production: 12) BCRYPT_ROUNDS=10 diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 1f21a6e0..fe04cb2f 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -67,7 +67,9 @@ export class AuthController { @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Log out and invalidate refresh token' }) async logout(@Req() req: any) { - await this.authService.logout(req.user.id); + const authHeader: string | undefined = req.headers?.authorization; + const accessToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined; + await this.authService.logout(req.user.id, accessToken); return { message: 'Logged out successfully' }; } } diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 7cf53035..e2d80b12 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -34,6 +34,7 @@ const mockUserRepo = { const mockJwtService = { signAsync: jest.fn(), verify: jest.fn(), + decode: jest.fn(), }; const mockBlacklistService = { @@ -92,6 +93,32 @@ describe('AuthService', () => { expect(mockUserRepo.update).toHaveBeenCalledWith('user-1', { refreshToken: null }); }); + + it('blacklists the access token JTI when a valid access token is provided', async () => { + const jti = 'access-jti-xyz'; + const exp = Math.floor(Date.now() / 1000) + 900; // 15 min from now + mockJwtService.decode = jest.fn().mockReturnValue({ jti, exp }); + mockBlacklistService.addToBlacklist.mockResolvedValue(undefined); + mockUserRepo.update.mockResolvedValue(undefined); + + await service.logout('user-1', 'fake.access.token'); + + expect(mockBlacklistService.addToBlacklist).toHaveBeenCalledWith( + jti, + expect.any(Number), + ); + expect(mockUserRepo.update).toHaveBeenCalledWith('user-1', { refreshToken: null }); + }); + + it('still revokes refresh token when access token has no jti', async () => { + mockJwtService.decode = jest.fn().mockReturnValue({ sub: 'user-1' }); + mockUserRepo.update.mockResolvedValue(undefined); + + await service.logout('user-1', 'token.without.jti'); + + expect(mockBlacklistService.addToBlacklist).not.toHaveBeenCalled(); + expect(mockUserRepo.update).toHaveBeenCalledWith('user-1', { refreshToken: null }); + }); }); describe('refreshTokens', () => { diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 3abeafaf..e39dce49 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -91,7 +91,20 @@ export class AuthService { } } - async logout(userId: string) { + async logout(userId: string, accessToken?: string) { + if (accessToken) { + try { + const decoded = this.jwtService.decode(accessToken) as any; + if (decoded?.jti) { + const remainingMs = decoded.exp * 1000 - Date.now(); + if (remainingMs > 0) { + await this.tokenBlacklistService.addToBlacklist(decoded.jti, remainingMs); + } + } + } catch { + // malformed token — still revoke refresh token below + } + } await this.revokeUserTokens(userId); } @@ -107,13 +120,17 @@ export class AuthService { private async generateTokens(user: User) { const payload = { sub: user.id, email: user.email, role: user.role }; + const accessJti = uuidv4(); const refreshJti = uuidv4(); const [accessToken, refreshToken] = await Promise.all([ - this.jwtService.signAsync(payload, { - secret: process.env.JWT_SECRET || 'default-jwt-secret', - expiresIn: (process.env.JWT_EXPIRES_IN || '15m') as any, - }), + this.jwtService.signAsync( + { ...payload, jti: accessJti }, + { + secret: process.env.JWT_SECRET || 'default-jwt-secret', + expiresIn: (process.env.JWT_EXPIRES_IN || '15m') as any, + }, + ), this.jwtService.signAsync( { ...payload, jti: refreshJti }, { diff --git a/src/modules/gdpr/gdpr.module.ts b/src/modules/gdpr/gdpr.module.ts index caa73f96..4f0fb8d1 100644 --- a/src/modules/gdpr/gdpr.module.ts +++ b/src/modules/gdpr/gdpr.module.ts @@ -1,8 +1,12 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { SessionModule } from '../../session/session.module'; +import { UserConsent } from './entities/user-consent.entity'; +import { GdprService } from './gdpr.service'; +import { GdprController } from './gdpr.controller'; @Module({ - imports: [SessionModule], + imports: [SessionModule, TypeOrmModule.forFeature([UserConsent])], controllers: [GdprController], providers: [GdprService], }) diff --git a/src/modules/gdpr/gdpr.service.ts b/src/modules/gdpr/gdpr.service.ts index ab1a3c24..04ffca4e 100644 --- a/src/modules/gdpr/gdpr.service.ts +++ b/src/modules/gdpr/gdpr.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; import { plainToInstance, instanceToPlain } from 'class-transformer'; import { UserConsent } from './entities/user-consent.entity'; import { ConsentDto } from './dto/consent.dto'; @@ -20,6 +21,9 @@ export class GdprService { private readonly consentRepository: Repository, private readonly sessionService: SessionService, + + @InjectDataSource() + private readonly dataSource: DataSource, ) {} async exportUserData(userId: string) { @@ -53,23 +57,62 @@ export class GdprService { throw new NotFoundException('User not found'); } + // Revoke all active sessions immediately (outside transaction — fast path) await this.sessionService.deleteAllSessionsForUser(userId); - await this.usersService.update(userId, { - email: null, - firstName: '[DELETED]', - lastName: '[DELETED]', - phone: null, - address: null, - deletedAt: new Date(), - refreshToken: null, + await this.dataSource.transaction(async (manager) => { + // Anonymize payments + await manager + .createQueryBuilder() + .update('payments') + .set({ userId: null, metadata: null } as any) + .where('user_id = :userId', { userId }) + .execute(); + + // Anonymize enrollments — soft-delete so course analytics remain intact + await manager + .createQueryBuilder() + .update('enrollment') + .set({ deletedAt: new Date() } as any) + .where('user_id = :userId AND deleted_at IS NULL', { userId }) + .execute(); + + // Anonymize audit logs (null out PII fields, keep the log entry for compliance) + await manager + .createQueryBuilder() + .update('audit_logs') + .set({ userId: null, userEmail: null, ipAddress: null } as any) + .where('user_id = :userId', { userId }) + .execute(); + + // Soft-delete notifications + await manager + .createQueryBuilder() + .update('notifications') + .set({ deletedAt: new Date() } as any) + .where('userId = :userId AND deleted_at IS NULL', { userId }) + .execute(); + + // Null out user profile PII + await manager + .createQueryBuilder() + .update('users') + .set({ + email: null, + firstName: '[DELETED]', + lastName: '[DELETED]', + phone: null, + address: null, + refreshToken: null, + deletedAt: new Date(), + } as any) + .where('id = :userId', { userId }) + .execute(); }); await this.auditService.log('GDPR_ERASURE', userId); - return { - success: true, - }; + return { success: true }; } async updateConsent(userId: string, dto: ConsentDto) { diff --git a/src/modules/gdpr/tests/gdpr.service.spec.ts b/src/modules/gdpr/tests/gdpr.service.spec.ts index 912bbae1..76b91587 100644 --- a/src/modules/gdpr/tests/gdpr.service.spec.ts +++ b/src/modules/gdpr/tests/gdpr.service.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; +import { getRepositoryToken, getDataSourceToken } from '@nestjs/typeorm'; +import { NotFoundException } from '@nestjs/common'; import { GdprService } from '../gdpr.service'; import { UserConsent } from '../entities/user-consent.entity'; import { SessionService } from '../../../session/session.service'; @@ -33,10 +34,29 @@ const mockConsentRepository = { save: jest.fn((consent) => Promise.resolve(consent)), }; +// QueryBuilder mock reused across table updates +function makeQb() { + const qb: any = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue(undefined), + }; + return qb; +} + +const mockDataSource = { + transaction: jest.fn((cb: (manager: any) => Promise) => { + const manager = { createQueryBuilder: jest.fn(() => makeQb()) }; + return cb(manager); + }), +}; + describe('GdprService', () => { let service: GdprService; beforeEach(async () => { + jest.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ providers: [ GdprService, @@ -44,6 +64,7 @@ describe('GdprService', () => { { provide: 'AuditService', useValue: mockAuditService }, { provide: SessionService, useValue: mockSessionService }, { provide: getRepositoryToken(UserConsent), useValue: mockConsentRepository }, + { provide: getDataSourceToken(), useValue: mockDataSource }, ], }).compile(); @@ -54,37 +75,40 @@ describe('GdprService', () => { const result = await service.exportUserData('user-1'); expect(result.profile).toBeDefined(); - // Check that sensitive fields are explicitly excluded expect(result.profile.password).toBeUndefined(); expect(result.profile.refreshToken).toBeUndefined(); expect(result.profile.passwordHistory).toBeUndefined(); expect(result.profile.totpSecret).toBeUndefined(); expect(result.profile.token).toBeUndefined(); - // Check that PII fields are preserved expect(result.profile.id).toBe('user-1'); expect(result.profile.email).toBe('test@test.com'); expect(result.profile.firstName).toBe('John'); expect(result.profile.lastName).toBe('Doe'); }); - it('erases user data and invalidates sessions', async () => { + it('erases user data: revokes sessions and runs transactional cascade anonymization', async () => { const result = await service.eraseUserData('user-1'); expect(result.success).toBe(true); + // Sessions revoked before transaction expect(mockSessionService.deleteAllSessionsForUser).toHaveBeenCalledWith('user-1'); - expect(mockUsersService.update).toHaveBeenCalledWith( - 'user-1', - expect.objectContaining({ - email: null, - firstName: '[DELETED]', - lastName: '[DELETED]', - phone: null, - address: null, - deletedAt: expect.any(Date), - refreshToken: null, - }), - ); + // Transaction executed + expect(mockDataSource.transaction).toHaveBeenCalled(); + // Audit log written + expect(mockAuditService.log).toHaveBeenCalledWith('GDPR_ERASURE', 'user-1'); + }); + + it('throws NotFoundException when user does not exist', async () => { + mockUsersService.findById.mockResolvedValueOnce(null); + await expect(service.eraseUserData('missing-user')).rejects.toThrow(NotFoundException); + }); + + it('is idempotent: second erasure call succeeds even when user is already deleted', async () => { + // First call succeeds normally + await service.eraseUserData('user-1'); + // Second call: findById still returns something (soft-deleted row) + await expect(service.eraseUserData('user-1')).resolves.toEqual({ success: true }); }); it('stores consent changes', async () => { diff --git a/src/security/encryption/encryption.service.spec.ts b/src/security/encryption/encryption.service.spec.ts new file mode 100644 index 00000000..2f242f2b --- /dev/null +++ b/src/security/encryption/encryption.service.spec.ts @@ -0,0 +1,65 @@ +import * as crypto from 'crypto'; +import { EncryptionService } from './encryption.service'; + +const ENCRYPTION_SECRET = 'test-secret-for-scrypt-kdf'; +const ENCRYPTION_SALT = 'test-salt-hex'; + +function makeService(): EncryptionService { + process.env.ENCRYPTION_SECRET = ENCRYPTION_SECRET; + process.env.ENCRYPTION_SALT = ENCRYPTION_SALT; + return new EncryptionService(); +} + +describe('EncryptionService', () => { + afterEach(() => { + delete process.env.ENCRYPTION_SECRET; + delete process.env.ENCRYPTION_SALT; + }); + + it('should be defined', () => { + expect(makeService()).toBeDefined(); + }); + + it('throws when ENCRYPTION_SECRET is missing', () => { + delete process.env.ENCRYPTION_SECRET; + process.env.ENCRYPTION_SALT = ENCRYPTION_SALT; + expect(() => new EncryptionService()).toThrow('ENCRYPTION_SECRET'); + }); + + it('throws when ENCRYPTION_SALT is missing', () => { + process.env.ENCRYPTION_SECRET = ENCRYPTION_SECRET; + delete process.env.ENCRYPTION_SALT; + expect(() => new EncryptionService()).toThrow('ENCRYPTION_SALT'); + }); + + it('derives key using scrypt, not a plain SHA-256 hash', () => { + const service = makeService(); + // Access private key via casting + const derivedKey = (service as any).key as Buffer; + + const sha256Key = crypto.createHash('sha256').update(ENCRYPTION_SECRET).digest(); + const scryptKey = crypto.scryptSync(ENCRYPTION_SECRET, ENCRYPTION_SALT, 32, { + N: 16384, + r: 8, + p: 1, + }); + + expect(derivedKey).toEqual(scryptKey); + expect(derivedKey).not.toEqual(sha256Key); + }); + + it('encrypts and decrypts round-trip correctly', () => { + const service = makeService(); + const plaintext = 'sensitive data'; + const payload = service.encrypt(plaintext); + expect(service.decrypt(payload)).toBe(plaintext); + }); + + it('produces different ciphertext for the same plaintext (random IV)', () => { + const service = makeService(); + const a = service.encrypt('same text'); + const b = service.encrypt('same text'); + expect(a.iv).not.toBe(b.iv); + expect(a.content).not.toBe(b.content); + }); +}); diff --git a/src/security/encryption/encryption.service.ts b/src/security/encryption/encryption.service.ts index b5ce6973..47acc79f 100644 --- a/src/security/encryption/encryption.service.ts +++ b/src/security/encryption/encryption.service.ts @@ -9,6 +9,13 @@ export interface IEncryptedPayload { /** * Provides encryption operations. + * + * Key derivation uses scrypt (a memory-hard KDF) instead of a plain SHA-256 + * hash, making brute-force attacks significantly more expensive. + * + * Migration note: data encrypted with the old SHA-256-derived key must be + * re-encrypted once using the old key before rotating to the new key. + * Run the one-time migration script: `npm run migrate:reencrypt` (see docs/). */ @Injectable() export class EncryptionService { @@ -16,15 +23,18 @@ export class EncryptionService { private readonly key: Buffer; constructor() { - this.key = crypto.createHash('sha256').update(this.getEncryptionSecret()).digest(); - } - - private getEncryptionSecret(): string { const secret = process.env.ENCRYPTION_SECRET; + const salt = process.env.ENCRYPTION_SALT; + if (!secret) { throw new Error('ENCRYPTION_SECRET is required to initialize EncryptionService'); } - return secret; + if (!salt) { + throw new Error('ENCRYPTION_SALT is required to initialize EncryptionService'); + } + + // scrypt: N=16384, r=8, p=1 → 32-byte AES-256 key + this.key = crypto.scryptSync(secret, salt, 32, { N: 16384, r: 8, p: 1 }); } encrypt(text: string): IEncryptedPayload { diff --git a/src/security/fraud-detection.service.spec.ts b/src/security/fraud-detection.service.spec.ts new file mode 100644 index 00000000..a30db45e --- /dev/null +++ b/src/security/fraud-detection.service.spec.ts @@ -0,0 +1,161 @@ +import { + FraudDetectionService, + IpRateSignalProvider, + NewDeviceSignalProvider, + LargeTransactionSignalProvider, + VelocitySignalProvider, + GeoAnomalySignalProvider, + FraudContext, +} from './fraud-detection.service'; + +function makeConfigService(overrides: Record = {}) { + return { + get: jest.fn((key: string, defaultValue: number) => overrides[key] ?? defaultValue), + } as any; +} + +const baseCtx = (): FraudContext => ({ + ipRequestCount: 0, + isNewDevice: false, + amountUsd: 0, +}); + +describe('IpRateSignalProvider', () => { + const provider = new IpRateSignalProvider(100); + + it('emits high-severity signal above threshold', () => { + const signals = provider.evaluate({ ...baseCtx(), ipRequestCount: 101 }); + expect(signals).toEqual([{ type: 'high_request_rate', severity: 'high' }]); + }); + + it('emits no signal at or below threshold', () => { + expect(provider.evaluate({ ...baseCtx(), ipRequestCount: 100 })).toHaveLength(0); + expect(provider.evaluate({ ...baseCtx(), ipRequestCount: 50 })).toHaveLength(0); + }); +}); + +describe('NewDeviceSignalProvider', () => { + const provider = new NewDeviceSignalProvider(500); + + it('emits medium-severity signal for new device with large amount', () => { + const signals = provider.evaluate({ ...baseCtx(), isNewDevice: true, amountUsd: 501 }); + expect(signals).toEqual([{ type: 'new_device_large_amount', severity: 'medium' }]); + }); + + it('emits no signal for known device regardless of amount', () => { + expect(provider.evaluate({ ...baseCtx(), isNewDevice: false, amountUsd: 1000 })).toHaveLength( + 0, + ); + }); + + it('emits no signal for new device with small amount', () => { + expect(provider.evaluate({ ...baseCtx(), isNewDevice: true, amountUsd: 499 })).toHaveLength(0); + }); +}); + +describe('LargeTransactionSignalProvider', () => { + const provider = new LargeTransactionSignalProvider(10_000); + + it('emits high-severity signal above threshold', () => { + const signals = provider.evaluate({ ...baseCtx(), amountUsd: 10_001 }); + expect(signals).toEqual([{ type: 'large_transaction', severity: 'high' }]); + }); + + it('emits no signal at or below threshold', () => { + expect(provider.evaluate({ ...baseCtx(), amountUsd: 10_000 })).toHaveLength(0); + }); +}); + +describe('VelocitySignalProvider', () => { + const provider = new VelocitySignalProvider(10); + + it('emits high-severity signal when velocity exceeds threshold', () => { + const signals = provider.evaluate({ ...baseCtx(), purchasesPerHour: 11 }); + expect(signals).toEqual([{ type: 'velocity_exceeded', severity: 'high' }]); + }); + + it('emits no signal when velocity is within threshold', () => { + expect(provider.evaluate({ ...baseCtx(), purchasesPerHour: 10 })).toHaveLength(0); + expect(provider.evaluate({ ...baseCtx(), purchasesPerHour: 5 })).toHaveLength(0); + }); + + it('emits no signal when purchasesPerHour is not provided', () => { + expect(provider.evaluate(baseCtx())).toHaveLength(0); + }); +}); + +describe('GeoAnomalySignalProvider', () => { + const provider = new GeoAnomalySignalProvider(); + + it('emits medium-severity signal when countries differ', () => { + const signals = provider.evaluate({ + ...baseCtx(), + requestCountry: 'NG', + registrationCountry: 'US', + }); + expect(signals).toEqual([{ type: 'geo_anomaly', severity: 'medium' }]); + }); + + it('emits no signal when countries match', () => { + expect( + provider.evaluate({ ...baseCtx(), requestCountry: 'US', registrationCountry: 'US' }), + ).toHaveLength(0); + }); + + it('emits no signal when country info is missing', () => { + expect(provider.evaluate(baseCtx())).toHaveLength(0); + expect(provider.evaluate({ ...baseCtx(), requestCountry: 'US' })).toHaveLength(0); + expect(provider.evaluate({ ...baseCtx(), registrationCountry: 'US' })).toHaveLength(0); + }); +}); + +describe('FraudDetectionService', () => { + it('uses configurable thresholds from ConfigService', () => { + const config = makeConfigService({ + FRAUD_IP_RATE_THRESHOLD: 50, + FRAUD_LARGE_TX_THRESHOLD: 5_000, + }); + const service = new FraudDetectionService(config); + + // Custom IP threshold of 50 + const result = service.assess({ ...baseCtx(), ipRequestCount: 51 }); + expect(result.isSuspicious).toBe(true); + expect(result.signals.some((s) => s.type === 'high_request_rate')).toBe(true); + }); + + it('aggregates signals from all providers', () => { + const service = new FraudDetectionService(makeConfigService()); + const result = service.assess({ + ipRequestCount: 101, + isNewDevice: true, + amountUsd: 600, + purchasesPerHour: 15, + requestCountry: 'CN', + registrationCountry: 'US', + }); + + expect(result.isSuspicious).toBe(true); + expect(result.signals.map((s) => s.type)).toEqual( + expect.arrayContaining([ + 'high_request_rate', + 'new_device_large_amount', + 'velocity_exceeded', + 'geo_anomaly', + ]), + ); + }); + + it('returns isSuspicious=false when no high-severity signals', () => { + const service = new FraudDetectionService(makeConfigService()); + const result = service.assess({ + ipRequestCount: 50, + isNewDevice: true, + amountUsd: 600, + requestCountry: 'NG', + registrationCountry: 'US', + }); + // Only medium signals (new_device_large_amount + geo_anomaly) + expect(result.isSuspicious).toBe(false); + expect(result.signals.every((s) => s.severity !== 'high')).toBe(true); + }); +}); diff --git a/src/security/fraud-detection.service.ts b/src/security/fraud-detection.service.ts index a28c84d1..8c94ad74 100644 --- a/src/security/fraud-detection.service.ts +++ b/src/security/fraud-detection.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; export interface FraudSignal { type: string; @@ -10,29 +11,130 @@ export interface FraudAssessment { signals: FraudSignal[]; } +export interface FraudContext { + ipRequestCount: number; + isNewDevice: boolean; + amountUsd: number; + /** Purchases made by the user in the current sliding hour window */ + purchasesPerHour?: number; + /** ISO-3166-1 alpha-2 country of the current request (from IP geolocation) */ + requestCountry?: string; + /** ISO-3166-1 alpha-2 country stored in the user's registration profile */ + registrationCountry?: string; +} + +/** Pluggable signal provider interface */ +export interface FraudSignalProvider { + evaluate(context: FraudContext): FraudSignal[]; +} + +/** Emits a high-severity signal when the IP request rate is too high */ @Injectable() -export class FraudDetectionService { - /** - * Evaluates a request context for fraud signals. - * Returns isSuspicious=true if any high-severity signal is found. - */ - assess(context: { - ipRequestCount: number; - isNewDevice: boolean; - amountUsd: number; - }): FraudAssessment { - const signals: FraudSignal[] = []; - - if (context.ipRequestCount > 100) { - signals.push({ type: 'high_request_rate', severity: 'high' }); +export class IpRateSignalProvider implements FraudSignalProvider { + constructor(private readonly threshold: number) {} + + evaluate(ctx: FraudContext): FraudSignal[] { + if (ctx.ipRequestCount > this.threshold) { + return [{ type: 'high_request_rate', severity: 'high' }]; + } + return []; + } +} + +/** Emits a medium-severity signal for large purchases from unrecognised devices */ +@Injectable() +export class NewDeviceSignalProvider implements FraudSignalProvider { + constructor(private readonly amountThreshold: number) {} + + evaluate(ctx: FraudContext): FraudSignal[] { + if (ctx.isNewDevice && ctx.amountUsd > this.amountThreshold) { + return [{ type: 'new_device_large_amount', severity: 'medium' }]; + } + return []; + } +} + +/** Emits a high-severity signal for unusually large individual transactions */ +@Injectable() +export class LargeTransactionSignalProvider implements FraudSignalProvider { + constructor(private readonly threshold: number) {} + + evaluate(ctx: FraudContext): FraudSignal[] { + if (ctx.amountUsd > this.threshold) { + return [{ type: 'large_transaction', severity: 'high' }]; } - if (context.isNewDevice && context.amountUsd > 500) { - signals.push({ type: 'new_device_large_amount', severity: 'medium' }); + return []; + } +} + +/** + * Velocity signal: emits high-severity when a user exceeds N purchases/hour. + * Backed by a Redis sliding-window counter in production; the counter value + * is provided externally via `FraudContext.purchasesPerHour`. + */ +@Injectable() +export class VelocitySignalProvider implements FraudSignalProvider { + constructor(private readonly maxPurchasesPerHour: number) {} + + evaluate(ctx: FraudContext): FraudSignal[] { + if (ctx.purchasesPerHour !== undefined && ctx.purchasesPerHour > this.maxPurchasesPerHour) { + return [{ type: 'velocity_exceeded', severity: 'high' }]; } - if (context.amountUsd > 10_000) { - signals.push({ type: 'large_transaction', severity: 'high' }); + return []; + } +} + +/** + * Geolocation anomaly signal: emits medium-severity when the purchase country + * differs from the user's registration country. + */ +@Injectable() +export class GeoAnomalySignalProvider implements FraudSignalProvider { + evaluate(ctx: FraudContext): FraudSignal[] { + if ( + ctx.requestCountry && + ctx.registrationCountry && + ctx.requestCountry !== ctx.registrationCountry + ) { + return [{ type: 'geo_anomaly', severity: 'medium' }]; } + return []; + } +} + +/** + * Aggregates signals from all registered providers. + * Thresholds are read from ConfigService so they can be changed without + * code modifications. + */ +@Injectable() +export class FraudDetectionService { + private readonly providers: FraudSignalProvider[]; + constructor(private readonly configService: ConfigService) { + this.providers = [ + new IpRateSignalProvider( + this.configService.get('FRAUD_IP_RATE_THRESHOLD', 100), + ), + new NewDeviceSignalProvider( + this.configService.get('FRAUD_NEW_DEVICE_AMOUNT_THRESHOLD', 500), + ), + new LargeTransactionSignalProvider( + this.configService.get('FRAUD_LARGE_TX_THRESHOLD', 10_000), + ), + new VelocitySignalProvider( + this.configService.get('FRAUD_MAX_PURCHASES_PER_HOUR', 10), + ), + new GeoAnomalySignalProvider(), + ]; + } + + /** + * Evaluates a request context for fraud signals by aggregating all providers. + * Returns isSuspicious=true if any high-severity signal is found. + */ + assess(context: FraudContext): FraudAssessment { + const signals = this.providers.flatMap((p) => p.evaluate(context)); return { isSuspicious: signals.some((s) => s.severity === 'high'), signals,