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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 02:27:47 +02:00
1 parent 79d20ee64e
commit 5c666fe49a
22 files changed
+494 -91

No files matched your search

+21 -1
View File
@@ -2,7 +2,12 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as QRCode from 'qrcode';
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
import { buildTotpUri, generateTotpSecret, verifyTotpCode } from './totp.util';
import {
buildTotpUri,
generateTotpSecret,
verifyTotpCode,
verifyTotpStep,
} from './totp.util';
import {
TOKEN_AUDIENCE,
jwtSignOptions,
@@ -104,6 +109,21 @@ export class MfaService {
return { userId: payload.sub };
}
/** See verifyTotpStep; use AccountGuardService.consumeTotp for logins. */
async verifyCodeStep(
encryptedSecret: string,
email: string,
code: string,
afterTimeStep?: number,
): Promise<number | null> {
return verifyTotpStep(
decryptMfaSecret(encryptedSecret),
email,
code,
afterTimeStep,
);
}
async verifyCode(
encryptedSecret: string,
email: string,