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, verifyTotpStep, } from './totp.util'; import { OneTimeTokenClaims, TOKEN_AUDIENCE, jwtSignOptions, jwtVerifyOptions, } from '../util/jwt-claims'; const SETUP_TOKEN_TTL = '10m'; const CHALLENGE_TOKEN_TTL = '5m'; const SETUP_TOKEN_PURPOSE = 'mfa-setup'; const CHALLENGE_TOKEN_PURPOSE = 'mfa-challenge'; interface SetupTokenPayload { sub: string; secret: string; purpose: typeof SETUP_TOKEN_PURPOSE; } interface ChallengeTokenPayload { sub: string; purpose: typeof CHALLENGE_TOKEN_PURPOSE; } export interface MfaEnrollment { secret: string; qrCodeSvg: string; otpauthUri: string; setupToken: string; } @Injectable() export class MfaService { constructor(private readonly jwtService: JwtService) {} async startEnrollment(userId: string, email: string): Promise { const secret = generateTotpSecret(); const otpauthUri = buildTotpUri(secret, email); const qrCodeSvg = await QRCode.toString(otpauthUri, { type: 'svg', margin: 1, width: 256, }); const setupToken = this.jwtService.sign( { sub: userId, secret, purpose: SETUP_TOKEN_PURPOSE }, jwtSignOptions(TOKEN_AUDIENCE.mfaSetup, SETUP_TOKEN_TTL), ); return { secret, qrCodeSvg, otpauthUri, setupToken }; } /** Returns the plaintext secret from a setup token once the enrollment code checks out. */ async confirmEnrollment( userId: string, setupToken: string, email: string, code: string, ): Promise<{ encryptedSecret: string }> { let payload: SetupTokenPayload; try { payload = this.jwtService.verify( setupToken, jwtVerifyOptions(TOKEN_AUDIENCE.mfaSetup), ); } catch { throw new UnauthorizedException( 'Setup-Token ungültig oder abgelaufen, MFA-Einrichtung neu starten', ); } if (payload.purpose !== SETUP_TOKEN_PURPOSE || payload.sub !== userId) { throw new UnauthorizedException('Setup-Token ungültig'); } const isValid = await verifyTotpCode(payload.secret, email, code); if (!isValid) { throw new UnauthorizedException('Code ungültig'); } return { encryptedSecret: encryptMfaSecret(payload.secret) }; } signChallengeToken(userId: string): string { return this.jwtService.sign( { sub: userId, purpose: CHALLENGE_TOKEN_PURPOSE }, jwtSignOptions(TOKEN_AUDIENCE.mfaChallenge, CHALLENGE_TOKEN_TTL), ); } verifyChallengeToken(challengeToken: string): { userId: string; claims: OneTimeTokenClaims; } { let payload: ChallengeTokenPayload & OneTimeTokenClaims; try { payload = this.jwtService.verify< ChallengeTokenPayload & OneTimeTokenClaims >(challengeToken, jwtVerifyOptions(TOKEN_AUDIENCE.mfaChallenge)); } catch { throw new UnauthorizedException( 'Anmeldung abgelaufen, bitte erneut mit E-Mail und Passwort anmelden', ); } if (payload.purpose !== CHALLENGE_TOKEN_PURPOSE) { throw new UnauthorizedException('Ungültiger Token'); } return { userId: payload.sub, claims: { jti: payload.jti, exp: payload.exp }, }; } /** 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, code: string, ): Promise { const secret = decryptMfaSecret(encryptedSecret); return verifyTotpCode(secret, email, code); } }