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

+48 -20
View File
@@ -5,6 +5,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { AuditLogService } from '../audit/audit-log.service';
import { MfaService } from './mfa/mfa.service';
import { AccountGuardService } from './mfa/account-guard.service';
describe('AuthService', () => {
let authService: AuthService;
@@ -26,7 +27,12 @@ describe('AuthService', () => {
let mfaService: {
signChallengeToken: jest.Mock;
verifyChallengeToken: jest.Mock;
verifyCode: jest.Mock;
};
let accountGuard: {
isLocked: jest.Mock;
recordFailure: jest.Mock;
recordSuccess: jest.Mock;
consumeTotp: jest.Mock;
};
const activeUser = {
@@ -60,7 +66,12 @@ describe('AuthService', () => {
mfaService = {
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
verifyChallengeToken: jest.fn(),
verifyCode: jest.fn(),
};
accountGuard = {
isLocked: jest.fn().mockReturnValue(false),
recordFailure: jest.fn().mockResolvedValue(undefined),
recordSuccess: jest.fn().mockResolvedValue(undefined),
consumeTotp: jest.fn(),
};
authService = new AuthService(
@@ -70,6 +81,7 @@ describe('AuthService', () => {
auditLog as unknown as AuditLogService,
permissionResolver as never,
mfaService as unknown as MfaService,
accountGuard as unknown as AccountGuardService,
);
});
@@ -157,21 +169,40 @@ describe('AuthService', () => {
);
});
it('records a LOGIN_FAILED audit entry keyed by email when the user is unknown', async () => {
it('writes nothing attacker-chosen to the audit log for an unknown email', async () => {
usersService.findByEmail.mockResolvedValue(null);
await expect(
authService.login('[email protected]', 'x'),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'LOGIN_FAILED',
entityId: '[email protected]',
}),
);
const [[recordArg]] = auditLog.record.mock.calls as [
[{ userId?: string }],
];
expect(recordArg).not.toHaveProperty('userId');
expect(auditLog.record).not.toHaveBeenCalled();
});
it('counts a wrong password against the account', async () => {
usersService.findByEmail.mockResolvedValue(activeUser);
await expect(
authService.login(activeUser.email, 'wrong-password'),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(accountGuard.recordFailure).toHaveBeenCalledWith(activeUser.id);
});
it('refuses a locked account even with the right password, with the same message', async () => {
usersService.findByEmail.mockResolvedValue(activeUser);
accountGuard.isLocked.mockReturnValue(true);
const lockedMessage = await authService
.login(activeUser.email, 'correct-password')
.then(() => 'no error')
.catch((error: Error) => error.message);
accountGuard.isLocked.mockReturnValue(false);
const wrongMessage = await authService
.login(activeUser.email, 'wrong-password')
.then(() => 'no error')
.catch((error: Error) => error.message);
expect(lockedMessage).not.toBe('no error');
expect(lockedMessage).toBe(wrongMessage);
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
});
});
@@ -185,7 +216,7 @@ describe('AuthService', () => {
it('issues tokens and records LOGIN when the code is valid', async () => {
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
usersService.findById.mockResolvedValue(mfaUser);
mfaService.verifyCode.mockResolvedValue(true);
accountGuard.consumeTotp.mockResolvedValue(true);
prisma.refreshToken.create.mockResolvedValue({});
const result = await authService.verifyMfaAndLogin(
@@ -194,11 +225,8 @@ describe('AuthService', () => {
);
expect(result.accessToken).toBe('signed.jwt.token');
expect(mfaService.verifyCode).toHaveBeenCalledWith(
mfaUser.mfaSecret,
mfaUser.email,
'123456',
);
expect(accountGuard.consumeTotp).toHaveBeenCalledWith(mfaUser, '123456');
expect(accountGuard.recordSuccess).toHaveBeenCalledWith(mfaUser.id);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'LOGIN', userId: mfaUser.id }),
);
@@ -207,7 +235,7 @@ describe('AuthService', () => {
it('throws and records LOGIN_FAILED when the code is invalid', async () => {
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
usersService.findById.mockResolvedValue(mfaUser);
mfaService.verifyCode.mockResolvedValue(false);
accountGuard.consumeTotp.mockResolvedValue(false);
await expect(
authService.verifyMfaAndLogin('mfa-challenge-token', '000000'),
+36 -18
View File
@@ -7,6 +7,7 @@ import { UsersService } from '../users/users.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from './permissions/permission-resolver.service';
import { MfaService } from './mfa/mfa.service';
import { AccountGuardService } from './mfa/account-guard.service';
import { parseDurationToMs } from './util/duration';
import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims';
@@ -32,6 +33,19 @@ export interface CurrentUserProfile {
const REFRESH_TOKEN_BYTES = 40;
// Same answer for unknown email, wrong password, deactivated or locked
// account - nothing in the response tells which one it was.
const INVALID_CREDENTIALS =
'E-Mail oder Passwort falsch. Nach mehreren Fehlversuchen ist die Anmeldung für 15 Minuten gesperrt.';
// Hash of a random value nobody knows, at the same cost as real password
// hashes; compared against when there is no real hash, so every login
// costs the same time.
const DUMMY_PASSWORD_HASH = bcrypt.hashSync(
crypto.randomBytes(32).toString('hex'),
12,
);
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
@@ -43,6 +57,7 @@ export class AuthService {
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
private readonly mfaService: MfaService,
private readonly accountGuard: AccountGuardService,
) {}
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
@@ -91,35 +106,36 @@ export class AuthService {
async login(email: string, password: string): Promise<LoginResult> {
const user = await this.usersService.findByEmail(email);
// bcrypt runs on every path - unknown, deactivated, locked or real - so
// the response time does not reveal which accounts exist.
const passwordMatches = await bcrypt.compare(
password,
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
);
if (!user) {
this.logger.warn(`Login failed: no user with email ${email}`);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: email,
action: 'LOGIN_FAILED',
});
throw new UnauthorizedException('Invalid credentials');
this.logger.warn('Login failed: unknown email');
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
if (!user.isActive) {
this.logger.warn(`Login failed: user ${user.id} is deactivated`);
if (!user.isActive || this.accountGuard.isLocked(user)) {
this.logger.warn(`Login refused: user ${user.id} inactive or locked`);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN_FAILED',
userId: user.id,
});
throw new UnauthorizedException('Invalid credentials');
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
const passwordMatches = await bcrypt.compare(password, user.passwordHash);
if (!passwordMatches) {
this.logger.warn(`Login failed: wrong password for user ${user.id}`);
await this.accountGuard.recordFailure(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN_FAILED',
userId: user.id,
});
throw new UnauthorizedException('Invalid credentials');
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
if (user.mfaEnabled) {
return {
@@ -133,6 +149,7 @@ export class AuthService {
};
const accessToken = this.signAccessToken(authenticatedUser);
const refreshToken = await this.issueRefreshToken(user.id);
await this.accountGuard.recordSuccess(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
@@ -180,15 +197,15 @@ export class AuthService {
if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) {
throw new UnauthorizedException('MFA nicht verfügbar');
}
const isValid = await this.mfaService.verifyCode(
user.mfaSecret,
user.email,
code,
);
if (this.accountGuard.isLocked(user)) {
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
const isValid = await this.accountGuard.consumeTotp(user, code);
if (!isValid) {
this.logger.warn(
`MFA verification failed: wrong code for user ${user.id}`,
`MFA verification failed: wrong or reused code for user ${user.id}`,
);
await this.accountGuard.recordFailure(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
@@ -197,6 +214,7 @@ export class AuthService {
});
throw new UnauthorizedException('Code ungültig');
}
await this.accountGuard.recordSuccess(user.id);
const authenticatedUser = {
id: user.id,
email: user.email,
@@ -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;
}
}
+3 -2
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { MfaService } from './mfa.service';
import { AccountGuardService } from './account-guard.service';
@Module({
imports: [JwtModule.register({})],
providers: [MfaService],
exports: [MfaService],
providers: [MfaService, AccountGuardService],
exports: [MfaService, AccountGuardService],
})
export class MfaModule {}
+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,
+17
View File
@@ -20,6 +20,23 @@ export function buildTotpUri(secret: string, label: string): string {
return createTotp(secret, label).toURI();
}
/**
* The time step a code belongs to, or null if it is wrong - or not newer
* than afterTimeStep (a code that was already used, or an older one).
*/
export async function verifyTotpStep(
secret: string,
label: string,
code: string,
afterTimeStep?: number,
): Promise<number | null> {
const result = await createTotp(secret, label).verify(code, {
epochTolerance: EPOCH_TOLERANCE_SECONDS,
afterTimeStep,
});
return result.valid ? result.timeStep : null;
}
export async function verifyTotpCode(
secret: string,
label: string,
@@ -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);
}
}
@@ -1,8 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { IsString, Matches } from 'class-validator';
export class DisableMfaDto {
@ApiProperty()
@IsString()
currentPassword!: string;
@ApiProperty({ description: 'Aktueller Code aus der Authenticator-App' })
@IsString()
@Matches(/^\d{6}$/, { message: 'mfaCode must be a 6-digit number' })
mfaCode!: string;
}
@@ -11,4 +11,9 @@ export class MfaConfirmDto {
@IsString()
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
code!: string;
@ApiProperty({ description: 'Aktuelles Passwort zur Bestätigung' })
@IsString()
@MinLength(1)
currentPassword!: string;
}
@@ -114,6 +114,7 @@ export class ProfileController {
actor.email,
dto.setupToken,
dto.code,
dto.currentPassword,
);
}
@@ -124,6 +125,10 @@ export class ProfileController {
@Body() dto: DisableMfaDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.profileService.disableMfa(actor.id, dto.currentPassword);
return this.profileService.disableMfa(
actor.id,
dto.currentPassword,
dto.mfaCode,
);
}
}
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { MfaModule } from '../auth/mfa/mfa.module';
import { ReauthModule } from '../auth/reauth/reauth.module';
import { UsersModule } from '../users/users.module';
import { ProfileController } from './profile.controller';
import { ProfileService } from './profile.service';
@Module({
imports: [MfaModule, UsersModule],
imports: [MfaModule, ReauthModule, UsersModule],
controllers: [ProfileController],
providers: [ProfileService],
})
@@ -10,6 +10,7 @@ import { ProfileService } from './profile.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { MfaService } 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';
@@ -43,6 +44,8 @@ describe('ProfileService', () => {
mfaSecret: null as string | null,
};
let reauth: { verify: jest.Mock };
beforeEach(async () => {
baseUser.passwordHash = await bcrypt.hash('correct-password', 4);
baseUser.avatarPath = null;
@@ -64,12 +67,14 @@ describe('ProfileService', () => {
mfaService = { startEnrollment: jest.fn(), confirmEnrollment: jest.fn() };
permissionResolver = { invalidateUser: jest.fn() };
usersService = { deactivate: jest.fn().mockResolvedValue(undefined) };
reauth = { verify: jest.fn().mockResolvedValue(undefined) };
service = new ProfileService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
mfaService as unknown as MfaService,
permissionResolver as unknown as PermissionResolverService,
usersService as unknown as UsersService,
reauth as unknown as ReauthService,
);
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
@@ -322,13 +327,22 @@ describe('ProfileService', () => {
baseUser.email,
'setup-token',
'123456',
'correct-password',
);
expect(reauth.verify).toHaveBeenCalledWith('user-1', {
password: 'correct-password',
});
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
data: { mfaEnabled: true, mfaSecret: 'encrypted' },
data: {
mfaEnabled: true,
mfaSecret: 'encrypted',
mfaLastTimeStep: null,
},
}),
);
expect(prisma.refreshToken.updateMany).toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'MFA_ENABLED' }),
);
@@ -336,11 +350,15 @@ describe('ProfileService', () => {
});
describe('disableMfa', () => {
it('throws UnauthorizedException when the current password is wrong', async () => {
prisma.user.findUnique.mockResolvedValue(baseUser);
it('needs both factors and changes nothing when the proof fails', async () => {
reauth.verify.mockRejectedValue(new UnauthorizedException());
await expect(
service.disableMfa('user-1', 'wrong-password'),
service.disableMfa('user-1', 'wrong-password', '000000'),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(reauth.verify).toHaveBeenCalledWith('user-1', {
password: 'wrong-password',
mfaCode: '000000',
});
expect(prisma.user.update).not.toHaveBeenCalled();
});
@@ -348,13 +366,14 @@ describe('ProfileService', () => {
prisma.user.findUnique.mockResolvedValue(baseUser);
prisma.user.update.mockResolvedValue({});
await service.disableMfa('user-1', 'correct-password');
await service.disableMfa('user-1', 'correct-password', '123456');
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
data: { mfaEnabled: false, mfaSecret: null },
data: { mfaEnabled: false, mfaSecret: null, mfaLastTimeStep: null },
}),
);
expect(prisma.refreshToken.updateMany).toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'MFA_DISABLED' }),
);
+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,