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:
1 parent
79d20ee64e
commit
5c666fe49a
22 files changed
+494
-91
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { MfaService } from '../mfa/mfa.service';
|
||||
import { AccountGuardService } from '../mfa/account-guard.service';
|
||||
|
||||
export interface ReauthProof {
|
||||
password?: string;
|
||||
@@ -18,25 +18,33 @@ export interface ReauthProof {
|
||||
export class ReauthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mfaService: MfaService,
|
||||
private readonly accountGuard: AccountGuardService,
|
||||
) {}
|
||||
|
||||
async verify(userId: string, proof: ReauthProof): Promise<void> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
passwordHash: true,
|
||||
mfaEnabled: true,
|
||||
mfaSecret: true,
|
||||
mfaLastTimeStep: true,
|
||||
authLockedUntil: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
if (!user || !user.isActive || this.accountGuard.isLocked(user)) {
|
||||
throw new UnauthorizedException(
|
||||
'Bestätigung derzeit nicht möglich. Nach mehreren Fehlversuchen ist sie für 15 Minuten gesperrt.',
|
||||
);
|
||||
}
|
||||
const passwordMatches =
|
||||
Boolean(user?.isActive) &&
|
||||
typeof proof.password === 'string' &&
|
||||
(await bcrypt.compare(proof.password, user!.passwordHash));
|
||||
if (!user || !passwordMatches) {
|
||||
(await bcrypt.compare(proof.password, user.passwordHash));
|
||||
if (!passwordMatches) {
|
||||
await this.accountGuard.recordFailure(user.id);
|
||||
throw new UnauthorizedException(
|
||||
'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben',
|
||||
);
|
||||
@@ -44,17 +52,14 @@ export class ReauthService {
|
||||
if (user.mfaEnabled) {
|
||||
const codeMatches =
|
||||
typeof proof.mfaCode === 'string' &&
|
||||
user.mfaSecret !== null &&
|
||||
(await this.mfaService.verifyCode(
|
||||
user.mfaSecret,
|
||||
user.email,
|
||||
proof.mfaCode,
|
||||
));
|
||||
(await this.accountGuard.consumeTotp(user, proof.mfaCode));
|
||||
if (!codeMatches) {
|
||||
await this.accountGuard.recordFailure(user.id);
|
||||
throw new UnauthorizedException(
|
||||
'Bitte zur Bestätigung den aktuellen Code aus Ihrer Authenticator-App eingeben',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.accountGuard.recordSuccess(user.id);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user