diff --git a/ApiCore-Backend/prisma/migrations/20261001100000_auth_lockout_totp_replay/migration.sql b/ApiCore-Backend/prisma/migrations/20261001100000_auth_lockout_totp_replay/migration.sql new file mode 100644 index 0000000..6bf1b8e --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261001100000_auth_lockout_totp_replay/migration.sql @@ -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'; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 3b6efcd..fb5e788 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -80,6 +80,14 @@ model User { // src/auth/mfa/mfa-crypto.util.ts), nie im Klartext in der DB. mfaEnabled Boolean @default(false) 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? // Gesetzt bei jedem Passwortwechsel; JwtAuthGuard verwirft Access-Tokens, @@ -168,6 +176,7 @@ enum AuditAction { TOKEN_REFRESHED MFA_ENABLED MFA_DISABLED + ACCOUNT_LOCKED } model AuditLog { diff --git a/ApiCore-Backend/src/auth/auth.service.spec.ts b/ApiCore-Backend/src/auth/auth.service.spec.ts index 95f22b0..2d90ce3 100644 --- a/ApiCore-Backend/src/auth/auth.service.spec.ts +++ b/ApiCore-Backend/src/auth/auth.service.spec.ts @@ -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('missing@example.com', 'x'), ).rejects.toBeInstanceOf(UnauthorizedException); - expect(auditLog.record).toHaveBeenCalledWith( - expect.objectContaining({ - action: 'LOGIN_FAILED', - entityId: 'missing@example.com', - }), - ); - 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'), diff --git a/ApiCore-Backend/src/auth/auth.service.ts b/ApiCore-Backend/src/auth/auth.service.ts index ee4f9f5..8fb20d3 100644 --- a/ApiCore-Backend/src/auth/auth.service.ts +++ b/ApiCore-Backend/src/auth/auth.service.ts @@ -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 { @@ -91,35 +106,36 @@ export class AuthService { async login(email: string, password: string): Promise { 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, diff --git a/ApiCore-Backend/src/auth/mfa/account-guard.service.ts b/ApiCore-Backend/src/auth/mfa/account-guard.service.ts new file mode 100644 index 0000000..0594151 --- /dev/null +++ b/ApiCore-Backend/src/auth/mfa/account-guard.service.ts @@ -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): boolean { + return Boolean(user.authLockedUntil && user.authLockedUntil > new Date()); + } + + async recordFailure(userId: string): Promise { + 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 { + 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 { + 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; + } +} diff --git a/ApiCore-Backend/src/auth/mfa/mfa.module.ts b/ApiCore-Backend/src/auth/mfa/mfa.module.ts index aaef36d..2b14640 100644 --- a/ApiCore-Backend/src/auth/mfa/mfa.module.ts +++ b/ApiCore-Backend/src/auth/mfa/mfa.module.ts @@ -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 {} diff --git a/ApiCore-Backend/src/auth/mfa/mfa.service.ts b/ApiCore-Backend/src/auth/mfa/mfa.service.ts index 19bad0a..caeb79e 100644 --- a/ApiCore-Backend/src/auth/mfa/mfa.service.ts +++ b/ApiCore-Backend/src/auth/mfa/mfa.service.ts @@ -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 { + return verifyTotpStep( + decryptMfaSecret(encryptedSecret), + email, + code, + afterTimeStep, + ); + } + async verifyCode( encryptedSecret: string, email: string, diff --git a/ApiCore-Backend/src/auth/mfa/totp.util.ts b/ApiCore-Backend/src/auth/mfa/totp.util.ts index 56572f9..e321446 100644 --- a/ApiCore-Backend/src/auth/mfa/totp.util.ts +++ b/ApiCore-Backend/src/auth/mfa/totp.util.ts @@ -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 { + 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, diff --git a/ApiCore-Backend/src/auth/reauth/reauth.service.ts b/ApiCore-Backend/src/auth/reauth/reauth.service.ts index cc8cf57..283497c 100644 --- a/ApiCore-Backend/src/auth/reauth/reauth.service.ts +++ b/ApiCore-Backend/src/auth/reauth/reauth.service.ts @@ -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 { 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); } } diff --git a/ApiCore-Backend/src/profile/dto/disable-mfa.dto.ts b/ApiCore-Backend/src/profile/dto/disable-mfa.dto.ts index cb7389b..637eb23 100644 --- a/ApiCore-Backend/src/profile/dto/disable-mfa.dto.ts +++ b/ApiCore-Backend/src/profile/dto/disable-mfa.dto.ts @@ -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; } diff --git a/ApiCore-Backend/src/profile/dto/mfa-confirm.dto.ts b/ApiCore-Backend/src/profile/dto/mfa-confirm.dto.ts index 2dc9498..0de338d 100644 --- a/ApiCore-Backend/src/profile/dto/mfa-confirm.dto.ts +++ b/ApiCore-Backend/src/profile/dto/mfa-confirm.dto.ts @@ -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; } diff --git a/ApiCore-Backend/src/profile/profile.controller.ts b/ApiCore-Backend/src/profile/profile.controller.ts index 9eb7589..1c0d54b 100644 --- a/ApiCore-Backend/src/profile/profile.controller.ts +++ b/ApiCore-Backend/src/profile/profile.controller.ts @@ -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, + ); } } diff --git a/ApiCore-Backend/src/profile/profile.module.ts b/ApiCore-Backend/src/profile/profile.module.ts index e26ba3a..8ebbf12 100644 --- a/ApiCore-Backend/src/profile/profile.module.ts +++ b/ApiCore-Backend/src/profile/profile.module.ts @@ -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], }) diff --git a/ApiCore-Backend/src/profile/profile.service.spec.ts b/ApiCore-Backend/src/profile/profile.service.spec.ts index 96c8577..2619679 100644 --- a/ApiCore-Backend/src/profile/profile.service.spec.ts +++ b/ApiCore-Backend/src/profile/profile.service.spec.ts @@ -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' }), ); diff --git a/ApiCore-Backend/src/profile/profile.service.ts b/ApiCore-Backend/src/profile/profile.service.ts index f60e4da..b7837e1 100644 --- a/ApiCore-Backend/src/profile/profile.service.ts +++ b/ApiCore-Backend/src/profile/profile.service.ts @@ -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 { + 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 { + // 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 { - 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 { + 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, diff --git a/ApiCore-Backend/test/account-guard.e2e-spec.ts b/ApiCore-Backend/test/account-guard.e2e-spec.ts new file mode 100644 index 0000000..8681c25 --- /dev/null +++ b/ApiCore-Backend/test/account-guard.e2e-spec.ts @@ -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; + 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); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/profile/mfa-section.spec.tsx b/ApiCore-Frontend/src/app/(app)/profile/mfa-section.spec.tsx index 4ce912f..7b87771 100644 --- a/ApiCore-Frontend/src/app/(app)/profile/mfa-section.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/profile/mfa-section.spec.tsx @@ -46,13 +46,18 @@ describe("MfaSection", () => { const codeInput = await screen.findByLabelText("Code"); await userEvent.type(codeInput, "123456"); + await userEvent.type(screen.getByLabelText("Passwort"), "correct-password"); 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()); }); - 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); const onChanged = vi.fn(); renderWithClient(); @@ -60,9 +65,10 @@ describe("MfaSection", () => { await userEvent.click(screen.getByRole("button", { name: "Deaktivieren" })); const passwordInput = await screen.findByLabelText("Passwort"); 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" })); - expect(disableMfa).toHaveBeenCalledWith("correct-password"); + expect(disableMfa).toHaveBeenCalledWith("correct-password", "654321"); await vi.waitFor(() => expect(onChanged).toHaveBeenCalled()); }); }); diff --git a/ApiCore-Frontend/src/app/(app)/profile/mfa-section.tsx b/ApiCore-Frontend/src/app/(app)/profile/mfa-section.tsx index 6d568a2..f4072a3 100644 --- a/ApiCore-Frontend/src/app/(app)/profile/mfa-section.tsx +++ b/ApiCore-Frontend/src/app/(app)/profile/mfa-section.tsx @@ -26,10 +26,17 @@ import { DialogTitle, } 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 }) { const [open, setOpen] = useState(false); const [enrollment, setEnrollment] = useState(null); const [code, setCode] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); const [error, setError] = useState(null); const startMutation = useMutation({ @@ -43,20 +50,21 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) { if (!enrollment) { throw new Error("no enrollment"); } - return confirmMfaEnrollment(enrollment.setupToken, code); + return confirmMfaEnrollment(enrollment.setupToken, code, currentPassword); }, onSuccess: () => { - toast.success("MFA aktiviert"); + toast.success("MFA aktiviert. Andere Geräte müssen sich neu anmelden."); onEnabled(); handleClose(); }, - onError: () => setError("Code ungültig."), + onError: (err) => setError(messageOf(err, "Code oder Passwort ungültig.")), }); function handleTrigger() { setError(null); setEnrollment(null); setCode(""); + setCurrentPassword(""); setOpen(true); startMutation.mutate(); } @@ -65,6 +73,7 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) { setOpen(false); setEnrollment(null); setCode(""); + setCurrentPassword(""); setError(null); } @@ -83,7 +92,8 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) { MFA einrichten - 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. {enrollment ? ( @@ -104,7 +114,18 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) { maxLength={6} required value={code} - onChange={(event) => setCode(event.target.value)} + onChange={(event) => setCode(digitsOnly(event.target.value))} + /> + +
+ + setCurrentPassword(event.target.value)} />
{error ? ( @@ -134,21 +155,23 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) { function DisableDialog({ onDisabled }: { onDisabled: () => void }) { const [open, setOpen] = useState(false); const [currentPassword, setCurrentPassword] = useState(""); + const [mfaCode, setMfaCode] = useState(""); const [error, setError] = useState(null); const disableMutation = useMutation({ - mutationFn: () => disableMfa(currentPassword), + mutationFn: () => disableMfa(currentPassword, mfaCode), onSuccess: () => { - toast.success("MFA deaktiviert"); + toast.success("MFA deaktiviert. Andere Geräte müssen sich neu anmelden."); onDisabled(); handleClose(); }, - onError: () => setError("Passwort ist falsch."), + onError: (err) => setError(messageOf(err, "Passwort oder Code ist falsch.")), }); function handleClose() { setOpen(false); setCurrentPassword(""); + setMfaCode(""); setError(null); } @@ -166,7 +189,9 @@ function DisableDialog({ onDisabled }: { onDisabled: () => void }) { MFA deaktivieren - Zur Bestätigung aktuelles Passwort eingeben. + + Zur Bestätigung aktuelles Passwort und den Code aus der Authenticator-App eingeben. +
@@ -180,6 +205,19 @@ function DisableDialog({ onDisabled }: { onDisabled: () => void }) { onChange={(event) => setCurrentPassword(event.target.value)} />
+
+ + setMfaCode(digitsOnly(event.target.value))} + /> +
{error ? (

{error} diff --git a/ApiCore-Frontend/src/app/(auth)/login/page.spec.tsx b/ApiCore-Frontend/src/app/(auth)/login/page.spec.tsx index c02a67d..7c24e9a 100644 --- a/ApiCore-Frontend/src/app/(auth)/login/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(auth)/login/page.spec.tsx @@ -64,7 +64,7 @@ describe("LoginPage", () => { await userEvent.type(screen.getByLabelText("Passwort"), "wrong-password"); 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 () => { diff --git a/ApiCore-Frontend/src/app/(auth)/login/page.tsx b/ApiCore-Frontend/src/app/(auth)/login/page.tsx index be7977f..16aeedd 100644 --- a/ApiCore-Frontend/src/app/(auth)/login/page.tsx +++ b/ApiCore-Frontend/src/app/(auth)/login/page.tsx @@ -107,7 +107,9 @@ function LoginPageContent() { router.push("/dashboard"); } } 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 { setIsSubmitting(false); } @@ -124,7 +126,9 @@ function LoginPageContent() { await verifyMfa(mfaToken, code); router.push("/dashboard"); } 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 { setIsSubmitting(false); } diff --git a/ApiCore-Frontend/src/lib/audit-log/labels.ts b/ApiCore-Frontend/src/lib/audit-log/labels.ts index 6e9e563..2a2d292 100644 --- a/ApiCore-Frontend/src/lib/audit-log/labels.ts +++ b/ApiCore-Frontend/src/lib/audit-log/labels.ts @@ -9,6 +9,7 @@ const AUDIT_ACTION_LABELS: Record = { TOKEN_REFRESHED: "Token erneuert", MFA_ENABLED: "MFA aktiviert", MFA_DISABLED: "MFA deaktiviert", + ACCOUNT_LOCKED: "Konto vorübergehend gesperrt", }; export function auditActionLabel(action: string): string { diff --git a/ApiCore-Frontend/src/lib/profile/api.ts b/ApiCore-Frontend/src/lib/profile/api.ts index 80138a5..2e54dbe 100644 --- a/ApiCore-Frontend/src/lib/profile/api.ts +++ b/ApiCore-Frontend/src/lib/profile/api.ts @@ -65,16 +65,20 @@ export function enrollMfa(): Promise { return apiFetch("/profile/mfa/enroll", { method: "POST" }); } -export function confirmMfaEnrollment(setupToken: string, code: string): Promise { +export function confirmMfaEnrollment( + setupToken: string, + code: string, + currentPassword: string, +): Promise { return apiFetch("/profile/mfa/enroll/confirm", { method: "POST", - body: JSON.stringify({ setupToken, code }), + body: JSON.stringify({ setupToken, code, currentPassword }), }); } -export function disableMfa(currentPassword: string): Promise { +export function disableMfa(currentPassword: string, mfaCode: string): Promise { return apiFetch("/profile/mfa/disable", { method: "POST", - body: JSON.stringify({ currentPassword }), + body: JSON.stringify({ currentPassword, mfaCode }), }); }