// otp.service.ts import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { randomInt } from "node:crypto"; 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 { // Cryptographically secure 6-digit code (100000–999999). Math.random() is a // non-CSPRNG and must never be used to mint a security token. return randomInt(100000, 1000000).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); } // NOTE: do NOT reset the brute-force attempt counter on send. Clearing it // here let an attacker wipe the per-target guess budget just by calling // /otp/send between guesses. The counter is cleared only when the code is // consumed/expired during verification. // TODO: add per-target + per-IP rate limiting on the public /otp/send and // /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists // in the codebase yet. 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); const key = this.targetKey(target); // not found if (!otpData) { throw new BadRequestException( target.email ? "Email address not found" : "Phone number not found", ); } // TTL: reuse the same age window as the hardened action verifier — an old // code can't be verified. const ageMs = Date.now() - new Date(otpData.updatedAt).getTime(); if (ageMs > this.ACTION_OTP_TTL_MS) { await this.otpRepository.deleteOtp(otpData); this.actionAttempts.delete(key); throw new BadRequestException( "Verification code has expired. Request a new one.", ); } // invalid otp — per-target attempt cap so a 6-digit code can't be // brute-forced within its TTL; the code is burned once the budget is spent. 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 OTP"); } // single-use: consume the code on success so it can't be replayed. await this.otpRepository.deleteOtp(otpData); this.actionAttempts.delete(key); 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 }; } }