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

+32 -11
View File
@@ -13,6 +13,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { matchesFileSignature } from '../common/file-signature';
import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service';
import { ReauthService } from '../auth/reauth/reauth.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { UsersService } from '../users/users.service';
import {
@@ -45,8 +46,17 @@ export class ProfileService {
private readonly mfaService: MfaService,
private readonly permissionResolver: PermissionResolverService,
private readonly usersService: UsersService,
private readonly reauth: ReauthService,
) {}
/** MFA changed: every refresh token ends, the current access token runs out. */
private async revokeSessions(userId: string): Promise<void> {
await this.prisma.refreshToken.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
private async requireUser(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
@@ -230,7 +240,11 @@ export class ProfileService {
email: string,
setupToken: string,
code: string,
currentPassword: string,
): Promise<void> {
// A stolen access token alone must not be able to put the attacker's
// own authenticator on the account.
await this.reauth.verify(userId, { password: currentPassword });
const { encryptedSecret } = await this.mfaService.confirmEnrollment(
userId,
setupToken,
@@ -239,8 +253,13 @@ export class ProfileService {
);
await this.prisma.user.update({
where: { id: userId },
data: { mfaEnabled: true, mfaSecret: encryptedSecret },
data: {
mfaEnabled: true,
mfaSecret: encryptedSecret,
mfaLastTimeStep: null,
},
});
await this.revokeSessions(userId);
await this.auditLog.record({
entityType: 'User',
entityId: userId,
@@ -249,19 +268,21 @@ export class ProfileService {
});
}
async disableMfa(userId: string, currentPassword: string): Promise<void> {
const user = await this.requireUser(userId);
const currentMatches = await bcrypt.compare(
currentPassword,
user.passwordHash,
);
if (!currentMatches) {
throw new UnauthorizedException('Aktuelles Passwort ist falsch');
}
/**
* Turning MFA off needs both factors: with the password alone, MFA would
* protect nothing against someone who already knows it.
*/
async disableMfa(
userId: string,
currentPassword: string,
mfaCode: string,
): Promise<void> {
await this.reauth.verify(userId, { password: currentPassword, mfaCode });
await this.prisma.user.update({
where: { id: userId },
data: { mfaEnabled: false, mfaSecret: null },
data: { mfaEnabled: false, mfaSecret: null, mfaLastTimeStep: null },
});
await this.revokeSessions(userId);
await this.auditLog.record({
entityType: 'User',
entityId: userId,