import { Injectable, UnauthorizedException } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import { PrismaService } from '../../prisma/prisma.service'; import { AccountGuardService } from '../mfa/account-guard.service'; export interface ReauthProof { password?: string; mfaCode?: string; } /** * Step-up check for actions that are as powerful as the account itself * (restoring a backup overwrites every user): a stolen access token alone * must not be enough, so the caller proves they know the password - and, * with MFA on, holds the second factor - right now. */ @Injectable() export class ReauthService { constructor( private readonly prisma: PrismaService, 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( 'Zu viele Fehlversuche. Bestätigen ist für 15 Minuten gesperrt.', ); } const passwordMatches = typeof proof.password === 'string' && (await bcrypt.compare(proof.password, user.passwordHash)); if (!passwordMatches) { await this.accountGuard.recordFailure(user.id); throw new UnauthorizedException('Das Passwort stimmt nicht.'); } if (user.mfaEnabled) { const codeMatches = typeof proof.mfaCode === 'string' && (await this.accountGuard.consumeTotp(user, proof.mfaCode)); if (!codeMatches) { await this.accountGuard.recordFailure(user.id); throw new UnauthorizedException('Der Code stimmt nicht.'); } } await this.accountGuard.recordSuccess(user.id); } }