import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common"; import { generateSecret, generateURI, verify } from "otplib"; import { usersRepo } from "@eden/database"; import { FieldEncryptionService } from "../crypto/field-encryption.service.js"; const ISSUER = "EDEN"; @Injectable() export class MfaService { constructor(private readonly fieldEncryption: FieldEncryptionService) {} /** Stages a new TOTP secret (not yet active — see confirmEnrollment). */ async startEnrollment(userId: string, email: string): Promise<{ secret: string; otpauthUrl: string }> { const secret = generateSecret(); await usersRepo.setMfaSecret(userId, this.fieldEncryption.encrypt(secret)); const otpauthUrl = generateURI({ issuer: ISSUER, label: email, secret }); return { secret, otpauthUrl }; } /** Verifies the first code against the staged secret and flips mfa_enabled on. */ async confirmEnrollment(userId: string, code: string): Promise { const user = await usersRepo.findUserById(userId); if (!user?.mfa_secret_encrypted) { throw new BadRequestException("Nenhum cadastro de MFA pendente para este usuário"); } if (!(await this.verifyCode(user.mfa_secret_encrypted, code))) { throw new BadRequestException("Código inválido"); } await usersRepo.confirmMfaEnrollment(userId); } /** Requires a currently-valid code — proves the factor, not just an authenticated session. */ async disable(userId: string, code: string): Promise { const user = await usersRepo.findUserById(userId); if (!user?.mfa_enabled || !user.mfa_secret_encrypted) { throw new BadRequestException("MFA não está habilitado para este usuário"); } if (!(await this.verifyCode(user.mfa_secret_encrypted, code))) { throw new UnauthorizedException("Código inválido"); } await usersRepo.disableMfa(userId); } async verifyCode(encryptedSecret: string, code: string): Promise { const secret = this.fieldEncryption.decrypt(encryptedSecret); const result = await verify({ token: code, secret }); return result.valid; } }