// otp.service.ts import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { OtpRepository } from "./otp.repository"; import { SmsClientService } from "../notifications/sms-client.service"; import { EmailClientService } from "../notifications/email-client.service"; // Exactly one of phone/email is set — enforced by the controller before it // reaches here. export type OtpTarget = { phone?: string; email?: string }; @Injectable() export class OtpService { logger = new Logger(OtpService.name); constructor( private readonly otpRepository: OtpRepository, private readonly smsClient: SmsClientService, private readonly emailClient: EmailClientService, ) { } // --------------------------------------------------------------------------- // Generate OTP // --------------------------------------------------------------------------- generateOtp(): string { return Math.floor(100000 + Math.random() * 900000).toString(); } // --------------------------------------------------------------------------- // Send OTP // --------------------------------------------------------------------------- async sendOtp(target: OtpTarget) { try { // The verification code is generated server-side — never supplied by the // caller — so the OTP stays a secret known only to the server and the // recipient of the SMS/email. const otp = this.generateOtp(); // find existing row for this channel const existing = await this.otpRepository.findByTarget(target); // update existing otp if (existing) { await this.otpRepository.updateOtp(existing, otp); } else { // create new otp await this.otpRepository.createOtp(target, otp); } // A freshly issued code gets a fresh guess budget. this.actionAttempts.delete(this.targetKey(target)); if (target.email) { // send email (queued to RabbitMQ via the shared Email service) await this.emailClient.sendEmail({ to: target.email, subject: "Your EDR Freight verification code", text: `Your verification code is ${otp}`, }); } else { // send sms (queued to RabbitMQ via the shared SMS service) await this.smsClient.sendSms({ to: target.phone as string, message: `Your verification code is ${otp}`, }); } this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`); return { success: true, message: "OTP sent successfully", }; } catch (error) { // Log the real cause (DB/SMS/email failure) with its stack so a deployed // "Failed to send OTP" 400 is diagnosable from the API logs, not opaque. this.logger.error( `Failed to send OTP to ${target.email ?? target.phone}: ${ error instanceof Error ? error.message : String(error) }`, error instanceof Error ? error.stack : undefined, ); throw new BadRequestException("Failed to send OTP"); } } // --------------------------------------------------------------------------- // Verify OTP // --------------------------------------------------------------------------- async verifyOtp(target: OtpTarget, otp: string) { // find the channel's row const otpData = await this.otpRepository.findByTarget(target); // not found if (!otpData) { throw new BadRequestException( target.email ? "Email address not found" : "Phone number not found", ); } // invalid otp if (otpData.otp !== otp) { throw new BadRequestException("Invalid OTP"); } // mark verified await this.otpRepository.markVerified(otpData); return { success: true, message: target.email ? "Email verified successfully" : "Phone verified successfully", }; } // --------------------------------------------------------------------------- // Verify OTP for a sensitive action (sudo mode) // --------------------------------------------------------------------------- // Fresh, single-use challenge gating a sensitive action (e.g. applying a // contract signature, resetting a forgotten password). Unlike verifyOtp above // — which marks a target verified and leaves the code in place — this enforces // a TTL and consumes the code on success so it can never be replayed. private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000; // Without a cap, a 6-digit code guarding a password reset is brute-forceable // within its own TTL. `otp_verifications` has no attempt column, so the // counter lives here and the code is burned once the budget is spent. // Per-process: it resets on restart and is not shared across replicas — a // persisted counter needs a migration on OtpVerification. private readonly MAX_ACTION_ATTEMPTS = 5; private readonly actionAttempts = new Map(); private targetKey(target: OtpTarget): string { return target.email ? `email:${target.email}` : `phone:${target.phone}`; } async verifyOtpForAction( target: OtpTarget, otp: string, ttlMs: number = this.ACTION_OTP_TTL_MS, ) { const otpData = await this.otpRepository.findByTarget(target); const key = this.targetKey(target); if (!otpData) { throw new BadRequestException( target.email ? "No verification code was requested for this email" : "No verification code was requested for this phone", ); } const ageMs = Date.now() - new Date(otpData.updatedAt).getTime(); if (ageMs > ttlMs) { await this.otpRepository.deleteOtp(otpData); this.actionAttempts.delete(key); throw new BadRequestException( "Verification code has expired. Request a new one.", ); } if (otpData.otp !== otp) { const attempts = (this.actionAttempts.get(key) ?? 0) + 1; if (attempts >= this.MAX_ACTION_ATTEMPTS) { await this.otpRepository.deleteOtp(otpData); this.actionAttempts.delete(key); throw new BadRequestException( "Too many incorrect attempts. Request a new code.", ); } this.actionAttempts.set(key, attempts); throw new BadRequestException("Invalid verification code"); } // single-use: consume on success await this.otpRepository.deleteOtp(otpData); this.actionAttempts.delete(key); return { success: true }; } }