- Per-account lockout: five failed attempts (password, MFA code or a step-up confirmation) lock the account for 15 minutes, on top of the per-IP throttle - guesses spread over many addresses no longer help. - A TOTP code is accepted once: the time step is stored and that step or any earlier one is refused (also between two concurrent requests). - Login runs bcrypt on every path and answers unknown email, wrong password, deactivated and locked accounts with the same message; failed logins for unknown emails no longer write attacker-chosen text into the audit log. - Turning MFA off needs password and a current code; turning it on needs the password. Both end all other sessions. - New audit action ACCOUNT_LOCKED. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
98 lines
3.0 KiB
TypeScript
98 lines
3.0 KiB
TypeScript
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<GuardedUser, 'authLockedUntil'>): boolean {
|
|
return Boolean(user.authLockedUntil && user.authLockedUntil > new Date());
|
|
}
|
|
|
|
async recordFailure(userId: string): Promise<void> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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;
|
|
}
|
|
}
|