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
@@ -0,0 +1,97 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user