import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { AuditLogService } from '../../audit/audit-log.service'; import { MfaService } from './mfa.service'; /** After this many failed attempts in a row the account is locked ... */ export const MAX_FAILED_AUTH_ATTEMPTS = 5; /** ... for this long. */ export const AUTH_LOCK_MS = 15 * 60 * 1000; export interface GuardedUser { id: string; email: string; mfaSecret: string | null; mfaLastTimeStep: number | null; authLockedUntil: Date | null; } /** * Account-level brute-force protection, on top of the per-IP throttle: * an attacker spreading guesses over many addresses still only gets five * tries per account per 15 minutes - for the password, the MFA code and * step-up confirmations alike. A TOTP code is accepted once: its time step * is recorded, and that step or any earlier one is refused afterwards. */ @Injectable() export class AccountGuardService { private readonly logger = new Logger(AccountGuardService.name); constructor( private readonly prisma: PrismaService, private readonly mfaService: MfaService, private readonly auditLog: AuditLogService, ) {} isLocked(user: Pick): boolean { return Boolean(user.authLockedUntil && user.authLockedUntil > new Date()); } async recordFailure(userId: string): Promise { const updated = await this.prisma.user.update({ where: { id: userId }, data: { failedAuthAttempts: { increment: 1 } }, select: { failedAuthAttempts: true }, }); if (updated.failedAuthAttempts >= MAX_FAILED_AUTH_ATTEMPTS) { await this.prisma.user.update({ where: { id: userId }, data: { authLockedUntil: new Date(Date.now() + AUTH_LOCK_MS), failedAuthAttempts: 0, }, }); this.logger.warn(`Account ${userId} locked after repeated failures`); await this.auditLog.record({ entityType: 'AuthSession', entityId: userId, action: 'ACCOUNT_LOCKED', userId, }); } } async recordSuccess(userId: string): Promise { await this.prisma.user.update({ where: { id: userId }, data: { failedAuthAttempts: 0, authLockedUntil: null }, }); } /** * True only for a valid code whose time step is newer than the last one * used - and only for one of two concurrent requests with the same code. */ async consumeTotp(user: GuardedUser, code: string): Promise { if (!user.mfaSecret) { return false; } const timeStep = await this.mfaService.verifyCodeStep( user.mfaSecret, user.email, code, user.mfaLastTimeStep ?? undefined, ); if (timeStep === null) { return false; } const claimed = await this.prisma.user.updateMany({ where: { id: user.id, OR: [{ mfaLastTimeStep: null }, { mfaLastTimeStep: { lt: timeStep } }], }, data: { mfaLastTimeStep: timeStep }, }); return claimed.count === 1; } }