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

@@ -0,0 +1,5 @@
-- TOTP replay protection and per-account lockout after failed attempts.
ALTER TABLE "User" ADD COLUMN "mfaLastTimeStep" INTEGER;
ALTER TABLE "User" ADD COLUMN "failedAuthAttempts" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "User" ADD COLUMN "authLockedUntil" TIMESTAMP(3);
ALTER TYPE "AuditAction" ADD VALUE 'ACCOUNT_LOCKED';
+9
View File
@@ -80,6 +80,14 @@ model User {
// src/auth/mfa/mfa-crypto.util.ts), nie im Klartext in der DB. // src/auth/mfa/mfa-crypto.util.ts), nie im Klartext in der DB.
mfaEnabled Boolean @default(false) mfaEnabled Boolean @default(false)
mfaSecret String? mfaSecret String?
// Zuletzt angenommener TOTP-Zeitschritt: derselbe Code (oder ein aelterer)
// wird danach nie wieder akzeptiert (Replay-Schutz).
mfaLastTimeStep Int?
// Fehlversuche (Passwort, MFA-Code, Bestaetigung) seit dem letzten Erfolg;
// ab 5 ist das Konto bis authLockedUntil gesperrt.
failedAuthAttempts Int @default(0)
authLockedUntil DateTime?
avatarPath String? avatarPath String?
// Gesetzt bei jedem Passwortwechsel; JwtAuthGuard verwirft Access-Tokens, // Gesetzt bei jedem Passwortwechsel; JwtAuthGuard verwirft Access-Tokens,
@@ -168,6 +176,7 @@ enum AuditAction {
TOKEN_REFRESHED TOKEN_REFRESHED
MFA_ENABLED MFA_ENABLED
MFA_DISABLED MFA_DISABLED
ACCOUNT_LOCKED
} }
model AuditLog { model AuditLog {
+48 -20
View File
@@ -5,6 +5,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { MfaService } from './mfa/mfa.service'; import { MfaService } from './mfa/mfa.service';
import { AccountGuardService } from './mfa/account-guard.service';
describe('AuthService', () => { describe('AuthService', () => {
let authService: AuthService; let authService: AuthService;
@@ -26,7 +27,12 @@ describe('AuthService', () => {
let mfaService: { let mfaService: {
signChallengeToken: jest.Mock; signChallengeToken: jest.Mock;
verifyChallengeToken: jest.Mock; verifyChallengeToken: jest.Mock;
verifyCode: jest.Mock; };
let accountGuard: {
isLocked: jest.Mock;
recordFailure: jest.Mock;
recordSuccess: jest.Mock;
consumeTotp: jest.Mock;
}; };
const activeUser = { const activeUser = {
@@ -60,7 +66,12 @@ describe('AuthService', () => {
mfaService = { mfaService = {
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'), signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
verifyChallengeToken: jest.fn(), 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( authService = new AuthService(
@@ -70,6 +81,7 @@ describe('AuthService', () => {
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
permissionResolver as never, permissionResolver as never,
mfaService as unknown as MfaService, 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); usersService.findByEmail.mockResolvedValue(null);
await expect( await expect(
authService.login('[email protected]', 'x'), authService.login('[email protected]', 'x'),
).rejects.toBeInstanceOf(UnauthorizedException); ).rejects.toBeInstanceOf(UnauthorizedException);
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).not.toHaveBeenCalled();
expect.objectContaining({ });
action: 'LOGIN_FAILED',
entityId: '[email protected]', it('counts a wrong password against the account', async () => {
}), usersService.findByEmail.mockResolvedValue(activeUser);
); await expect(
const [[recordArg]] = auditLog.record.mock.calls as [ authService.login(activeUser.email, 'wrong-password'),
[{ userId?: string }], ).rejects.toBeInstanceOf(UnauthorizedException);
]; expect(accountGuard.recordFailure).toHaveBeenCalledWith(activeUser.id);
expect(recordArg).not.toHaveProperty('userId'); });
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 () => { it('issues tokens and records LOGIN when the code is valid', async () => {
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id }); mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
usersService.findById.mockResolvedValue(mfaUser); usersService.findById.mockResolvedValue(mfaUser);
mfaService.verifyCode.mockResolvedValue(true); accountGuard.consumeTotp.mockResolvedValue(true);
prisma.refreshToken.create.mockResolvedValue({}); prisma.refreshToken.create.mockResolvedValue({});
const result = await authService.verifyMfaAndLogin( const result = await authService.verifyMfaAndLogin(
@@ -194,11 +225,8 @@ describe('AuthService', () => {
); );
expect(result.accessToken).toBe('signed.jwt.token'); expect(result.accessToken).toBe('signed.jwt.token');
expect(mfaService.verifyCode).toHaveBeenCalledWith( expect(accountGuard.consumeTotp).toHaveBeenCalledWith(mfaUser, '123456');
mfaUser.mfaSecret, expect(accountGuard.recordSuccess).toHaveBeenCalledWith(mfaUser.id);
mfaUser.email,
'123456',
);
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'LOGIN', userId: mfaUser.id }), 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 () => { it('throws and records LOGIN_FAILED when the code is invalid', async () => {
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id }); mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
usersService.findById.mockResolvedValue(mfaUser); usersService.findById.mockResolvedValue(mfaUser);
mfaService.verifyCode.mockResolvedValue(false); accountGuard.consumeTotp.mockResolvedValue(false);
await expect( await expect(
authService.verifyMfaAndLogin('mfa-challenge-token', '000000'), 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 { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from './permissions/permission-resolver.service'; import { PermissionResolverService } from './permissions/permission-resolver.service';
import { MfaService } from './mfa/mfa.service'; import { MfaService } from './mfa/mfa.service';
import { AccountGuardService } from './mfa/account-guard.service';
import { parseDurationToMs } from './util/duration'; import { parseDurationToMs } from './util/duration';
import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims'; import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims';
@@ -32,6 +33,19 @@ export interface CurrentUserProfile {
const REFRESH_TOKEN_BYTES = 40; 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() @Injectable()
export class AuthService { export class AuthService {
private readonly logger = new Logger(AuthService.name); private readonly logger = new Logger(AuthService.name);
@@ -43,6 +57,7 @@ export class AuthService {
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService, private readonly permissionResolver: PermissionResolverService,
private readonly mfaService: MfaService, private readonly mfaService: MfaService,
private readonly accountGuard: AccountGuardService,
) {} ) {}
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> { async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
@@ -91,35 +106,36 @@ export class AuthService {
async login(email: string, password: string): Promise<LoginResult> { async login(email: string, password: string): Promise<LoginResult> {
const user = await this.usersService.findByEmail(email); 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) { if (!user) {
this.logger.warn(`Login failed: no user with email ${email}`); this.logger.warn('Login failed: unknown email');
await this.auditLog.record({ throw new UnauthorizedException(INVALID_CREDENTIALS);
entityType: 'AuthSession',
entityId: email,
action: 'LOGIN_FAILED',
});
throw new UnauthorizedException('Invalid credentials');
} }
if (!user.isActive) { if (!user.isActive || this.accountGuard.isLocked(user)) {
this.logger.warn(`Login failed: user ${user.id} is deactivated`); this.logger.warn(`Login refused: user ${user.id} inactive or locked`);
await this.auditLog.record({ await this.auditLog.record({
entityType: 'AuthSession', entityType: 'AuthSession',
entityId: user.id, entityId: user.id,
action: 'LOGIN_FAILED', action: 'LOGIN_FAILED',
userId: user.id, userId: user.id,
}); });
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException(INVALID_CREDENTIALS);
} }
const passwordMatches = await bcrypt.compare(password, user.passwordHash);
if (!passwordMatches) { if (!passwordMatches) {
this.logger.warn(`Login failed: wrong password for user ${user.id}`); this.logger.warn(`Login failed: wrong password for user ${user.id}`);
await this.accountGuard.recordFailure(user.id);
await this.auditLog.record({ await this.auditLog.record({
entityType: 'AuthSession', entityType: 'AuthSession',
entityId: user.id, entityId: user.id,
action: 'LOGIN_FAILED', action: 'LOGIN_FAILED',
userId: user.id, userId: user.id,
}); });
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException(INVALID_CREDENTIALS);
} }
if (user.mfaEnabled) { if (user.mfaEnabled) {
return { return {
@@ -133,6 +149,7 @@ export class AuthService {
}; };
const accessToken = this.signAccessToken(authenticatedUser); const accessToken = this.signAccessToken(authenticatedUser);
const refreshToken = await this.issueRefreshToken(user.id); const refreshToken = await this.issueRefreshToken(user.id);
await this.accountGuard.recordSuccess(user.id);
await this.auditLog.record({ await this.auditLog.record({
entityType: 'AuthSession', entityType: 'AuthSession',
entityId: user.id, entityId: user.id,
@@ -180,15 +197,15 @@ export class AuthService {
if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) { if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) {
throw new UnauthorizedException('MFA nicht verfügbar'); throw new UnauthorizedException('MFA nicht verfügbar');
} }
const isValid = await this.mfaService.verifyCode( if (this.accountGuard.isLocked(user)) {
user.mfaSecret, throw new UnauthorizedException(INVALID_CREDENTIALS);
user.email, }
code, const isValid = await this.accountGuard.consumeTotp(user, code);
);
if (!isValid) { if (!isValid) {
this.logger.warn( 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({ await this.auditLog.record({
entityType: 'AuthSession', entityType: 'AuthSession',
entityId: user.id, entityId: user.id,
@@ -197,6 +214,7 @@ export class AuthService {
}); });
throw new UnauthorizedException('Code ungültig'); throw new UnauthorizedException('Code ungültig');
} }
await this.accountGuard.recordSuccess(user.id);
const authenticatedUser = { const authenticatedUser = {
id: user.id, id: user.id,
email: user.email, 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 { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { MfaService } from './mfa.service'; import { MfaService } from './mfa.service';
import { AccountGuardService } from './account-guard.service';
@Module({ @Module({
imports: [JwtModule.register({})], imports: [JwtModule.register({})],
providers: [MfaService], providers: [MfaService, AccountGuardService],
exports: [MfaService], exports: [MfaService, AccountGuardService],
}) })
export class MfaModule {} export class MfaModule {}
+21 -1
View File
@@ -2,7 +2,12 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as QRCode from 'qrcode'; import * as QRCode from 'qrcode';
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util'; import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
import { buildTotpUri, generateTotpSecret, verifyTotpCode } from './totp.util'; import {
buildTotpUri,
generateTotpSecret,
verifyTotpCode,
verifyTotpStep,
} from './totp.util';
import { import {
TOKEN_AUDIENCE, TOKEN_AUDIENCE,
jwtSignOptions, jwtSignOptions,
@@ -104,6 +109,21 @@ export class MfaService {
return { userId: payload.sub }; 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( async verifyCode(
encryptedSecret: string, encryptedSecret: string,
email: string, email: string,
+17
View File
@@ -20,6 +20,23 @@ export function buildTotpUri(secret: string, label: string): string {
return createTotp(secret, label).toURI(); 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( export async function verifyTotpCode(
secret: string, secret: string,
label: string, label: string,
@@ -1,7 +1,7 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { MfaService } from '../mfa/mfa.service'; import { AccountGuardService } from '../mfa/account-guard.service';
export interface ReauthProof { export interface ReauthProof {
password?: string; password?: string;
@@ -18,25 +18,33 @@ export interface ReauthProof {
export class ReauthService { export class ReauthService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly mfaService: MfaService, private readonly accountGuard: AccountGuardService,
) {} ) {}
async verify(userId: string, proof: ReauthProof): Promise<void> { async verify(userId: string, proof: ReauthProof): Promise<void> {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { select: {
id: true,
email: true, email: true,
passwordHash: true, passwordHash: true,
mfaEnabled: true, mfaEnabled: true,
mfaSecret: true, mfaSecret: true,
mfaLastTimeStep: true,
authLockedUntil: true,
isActive: 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 = const passwordMatches =
Boolean(user?.isActive) &&
typeof proof.password === 'string' && typeof proof.password === 'string' &&
(await bcrypt.compare(proof.password, user!.passwordHash)); (await bcrypt.compare(proof.password, user.passwordHash));
if (!user || !passwordMatches) { if (!passwordMatches) {
await this.accountGuard.recordFailure(user.id);
throw new UnauthorizedException( throw new UnauthorizedException(
'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben', 'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben',
); );
@@ -44,17 +52,14 @@ export class ReauthService {
if (user.mfaEnabled) { if (user.mfaEnabled) {
const codeMatches = const codeMatches =
typeof proof.mfaCode === 'string' && typeof proof.mfaCode === 'string' &&
user.mfaSecret !== null && (await this.accountGuard.consumeTotp(user, proof.mfaCode));
(await this.mfaService.verifyCode(
user.mfaSecret,
user.email,
proof.mfaCode,
));
if (!codeMatches) { if (!codeMatches) {
await this.accountGuard.recordFailure(user.id);
throw new UnauthorizedException( throw new UnauthorizedException(
'Bitte zur Bestätigung den aktuellen Code aus Ihrer Authenticator-App eingeben', '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 { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator'; import { IsString, Matches } from 'class-validator';
export class DisableMfaDto { export class DisableMfaDto {
@ApiProperty() @ApiProperty()
@IsString() @IsString()
currentPassword!: string; 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() @IsString()
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' }) @Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
code!: string; code!: string;
@ApiProperty({ description: 'Aktuelles Passwort zur Bestätigung' })
@IsString()
@MinLength(1)
currentPassword!: string;
} }
@@ -114,6 +114,7 @@ export class ProfileController {
actor.email, actor.email,
dto.setupToken, dto.setupToken,
dto.code, dto.code,
dto.currentPassword,
); );
} }
@@ -124,6 +125,10 @@ export class ProfileController {
@Body() dto: DisableMfaDto, @Body() dto: DisableMfaDto,
@CurrentUser() actor: AuthenticatedUser, @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 { Module } from '@nestjs/common';
import { MfaModule } from '../auth/mfa/mfa.module'; import { MfaModule } from '../auth/mfa/mfa.module';
import { ReauthModule } from '../auth/reauth/reauth.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { ProfileController } from './profile.controller'; import { ProfileController } from './profile.controller';
import { ProfileService } from './profile.service'; import { ProfileService } from './profile.service';
@Module({ @Module({
imports: [MfaModule, UsersModule], imports: [MfaModule, ReauthModule, UsersModule],
controllers: [ProfileController], controllers: [ProfileController],
providers: [ProfileService], providers: [ProfileService],
}) })
@@ -10,6 +10,7 @@ import { ProfileService } from './profile.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { MfaService } from '../auth/mfa/mfa.service'; import { MfaService } from '../auth/mfa/mfa.service';
import { ReauthService } from '../auth/reauth/reauth.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
@@ -43,6 +44,8 @@ describe('ProfileService', () => {
mfaSecret: null as string | null, mfaSecret: null as string | null,
}; };
let reauth: { verify: jest.Mock };
beforeEach(async () => { beforeEach(async () => {
baseUser.passwordHash = await bcrypt.hash('correct-password', 4); baseUser.passwordHash = await bcrypt.hash('correct-password', 4);
baseUser.avatarPath = null; baseUser.avatarPath = null;
@@ -64,12 +67,14 @@ describe('ProfileService', () => {
mfaService = { startEnrollment: jest.fn(), confirmEnrollment: jest.fn() }; mfaService = { startEnrollment: jest.fn(), confirmEnrollment: jest.fn() };
permissionResolver = { invalidateUser: jest.fn() }; permissionResolver = { invalidateUser: jest.fn() };
usersService = { deactivate: jest.fn().mockResolvedValue(undefined) }; usersService = { deactivate: jest.fn().mockResolvedValue(undefined) };
reauth = { verify: jest.fn().mockResolvedValue(undefined) };
service = new ProfileService( service = new ProfileService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
mfaService as unknown as MfaService, mfaService as unknown as MfaService,
permissionResolver as unknown as PermissionResolverService, permissionResolver as unknown as PermissionResolverService,
usersService as unknown as UsersService, usersService as unknown as UsersService,
reauth as unknown as ReauthService,
); );
(fs.mkdir as jest.Mock).mockResolvedValue(undefined); (fs.mkdir as jest.Mock).mockResolvedValue(undefined);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined); (fs.writeFile as jest.Mock).mockResolvedValue(undefined);
@@ -322,13 +327,22 @@ describe('ProfileService', () => {
baseUser.email, baseUser.email,
'setup-token', 'setup-token',
'123456', '123456',
'correct-password',
); );
expect(reauth.verify).toHaveBeenCalledWith('user-1', {
password: 'correct-password',
});
expect(prisma.user.update).toHaveBeenCalledWith( expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
data: { mfaEnabled: true, mfaSecret: 'encrypted' }, data: {
mfaEnabled: true,
mfaSecret: 'encrypted',
mfaLastTimeStep: null,
},
}), }),
); );
expect(prisma.refreshToken.updateMany).toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'MFA_ENABLED' }), expect.objectContaining({ action: 'MFA_ENABLED' }),
); );
@@ -336,11 +350,15 @@ describe('ProfileService', () => {
}); });
describe('disableMfa', () => { describe('disableMfa', () => {
it('throws UnauthorizedException when the current password is wrong', async () => { it('needs both factors and changes nothing when the proof fails', async () => {
prisma.user.findUnique.mockResolvedValue(baseUser); reauth.verify.mockRejectedValue(new UnauthorizedException());
await expect( await expect(
service.disableMfa('user-1', 'wrong-password'), service.disableMfa('user-1', 'wrong-password', '000000'),
).rejects.toBeInstanceOf(UnauthorizedException); ).rejects.toBeInstanceOf(UnauthorizedException);
expect(reauth.verify).toHaveBeenCalledWith('user-1', {
password: 'wrong-password',
mfaCode: '000000',
});
expect(prisma.user.update).not.toHaveBeenCalled(); expect(prisma.user.update).not.toHaveBeenCalled();
}); });
@@ -348,13 +366,14 @@ describe('ProfileService', () => {
prisma.user.findUnique.mockResolvedValue(baseUser); prisma.user.findUnique.mockResolvedValue(baseUser);
prisma.user.update.mockResolvedValue({}); 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(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
data: { mfaEnabled: false, mfaSecret: null }, data: { mfaEnabled: false, mfaSecret: null, mfaLastTimeStep: null },
}), }),
); );
expect(prisma.refreshToken.updateMany).toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'MFA_DISABLED' }), 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 { AuditLogService } from '../audit/audit-log.service';
import { matchesFileSignature } from '../common/file-signature'; import { matchesFileSignature } from '../common/file-signature';
import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service'; import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service';
import { ReauthService } from '../auth/reauth/reauth.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { import {
@@ -45,8 +46,17 @@ export class ProfileService {
private readonly mfaService: MfaService, private readonly mfaService: MfaService,
private readonly permissionResolver: PermissionResolverService, private readonly permissionResolver: PermissionResolverService,
private readonly usersService: UsersService, 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) { private async requireUser(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } }); const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
@@ -230,7 +240,11 @@ export class ProfileService {
email: string, email: string,
setupToken: string, setupToken: string,
code: string, code: string,
currentPassword: string,
): Promise<void> { ): 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( const { encryptedSecret } = await this.mfaService.confirmEnrollment(
userId, userId,
setupToken, setupToken,
@@ -239,8 +253,13 @@ export class ProfileService {
); );
await this.prisma.user.update({ await this.prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { mfaEnabled: true, mfaSecret: encryptedSecret }, data: {
mfaEnabled: true,
mfaSecret: encryptedSecret,
mfaLastTimeStep: null,
},
}); });
await this.revokeSessions(userId);
await this.auditLog.record({ await this.auditLog.record({
entityType: 'User', entityType: 'User',
entityId: userId, entityId: userId,
@@ -249,19 +268,21 @@ export class ProfileService {
}); });
} }
async disableMfa(userId: string, currentPassword: string): Promise<void> { /**
const user = await this.requireUser(userId); * Turning MFA off needs both factors: with the password alone, MFA would
const currentMatches = await bcrypt.compare( * protect nothing against someone who already knows it.
currentPassword, */
user.passwordHash, async disableMfa(
); userId: string,
if (!currentMatches) { currentPassword: string,
throw new UnauthorizedException('Aktuelles Passwort ist falsch'); mfaCode: string,
} ): Promise<void> {
await this.reauth.verify(userId, { password: currentPassword, mfaCode });
await this.prisma.user.update({ await this.prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { mfaEnabled: false, mfaSecret: null }, data: { mfaEnabled: false, mfaSecret: null, mfaLastTimeStep: null },
}); });
await this.revokeSessions(userId);
await this.auditLog.record({ await this.auditLog.record({
entityType: 'User', entityType: 'User',
entityId: userId, entityId: userId,
@@ -0,0 +1,94 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { NobleCryptoPlugin, ScureBase32Plugin, TOTP } from 'otplib';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { encryptMfaSecret } from '../src/auth/mfa/mfa-crypto.util';
import { generateTotpSecret } from '../src/auth/mfa/totp.util';
import { createTestApp } from './utils/create-test-app';
/**
* Account-level brute-force protection and one-time TOTP codes, against a
* real database. Each test uses its own account, because the per-IP
* throttle (10/min on login) is shared by the whole suite.
*/
describe('Account guard (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
const login = (email: string, pw: string) =>
request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ email, password: pw });
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
});
afterAll(async () => {
await app.close();
});
it('locks an account after five wrong passwords, even for the right one', async () => {
const email = `lockout-${suffix}@example.com`;
await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 4),
name: 'Lockout',
role: { connect: { key: 'READONLY' } },
},
});
for (let i = 0; i < 5; i++) {
await login(email, 'wrong-password-123').expect(401);
}
const locked = await login(email, password).expect(401);
const wrong = await login(`nobody-${suffix}@example.com`, password).expect(
401,
);
// Same message for "locked" and "no such account".
expect((locked.body as { message: string }).message).toBe(
(wrong.body as { message: string }).message,
);
const stored = await prisma.user.findUniqueOrThrow({ where: { email } });
expect(stored.authLockedUntil?.getTime()).toBeGreaterThan(Date.now());
});
it('accepts a TOTP code only once', async () => {
const email = `totp-${suffix}@example.com`;
const secret = generateTotpSecret();
await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 4),
name: 'TOTP',
mfaEnabled: true,
mfaSecret: encryptMfaSecret(secret),
role: { connect: { key: 'READONLY' } },
},
});
const code = await new TOTP({
secret,
crypto: new NobleCryptoPlugin(),
base32: new ScureBase32Plugin(),
}).generate();
const first = await login(email, password).expect(200);
await request(app.getHttpServer())
.post('/api/v1/auth/mfa/verify')
.send({ mfaToken: (first.body as { mfaToken: string }).mfaToken, code })
.expect(200);
const second = await login(email, password).expect(200);
await request(app.getHttpServer())
.post('/api/v1/auth/mfa/verify')
.send({ mfaToken: (second.body as { mfaToken: string }).mfaToken, code })
.expect(401);
});
});
@@ -46,13 +46,18 @@ describe("MfaSection", () => {
const codeInput = await screen.findByLabelText("Code"); const codeInput = await screen.findByLabelText("Code");
await userEvent.type(codeInput, "123456"); await userEvent.type(codeInput, "123456");
await userEvent.type(screen.getByLabelText("Passwort"), "correct-password");
await userEvent.click(screen.getByRole("button", { name: "Bestätigen" })); await userEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
expect(confirmMfaEnrollment).toHaveBeenCalledWith("setup-token", "123456"); expect(confirmMfaEnrollment).toHaveBeenCalledWith(
"setup-token",
"123456",
"correct-password",
);
await vi.waitFor(() => expect(onChanged).toHaveBeenCalled()); await vi.waitFor(() => expect(onChanged).toHaveBeenCalled());
}); });
it("disables MFA after confirming the current password", async () => { it("disables MFA after confirming the current password and a code", async () => {
vi.mocked(disableMfa).mockResolvedValueOnce(undefined); vi.mocked(disableMfa).mockResolvedValueOnce(undefined);
const onChanged = vi.fn(); const onChanged = vi.fn();
renderWithClient(<MfaSection mfaEnabled onChanged={onChanged} />); renderWithClient(<MfaSection mfaEnabled onChanged={onChanged} />);
@@ -60,9 +65,10 @@ describe("MfaSection", () => {
await userEvent.click(screen.getByRole("button", { name: "Deaktivieren" })); await userEvent.click(screen.getByRole("button", { name: "Deaktivieren" }));
const passwordInput = await screen.findByLabelText("Passwort"); const passwordInput = await screen.findByLabelText("Passwort");
await userEvent.type(passwordInput, "correct-password"); await userEvent.type(passwordInput, "correct-password");
await userEvent.type(screen.getByLabelText("Code aus der Authenticator-App"), "654321");
await userEvent.click(screen.getByRole("button", { name: "Deaktivieren" })); await userEvent.click(screen.getByRole("button", { name: "Deaktivieren" }));
expect(disableMfa).toHaveBeenCalledWith("correct-password"); expect(disableMfa).toHaveBeenCalledWith("correct-password", "654321");
await vi.waitFor(() => expect(onChanged).toHaveBeenCalled()); await vi.waitFor(() => expect(onChanged).toHaveBeenCalled());
}); });
}); });
@@ -26,10 +26,17 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
function messageOf(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback;
}
const digitsOnly = (value: string) => value.replace(/\D/g, "").slice(0, 6);
function EnrollDialog({ onEnabled }: { onEnabled: () => void }) { function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [enrollment, setEnrollment] = useState<MfaEnrollment | null>(null); const [enrollment, setEnrollment] = useState<MfaEnrollment | null>(null);
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const startMutation = useMutation({ const startMutation = useMutation({
@@ -43,20 +50,21 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
if (!enrollment) { if (!enrollment) {
throw new Error("no enrollment"); throw new Error("no enrollment");
} }
return confirmMfaEnrollment(enrollment.setupToken, code); return confirmMfaEnrollment(enrollment.setupToken, code, currentPassword);
}, },
onSuccess: () => { onSuccess: () => {
toast.success("MFA aktiviert"); toast.success("MFA aktiviert. Andere Geräte müssen sich neu anmelden.");
onEnabled(); onEnabled();
handleClose(); handleClose();
}, },
onError: () => setError("Code ungültig."), onError: (err) => setError(messageOf(err, "Code oder Passwort ungültig.")),
}); });
function handleTrigger() { function handleTrigger() {
setError(null); setError(null);
setEnrollment(null); setEnrollment(null);
setCode(""); setCode("");
setCurrentPassword("");
setOpen(true); setOpen(true);
startMutation.mutate(); startMutation.mutate();
} }
@@ -65,6 +73,7 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
setOpen(false); setOpen(false);
setEnrollment(null); setEnrollment(null);
setCode(""); setCode("");
setCurrentPassword("");
setError(null); setError(null);
} }
@@ -83,7 +92,8 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
<DialogHeader> <DialogHeader>
<DialogTitle>MFA einrichten</DialogTitle> <DialogTitle>MFA einrichten</DialogTitle>
<DialogDescription> <DialogDescription>
QR-Code mit deiner Authenticator-App scannen, dann den angezeigten Code eingeben. 1. QR-Code mit deiner Authenticator-App scannen. 2. Den angezeigten Code und dein
Passwort eingeben.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{enrollment ? ( {enrollment ? (
@@ -104,7 +114,18 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
maxLength={6} maxLength={6}
required required
value={code} value={code}
onChange={(event) => setCode(event.target.value)} onChange={(event) => setCode(digitsOnly(event.target.value))}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="mfa-enroll-password">Passwort</Label>
<Input
id="mfa-enroll-password"
type="password"
autoComplete="current-password"
required
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
/> />
</div> </div>
{error ? ( {error ? (
@@ -134,21 +155,23 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
function DisableDialog({ onDisabled }: { onDisabled: () => void }) { function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [mfaCode, setMfaCode] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const disableMutation = useMutation({ const disableMutation = useMutation({
mutationFn: () => disableMfa(currentPassword), mutationFn: () => disableMfa(currentPassword, mfaCode),
onSuccess: () => { onSuccess: () => {
toast.success("MFA deaktiviert"); toast.success("MFA deaktiviert. Andere Geräte müssen sich neu anmelden.");
onDisabled(); onDisabled();
handleClose(); handleClose();
}, },
onError: () => setError("Passwort ist falsch."), onError: (err) => setError(messageOf(err, "Passwort oder Code ist falsch.")),
}); });
function handleClose() { function handleClose() {
setOpen(false); setOpen(false);
setCurrentPassword(""); setCurrentPassword("");
setMfaCode("");
setError(null); setError(null);
} }
@@ -166,7 +189,9 @@ function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>MFA deaktivieren</DialogTitle> <DialogTitle>MFA deaktivieren</DialogTitle>
<DialogDescription>Zur Bestätigung aktuelles Passwort eingeben.</DialogDescription> <DialogDescription>
Zur Bestätigung aktuelles Passwort und den Code aus der Authenticator-App eingeben.
</DialogDescription>
</DialogHeader> </DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -180,6 +205,19 @@ function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
onChange={(event) => setCurrentPassword(event.target.value)} onChange={(event) => setCurrentPassword(event.target.value)}
/> />
</div> </div>
<div className="flex flex-col gap-2">
<Label htmlFor="disable-mfa-code">Code aus der Authenticator-App</Label>
<Input
id="disable-mfa-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
required
value={mfaCode}
onChange={(event) => setMfaCode(digitsOnly(event.target.value))}
/>
</div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-destructive"> <p role="alert" className="text-sm text-destructive">
{error} {error}
@@ -64,7 +64,7 @@ describe("LoginPage", () => {
await userEvent.type(screen.getByLabelText("Passwort"), "wrong-password"); await userEvent.type(screen.getByLabelText("Passwort"), "wrong-password");
await userEvent.click(screen.getByRole("button", { name: /anmelden/i })); await userEvent.click(screen.getByRole("button", { name: /anmelden/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(/fehlgeschlagen/i); expect(await screen.findByRole("alert")).toHaveTextContent(/E-Mail oder Passwort falsch/i);
}); });
it("shows the code step and verifies MFA when required", async () => { it("shows the code step and verifies MFA when required", async () => {
@@ -107,7 +107,9 @@ function LoginPageContent() {
router.push("/dashboard"); router.push("/dashboard");
} }
} catch { } catch {
setError("Login fehlgeschlagen. Bitte E-Mail und Passwort prüfen."); setError(
"E-Mail oder Passwort falsch. Nach 5 Fehlversuchen ist die Anmeldung für 15 Minuten gesperrt.",
);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -124,7 +126,9 @@ function LoginPageContent() {
await verifyMfa(mfaToken, code); await verifyMfa(mfaToken, code);
router.push("/dashboard"); router.push("/dashboard");
} catch { } catch {
setError("Code ungültig. Bitte erneut versuchen."); setError(
"Code ungültig oder schon benutzt. Bitte den nächsten Code aus der App eingeben.",
);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -9,6 +9,7 @@ const AUDIT_ACTION_LABELS: Record<string, string> = {
TOKEN_REFRESHED: "Token erneuert", TOKEN_REFRESHED: "Token erneuert",
MFA_ENABLED: "MFA aktiviert", MFA_ENABLED: "MFA aktiviert",
MFA_DISABLED: "MFA deaktiviert", MFA_DISABLED: "MFA deaktiviert",
ACCOUNT_LOCKED: "Konto vorübergehend gesperrt",
}; };
export function auditActionLabel(action: string): string { export function auditActionLabel(action: string): string {
+8 -4
View File
@@ -65,16 +65,20 @@ export function enrollMfa(): Promise<MfaEnrollment> {
return apiFetch<MfaEnrollment>("/profile/mfa/enroll", { method: "POST" }); return apiFetch<MfaEnrollment>("/profile/mfa/enroll", { method: "POST" });
} }
export function confirmMfaEnrollment(setupToken: string, code: string): Promise<void> { export function confirmMfaEnrollment(
setupToken: string,
code: string,
currentPassword: string,
): Promise<void> {
return apiFetch<void>("/profile/mfa/enroll/confirm", { return apiFetch<void>("/profile/mfa/enroll/confirm", {
method: "POST", method: "POST",
body: JSON.stringify({ setupToken, code }), body: JSON.stringify({ setupToken, code, currentPassword }),
}); });
} }
export function disableMfa(currentPassword: string): Promise<void> { export function disableMfa(currentPassword: string, mfaCode: string): Promise<void> {
return apiFetch<void>("/profile/mfa/disable", { return apiFetch<void>("/profile/mfa/disable", {
method: "POST", method: "POST",
body: JSON.stringify({ currentPassword }), body: JSON.stringify({ currentPassword, mfaCode }),
}); });
} }