import { Injectable, UnauthorizedException, BadRequestException, NotFoundException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { createHash, randomInt } from 'crypto'; import * as CryptoJS from 'crypto-js'; import { authenticator } from 'otplib'; import { toDataURL } from 'qrcode'; import { nanoid } from 'nanoid'; import { EmailService } from 'src/shared/modules/email/email.service'; import { CommonService } from 'src/shared/modules/common/common.service'; import { SuperAdminCoreService } from 'src/core/super-admin-core/super-admin-core.service'; import { SuperAdminSessionCoreService } from 'src/core/super-admin-session-core/super-admin-session-core.service'; import { SuperAdminTwoFactorMethodCoreService } from 'src/core/super-admin-two-factor-method-core/super-admin-two-factor-method-core.service'; import { SuperAdminEmailTwoFactorCoreService } from 'src/core/super-admin-email-two-factor-core/super-admin-email-two-factor-core.service'; import { SuperAdminTotpTwoFactorCoreService } from 'src/core/super-admin-totp-two-factor-core/super-admin-totp-two-factor-core.service'; import { SuperAdminRecoveryCodeCoreService } from 'src/core/super-admin-recovery-code-core/super-admin-recovery-code-core.service'; import { SuperAdminTrustedDeviceCoreService } from 'src/core/super-admin-trusted-device-core/super-admin-trusted-device-core.service'; import { SuperAdminEmailTwoFactorMessages } from 'src/shared/keys/super-admin-email-two-factor.keys'; import { SuperAdminTwoFactorMethodMessages } from 'src/shared/keys/super-admin-two-factor-method.keys'; import { SuperAdminTotpTwoFactorMessages } from 'src/shared/keys/super-admin-totp-two-factor.keys'; import { SuperAdminRecoveryCodeMessages } from 'src/shared/keys/super-admin-recovery-code.keys'; import { SuperAdminMessages } from 'src/shared/keys/super-admin.keys'; import { accessTokenSignSettings, refreshTokenSignSettings, TOKEN_USER_TYPE, TOKEN_TYPE, } from 'src/shared/keys/auth.keys'; import { TWO_FACTOR_METHOD_TYPE, SESSION_STATUS } from '@prisma/client'; import { COMMON_ERROR_MESSAGES } from 'src/keys'; @Injectable() export class TwoFactorAuthService { constructor( private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly emailService: EmailService, private readonly commonService: CommonService, private readonly superAdminCoreService: SuperAdminCoreService, private readonly superAdminSessionCoreService: SuperAdminSessionCoreService, private readonly twoFactorMethodCoreService: SuperAdminTwoFactorMethodCoreService, private readonly emailTwoFactorCoreService: SuperAdminEmailTwoFactorCoreService, private readonly totpTwoFactorCoreService: SuperAdminTotpTwoFactorCoreService, private readonly recoveryCodeCoreService: SuperAdminRecoveryCodeCoreService, private readonly trustedDeviceCoreService: SuperAdminTrustedDeviceCoreService, ) {} // ==================== EMAIL 2FA ==================== async enableEmail2FA(email: string) { const superAdmin = await this.superAdminCoreService.findFirst({ where: { email, isDeleted: false }, }); if (!superAdmin) { throw new NotFoundException(SuperAdminMessages.NOT_FOUND); } // Check if already enabled const existingMethod = await this.twoFactorMethodCoreService .findFirst({ where: { superAdminId: superAdmin.id, type: TWO_FACTOR_METHOD_TYPE.EMAIL, isDeleted: false, }, }) .catch(() => null); if (existingMethod && existingMethod.isEnabled) { throw new BadRequestException( SuperAdminTwoFactorMethodMessages.ALREADY_ENABLED, ); } // Create or update 2FA method const method = existingMethod ? await this.twoFactorMethodCoreService.update({ where: { id: existingMethod.id }, data: { isEnabled: true }, }) : await this.twoFactorMethodCoreService.create({ data: { superAdminId: superAdmin.id, type: TWO_FACTOR_METHOD_TYPE.EMAIL, isEnabled: true, isPrimary: true, }, }); // Create email 2FA config if not exists const emailConfig = await this.emailTwoFactorCoreService .findFirst({ where: { methodId: method.id, isDeleted: false }, }) .catch(() => null); if (!emailConfig) { await this.emailTwoFactorCoreService.create({ data: { methodId: method.id, failedAttempts: 0, }, }); } // Update super admin await this.superAdminCoreService.update({ where: { id: superAdmin.id }, data: { isTwoFactorEnabled: true }, }); return { message: SuperAdminTwoFactorMethodMessages.ENABLE_SUCCESS, }; } async sendEmail2FACode(pending2faToken: string, request: any) { const { ipAddress, userAgent } = this.commonService.getClientInfo(request); const clientInfo = this.commonService.getClientInfo(request); // Verify pending token const payload = await this.verifyPending2FAToken(pending2faToken); const superAdmin = await this.superAdminCoreService.findUnique({ where: { id: payload.superAdminId }, }); if (!superAdmin) { throw new UnauthorizedException(COMMON_ERROR_MESSAGES.INVALID_TOKEN); } // Get email 2FA method const method = await this.twoFactorMethodCoreService.findFirst({ where: { superAdminId: superAdmin.id, type: TWO_FACTOR_METHOD_TYPE.EMAIL, isEnabled: true, isDeleted: false, }, }); if (!method) { throw new NotFoundException( SuperAdminTwoFactorMethodMessages.NOT_ENABLED, ); } const emailTwoFactor = await this.emailTwoFactorCoreService.findFirst({ where: { methodId: method.id, isDeleted: false }, }); if (!emailTwoFactor) { throw new NotFoundException(SuperAdminEmailTwoFactorMessages.NOT_FOUND); } // Generate 2FA code const code = this.generateTwoFactorCode(); const codeHash = this.hashCode(code); const expiresAt = new Date( Date.now() + this.configService.get('TWO_FA_CODE_EXPIRY_MINUTES', 10) * 60 * 1000, ); // Update email 2FA config await this.emailTwoFactorCoreService.update({ where: { id: emailTwoFactor.id }, data: { lastCodeHash: codeHash, lastCodeExpiresAt: expiresAt, lastSentAt: new Date(), failedAttempts: 0, }, }); // Build location string from geoIP data const location = clientInfo.geoLocation ? `${clientInfo.geoLocation.city || ''}, ${clientInfo.geoLocation.region || ''}, ${clientInfo.geoLocation.country || ''}` .replace(/, ,/g, ',') .replace(/^,\s*|,\s*$/g, '') || 'Unknown Location' : 'Unknown Location'; // Send email await this.emailService.send2FACode( superAdmin.email, superAdmin.name, code, ipAddress ?? undefined, userAgent ?? 'Unknown', location, ); return { message: SuperAdminEmailTwoFactorMessages.CODE_SENT, }; } async verifyEmail2FACode( pending2faToken: string, code: string, trustDevice: boolean, request: any, ) { const { ipAddress, userAgent } = this.commonService.getClientInfo(request); // Verify pending token const payload = await this.verifyPending2FAToken(pending2faToken); const superAdmin = await this.superAdminCoreService.findUnique({ where: { id: payload.superAdminId }, }); if (!superAdmin) { throw new UnauthorizedException(COMMON_ERROR_MESSAGES.INVALID_TOKEN); } // Get email 2FA config const method = await this.twoFactorMethodCoreService.findFirst({ where: { superAdminId: superAdmin.id, type: TWO_FACTOR_METHOD_TYPE.EMAIL, isEnabled: true, isDeleted: false, }, }); if (!method) { throw new NotFoundException( SuperAdminTwoFactorMethodMessages.NOT_ENABLED, ); } const emailConfig = await this.emailTwoFactorCoreService.findFirst({ where: { methodId: method.id, isDeleted: false }, }); if (!emailConfig) { throw new NotFoundException(SuperAdminEmailTwoFactorMessages.NOT_FOUND); } // Check attempts const maxAttempts = this.configService.get( 'TWO_FA_MAX_ATTEMPTS', 5, ); if (emailConfig.failedAttempts >= maxAttempts) { throw new BadRequestException( SuperAdminEmailTwoFactorMessages.TOO_MANY_ATTEMPTS, ); } // Check expiry if ( !emailConfig.lastCodeExpiresAt || emailConfig.lastCodeExpiresAt < new Date() ) { throw new BadRequestException( SuperAdminEmailTwoFactorMessages.CODE_EXPIRED, ); } // Verify code const codeHash = this.hashCode(code); if (codeHash !== emailConfig.lastCodeHash) { // Increment failed attempts await this.emailTwoFactorCoreService.update({ where: { id: emailConfig.id }, data: { failedAttempts: emailConfig.failedAttempts + 1 }, }); throw new UnauthorizedException( SuperAdminEmailTwoFactorMessages.CODE_INVALID, ); } // Code is valid - clear the code await this.emailTwoFactorCoreService.update({ where: { id: emailConfig.id }, data: { lastCodeHash: null, lastCodeExpiresAt: null, failedAttempts: 0, }, }); // Generate tokens and create session const { accessToken, refreshToken } = await this.generateTokensAndSession( superAdmin, request, ); // Handle trusted device let trustedDeviceId: string | null = null; if (trustDevice) { trustedDeviceId = await this.createTrustedDevice( superAdmin.id, ipAddress ?? 'Unknown', userAgent ?? 'Unknown', ); } return { message: SuperAdminEmailTwoFactorMessages.VERIFIED_SUCCESS, accessToken, refreshToken, ...(trustedDeviceId && { trustedDeviceId }), }; } // ==================== TOTP 2FA ==================== async initTotp2FA(email: string) { const superAdmin = await this.superAdminCoreService.findFirst({ where: { email, isDeleted: false }, }); if (!superAdmin) { throw new NotFoundException(SuperAdminMessages.NOT_FOUND); } // Check if already enabled const existingMethod = await this.twoFactorMethodCoreService .findFirst({ where: { superAdminId: superAdmin.id, type: TWO_FACTOR_METHOD_TYPE.AUTHENTICATOR, isDeleted: false, }, }) .catch(() => null); let totpTwoFactor: Awaited< ReturnType > | null = null; if (existingMethod) { totpTwoFactor = await this.totpTwoFactorCoreService .findFirst({ where: { methodId: existingMethod.id, isDeleted: false }, }) .catch(() => null); } if (totpTwoFactor?.confirmedAt) { throw new BadRequestException( SuperAdminTotpTwoFactorMessages.ALREADY_CONFIRMED, ); } // Generate secret const secret = authenticator.generateSecret(); // Build otpauth URL const otpauthUrl = authenticator.keyuri( superAdmin.email, this.configService.get('TOTP_ISSUER', 'Streamflix'), secret, ); // Encrypt secret const encryptedSecret = this.encryptTotpSecret(secret); // Create or update method let method = existingMethod; if (!method) { method = await this.twoFactorMethodCoreService.create({ data: { superAdminId: superAdmin.id, type: TWO_FACTOR_METHOD_TYPE.AUTHENTICATOR, isEnabled: false, }, }); } // Create or update TOTP config if (totpTwoFactor) { await this.totpTwoFactorCoreService.update({ where: { id: totpTwoFactor.id }, data: { secretEncrypted: encryptedSecret, confirmedAt: null }, }); } else { await this.totpTwoFactorCoreService.create({ data: { methodId: method.id, secretEncrypted: encryptedSecret, }, }); } // Generate QR code const qrCodeDataUrl = await toDataURL(otpauthUrl); return { message: SuperAdminTotpTwoFactorMessages.SETUP_SUCCESS, secret, // For manual entry otpauthUrl, qrCodeDataUrl, appName: this.configService.get( 'TOTP_APP_NAME', 'Streamflix Admin', ), }; } async verifyTotpSetup(email: string, code: string) { const superAdmin = await this.superAdminCoreService.findFirst({ where: { email, isDeleted: false }, }); if (!superAdmin) { throw new NotFoundException(SuperAdminMessages.NOT_FOUND); } // Get TOTP method const method = await this.twoFactorMethodCoreService.findFirst({ where: { superAdminId: superAdmin.id, type: TWO_FACTOR_METHOD_TYPE.AUTHENTICATOR, isDeleted: false, }, }); if (!method) { throw new NotFoundException(SuperAdminTotpTwoFactorMessages.NOT_FOUND); } const totpConfig = await this.totpTwoFactorCoreService.findFirst({ where: { methodId: method.id, isDeleted: false }, }); if (!totpConfig) { throw new NotFoundException(SuperAdminTotpTwoFactorMessages.NOT_FOUND); } if (totpConfig.confirmedAt) { throw new BadRequestException( SuperAdminTotpTwoFactorMessages.ALREADY_CONFIRMED, ); } // Decrypt secret const secret = this.decryptTotpSecret(totpConfig.secretEncrypted); // Verify code const isValid = authenticator.verify({ token: code, secret }); if (!isValid) { throw new UnauthorizedException( SuperAdminTotpTwoFactorMessages.INVALID_CODE, ); } // Confirm TOTP await this.totpTwoFactorCoreService.update({ where: { id: totpConfig.id }, data: { confirmedAt: new Date() }, }); // Enable method await this.twoFactorMethodCoreService.update({ where: { id: method.id }, data: { isEnabled: true }, }); // Update super admin await this.superAdminCoreService.update({ where: { id: superAdmin.id }, data: { isTwoFactorEnabled: true }, }); // Generate recovery codes const recoveryCodes = await this.generateRecoveryCodes(superAdmin.id); return { message: SuperAdminTotpTwoFactorMessages.VERIFIED_SUCCESS, recoveryCodes, warning: 'Save these recovery codes in a safe place. They will not be shown again!', }; } async verifyTotp2FACode( pending2faToken: string, code: string, trustDevice: boolean, request: any, ) { const { ipAddress, userAgent } = this.commonService.getClientInfo(request); // Verify pending token const payload = await this.verifyPending2FAToken(pending2faToken); const superAdmin = await this.superAdminCoreService.findUnique({ where: { id: payload.superAdminId }, }); if (!superAdmin) { throw new UnauthorizedException(COMMON_ERROR_MESSAGES.INVALID_TOKEN); } // Get TOTP method const method = await this.twoFactorMethodCoreService.findFirst({ where: { superAdminId: superAdmin.id, type: TWO_FACTOR_METHOD_TYPE.AUTHENTICATOR, isEnabled: true, isDeleted: false, }, }); if (!method) { throw new NotFoundException( SuperAdminTwoFactorMethodMessages.NOT_ENABLED, ); } const totpConfig = await this.totpTwoFactorCoreService.findFirst({ where: { methodId: method.id, isDeleted: false }, }); if (!totpConfig) { throw new NotFoundException(SuperAdminTotpTwoFactorMessages.NOT_FOUND); } if (!totpConfig.confirmedAt) { throw new BadRequestException( SuperAdminTotpTwoFactorMessages.NOT_CONFIRMED, ); } // Decrypt secret const secret = this.decryptTotpSecret(totpConfig.secretEncrypted); // Verify code const isValid = authenticator.verify({ token: code, secret }); if (!isValid) { throw new UnauthorizedException( SuperAdminTotpTwoFactorMessages.INVALID_CODE, ); } // Generate tokens and create session const tokens = await this.generateTokensAndSession(superAdmin, request); // Handle trusted device let trustedDeviceId: string | null = null; if (trustDevice) { trustedDeviceId = await this.createTrustedDevice( superAdmin.id, ipAddress ?? 'Unknown', userAgent ?? 'Unknown', ); } return { message: SuperAdminTotpTwoFactorMessages.VERIFIED_SUCCESS, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, trustedDeviceId, superAdmin: { id: superAdmin.id, email: superAdmin.email, name: superAdmin.name, }, }; } // ==================== RECOVERY CODES ==================== async verifyRecoveryCode( pending2faToken: string, code: string, trustDevice: boolean, request: any, ) { const { ipAddress, userAgent } = this.commonService.getClientInfo(request); // Verify pending token const payload = await this.verifyPending2FAToken(pending2faToken); const superAdmin = await this.superAdminCoreService.findUnique({ where: { id: payload.superAdminId }, }); if (!superAdmin) { throw new UnauthorizedException(COMMON_ERROR_MESSAGES.INVALID_TOKEN); } // Hash the code const codeHash = this.hashCode(code); // Find unused recovery code const recoveryCode = await this.recoveryCodeCoreService .findFirst({ where: { superAdminId: superAdmin.id, codeHash, usedAt: null, isDeleted: false, }, }) .catch(() => null); if (!recoveryCode) { throw new UnauthorizedException( SuperAdminRecoveryCodeMessages.INVALID_CODE, ); } // Mark as used await this.recoveryCodeCoreService.update({ where: { id: recoveryCode.id }, data: { usedAt: new Date() }, }); // Generate tokens and create session const tokens = await this.generateTokensAndSession(superAdmin, request); // Handle trusted device let trustedDeviceId: string | null = null; if (trustDevice) { trustedDeviceId = await this.createTrustedDevice( superAdmin.id, ipAddress ?? 'Unknown', userAgent ?? 'Unknown', ); } return { message: SuperAdminRecoveryCodeMessages.VERIFIED_SUCCESS, warning: SuperAdminRecoveryCodeMessages.ALREADY_USED_VALID, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, trustedDeviceId, superAdmin: { id: superAdmin.id, email: superAdmin.email, name: superAdmin.name, }, }; } async regenerateRecoveryCodes(email: string) { const superAdmin = await this.superAdminCoreService.findFirst({ where: { email, isDeleted: false }, }); if (!superAdmin) { throw new NotFoundException(SuperAdminMessages.NOT_FOUND); } // Delete old codes await this.recoveryCodeCoreService.deleteMany({ where: { superAdminId: superAdmin.id }, }); // Generate new codes const recoveryCodes = await this.generateRecoveryCodes(superAdmin.id); return { message: SuperAdminRecoveryCodeMessages.GENERATED_SUCCESS, recoveryCodes, warning: 'Save these recovery codes in a safe place. They will not be shown again!', }; } // ==================== HELPER METHODS ==================== /** * Generate a cryptographically secure random code for 2FA * Uses crypto.randomInt() for security instead of Math.random() */ private generateTwoFactorCode(): string { const codeLength = this.configService.get('TWO_FA_CODE_LENGTH', 6); const min = Math.pow(10, codeLength - 1); const max = Math.pow(10, codeLength); return randomInt(min, max).toString(); } private hashCode(code: string): string { return createHash('sha256').update(code).digest('hex'); } private encryptTotpSecret(secret: string): string { const encryptionKey = this.configService.get('TOTP_ENCRYPTION_KEY'); if (!encryptionKey) { throw new Error('TOTP_ENCRYPTION_KEY is not configured'); } return CryptoJS.AES.encrypt(secret, encryptionKey).toString(); } private decryptTotpSecret(encryptedSecret: string): string { const encryptionKey = this.configService.get('TOTP_ENCRYPTION_KEY'); if (!encryptionKey) { throw new Error('TOTP_ENCRYPTION_KEY is not configured'); } const bytes = CryptoJS.AES.decrypt(encryptedSecret, encryptionKey); return bytes.toString(CryptoJS.enc.Utf8); } private async generateRecoveryCodes(superAdminId: string): Promise { const count = this.configService.get('RECOVERY_CODES_COUNT', 10); const codeLength = this.configService.get( 'RECOVERY_CODE_LENGTH', 8, ); const segmentLength = Math.floor(codeLength / 2); const codes: string[] = []; for (let i = 0; i < count; i++) { const code = `${nanoid(segmentLength)}-${nanoid(segmentLength)}`.toUpperCase(); codes.push(code); const codeHash = this.hashCode(code); await this.recoveryCodeCoreService.create({ data: { superAdminId, codeHash, }, }); } return codes; } private async verifyPending2FAToken(token: string): Promise { try { return this.jwtService.verify(token, { secret: this.configService.get('PENDING_2FA_TOKEN_SECRET'), }); } catch { throw new UnauthorizedException(COMMON_ERROR_MESSAGES.INVALID_2FA_TOKEN); } } private async generateTokensAndSession(superAdmin: any, request: any) { // Generate tokens using auth service pattern const payload = { id: superAdmin.id, email: superAdmin.email, userType: TOKEN_USER_TYPE.SUPER_ADMIN, }; const accessToken = await this.jwtService.signAsync( { ...payload, type: TOKEN_TYPE.ACCESS }, accessTokenSignSettings, ); const refreshToken = await this.jwtService.signAsync( { ...payload, type: TOKEN_TYPE.REFRESH }, refreshTokenSignSettings, ); // Get client info const clientInfo = this.commonService.getClientInfo(request); // Create session await this.superAdminSessionCoreService.create({ data: { superAdminId: superAdmin.id, accessToken, refreshToken, ipAddress: clientInfo.ipAddress, userAgent: clientInfo.userAgent, geoIpCountry: clientInfo.geoLocation?.country || '', city: clientInfo.geoLocation?.city || '', state: clientInfo.geoLocation?.region || '', latitude: clientInfo.geoLocation?.ll?.[0] || null, longitude: clientInfo.geoLocation?.ll?.[1] || null, isFromAdmin: true, status: SESSION_STATUS.CURRENT, loginAt: new Date(), }, }); return { accessToken, refreshToken }; } /** * Create a trusted device and return a JWT token signed with TRUSTED_DEVICE_TOKEN_SECRET * This token can be stored in cookies/localStorage for device recognition */ private async createTrustedDevice( superAdminId: string, ipAddress: string, userAgent: string, ): Promise { const deviceId = nanoid(32); const expiryDays = parseInt( this.configService .get('TRUSTED_DEVICE_TOKEN_EXPIRY', '30d') .replace('d', ''), ); const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000); await this.trustedDeviceCoreService.create({ data: { superAdminId, deviceId, deviceName: userAgent || 'Unknown device', ipAddress, userAgent, expiresAt, }, }); // Create JWT token using TRUSTED_DEVICE_TOKEN_SECRET const token = await this.jwtService.signAsync( { deviceId, superAdminId, ipAddress, type: 'TRUSTED_DEVICE', }, { secret: this.configService.get('TRUSTED_DEVICE_TOKEN_SECRET'), expiresIn: `${expiryDays}d`, }, ); return token; } async get2FAStatus(email: string) { const superAdmin = await this.superAdminCoreService.findFirst({ where: { email, isDeleted: false }, }); if (!superAdmin) { throw new NotFoundException(SuperAdminMessages.NOT_FOUND); } const methods = await this.twoFactorMethodCoreService.findMany({ where: { superAdminId: superAdmin.id, isDeleted: false, }, }); const enabledMethods = methods .filter((m) => m.isEnabled) .map((m) => m.type); return { is2FAEnabled: superAdmin.isTwoFactorEnabled, enabledMethods, }; } }