Files
ApiCore/ApiCore-Backend/src/auth/mfa/account-guard.service.ts
T
Nick SzaunigandClaude Opus 5.5 5c666fe49a fix(auth): lock accounts after failed attempts, one-time TOTP codes, stronger MFA changes
- 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]>
2026-10-01 02:27:47 +02:00

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;
}
}