// otp.service.ts import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { randomInt } from "node:crypto"; import { OtpRepository } from "./otp.repository"; import { NotificationsService } from "../notifications/notifications.service"; import { EmailClientService } from "../notifications/email-client.service"; /** * Where a code goes. At least one of phone/email must be set — enforced by the * controller and re-checked here. When BOTH are set the same code is sent to * both and either one can be used to verify it: a user who never receives the * SMS can still finish from their inbox, and vice versa. Callers that resolve * contacts from IAM pass whatever the account actually has, so an account with * only one of the two silently degrades to a single channel. */ export type OtpTarget = { phone?: string; email?: string }; /** Which transports a target resolves to, in a stable order for logging. */ function channelsOf(target: OtpTarget): Array<"email" | "sms"> { const channels: Array<"email" | "sms"> = []; if (target.email) channels.push("email"); if (target.phone) channels.push("sms"); return channels; } /** * Canonicalise a phone to E.164 so the code stored on send and the one looked * up on verify collide regardless of how the number was typed. Without this, * `+251986680099`, `251986680099` and `0986680099` are three different keys and * a code sent to one is invisible to the others — the send/verify halves must * agree on the exact string. Ethiopian local `09…`/`07…` (10 digits) maps to * `+2519…`/`+2517…`; a bare `251…` gains its `+`; anything already `+…` is kept. */ function normalizePhone(rawPhone: string): string { const raw = rawPhone.trim(); const digits = raw.replace(/[^\d+]/g, ""); if (digits.startsWith("+")) return digits; const bare = digits.replace(/^0+/, ""); if (/^251\d{9}$/.test(digits)) return `+${digits}`; if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`; // Unknown shape (foreign number, already-clean intl without +) — prefix + if // it looks like a full international number, else leave as typed. return digits.length >= 11 ? `+${digits}` : raw; } /** * Whether a phone is an Ethiopian mobile the SMS gateway can actually reach — * the carrier integration is domestic-only, so a send to anything else is * queued and silently lost. Callers use this to fall back to email instead of * pretending an SMS is on its way. */ export function isDomesticPhone(rawPhone: string): boolean { return /^\+2519\d{8}$/.test(normalizePhone(rawPhone)); } /** * Canonicalise every channel present on the target. Each field is normalised * independently — a dual-channel target must end up with both halves in their * canonical form, since verify may arrive naming either one. */ export function normalizeOtpTarget(target: OtpTarget): OtpTarget { const normalized: OtpTarget = {}; if (target.email?.trim()) { // Same contract as the phone branch: the string stored on send and the one // looked up on verify must be byte-identical, or the code is invisible to // the verifier. Addresses reach us from a raw `@Body("email")` with no DTO // or ValidationPipe, so `User@X.com`, `user@x.com` and a copy-paste with a // trailing space are three different keys for one mailbox. Domains are // case-insensitive (RFC 1035); local-parts are formally case-sensitive // (RFC 5321 §2.4) but no mail provider in practice treats them so, and // matching what users expect beats matching the letter of the spec here. normalized.email = target.email.trim().toLowerCase(); } if (target.phone?.trim()) { normalized.phone = normalizePhone(target.phone); } return normalized; } /** One transport's hand-off outcome. Never thrown — collected and reported. */ interface DispatchOutcome { channel: "email" | "sms"; queued: boolean; error?: string; } @Injectable() export class OtpService { logger = new Logger(OtpService.name); constructor( private readonly otpRepository: OtpRepository, private readonly notifications: NotificationsService, 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(rawTarget: OtpTarget) { // Store under the canonical keys so verify (which normalises the same way) // always finds this row regardless of how either side typed the number. const target = normalizeOtpTarget(rawTarget); const channels = channelsOf(target); const label = this.targetLabel(target); const startedAt = Date.now(); if (channels.length === 0) { throw new BadRequestException("phone or email is required"); } 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. ONE code covers every channel: the user // types whichever message reaches them first. const otp = this.generateOtp(); // Replaces every row this target overlaps with, so a dual-channel send // leaves exactly one row holding both halves — verify then resolves the // same row whichever channel it is given. const { rotated } = await this.otpRepository.replaceOtp(target, otp); // `rotate` means a code already existed for this target and was replaced — // the previous one is now dead. A user holding a slow-to-arrive SMS and // typing its code will fail against the row; this line is how that shows up // in the log rather than as an unexplained "invalid OTP" report. this.logger.log( `otp.issue channels=${channels.join("+")} target=${label} action=${rotated ? "rotate" : "create"}`, ); // 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. // A foreign number is unreachable by the domestic-only SMS gateway; when // email is also on the target, go email-only rather than queueing an SMS // that will never arrive. With no email the SMS attempt stays — it is the // only route there is. const smsPhone = target.phone && (!target.email || isDomesticPhone(target.phone)) ? target.phone : null; if (target.phone && !smsPhone) { this.logger.warn( `otp.dispatch.sms-skipped target=${label} — non-domestic phone, delivering via email only`, ); } // Fan out to every channel the target has, independently: one transport // being down must not suppress the other, which is the whole point of // sending to both. Each helper swallows its own failure so a rejected // email publish still leaves the SMS delivered (and the code valid). const outcomes = ( await Promise.all([ target.email ? this.dispatchEmail(target.email, otp) : null, smsPhone ? this.dispatchSms(smsPhone, otp) : null, ]) ).filter((outcome): outcome is DispatchOutcome => outcome !== null); for (const outcome of outcomes) { this.logger.log( `otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued } latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : "" }`, ); } // Every channel threw. Nothing can arrive and there is no partial success // to preserve — fail the request the way a single-channel send always did. if (outcomes.every((outcome) => outcome.error)) { throw new Error( outcomes.map((o) => `${o.channel}: ${o.error}`).join("; "), ); } // Both clients report hand-off, not delivery — capture it rather than // discarding it, so "delivered=false" is distinguishable from a code that // was published fine and lost downstream at the carrier. const delivered = outcomes.some((outcome) => outcome.queued); if (!delivered) { // The row is committed and we are about to answer "OTP sent successfully", // but nothing left this process. Without this line the only symptom is a // user who never receives a code — indistinguishable from carrier loss, // and the misleading success response makes it look like our side worked. this.logger.error( `otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset" } — no transport reported hand-off; no code will arrive for this send`, ); } // SECURITY: this logs a live credential in cleartext. Anyone with read // access to the log stream can complete a password reset or a contract // signature for the address on the same line. Kept deliberately (log // aggregation is the debugging path for flaky SMS here) — if that tradeoff // is ever revisited, gate on an env flag rather than deleting the line, so // dev keeps its workflow. this.logger.log(`OTP send for ${label}: ${otp}`); return { success: true, // Distinguishes "we published it" from "the transport is a no-op". The // HTTP response shape is unchanged; the controller drops this field. delivered, 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( `otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt }: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error.stack : undefined, ); throw new BadRequestException("Failed to send OTP"); } } /** * Publish to one transport, converting a throw into a reported outcome. A * broker error on one channel must not abort the other — with dual-channel * sends the user still has a working route to the code. */ private async dispatchEmail( email: string, otp: string, ): Promise { try { const { queued } = await this.emailClient.sendEmail({ to: email, subject: "Your EDR Freight verification code", text: `Your verification code is ${otp}`, }); return { channel: "email", queued }; } catch (error) { return { channel: "email", queued: false, error: error instanceof Error ? error.message : String(error), }; } } /** * SMS half of {@link dispatchEmail}; same swallow-and-report contract. Sent * via NotificationsService's `directSend`, which now routes through the * same RabbitMQ `SMS_SERVICE` queue as every other SMS in freight-api, so * `queued: true` here means the broker confirmed ownership of the message, * not that the carrier delivered it. */ private async dispatchSms( phone: string, otp: string, ): Promise { try { await this.notifications.directSend( "sms", phone, `Your verification code is ${otp}`, ); return { channel: "sms", queued: true }; } catch (error) { return { channel: "sms", queued: false, error: error instanceof Error ? error.message : String(error), }; } } /** * Correlation key shared by every `otp.*` line for one target, so a send and * its later verify can be joined with a single grep. The raw values are used * because the code itself is already logged in cleartext above — hashing the * address while printing the credential next to it would buy nothing. */ private targetLabel(target: OtpTarget): string { return [target.email, target.phone].filter(Boolean).join("+") || "unknown"; } /** * One line per verify exit path. `result` is a closed set — ok | invalid | * expired | exhausted | not_found — so failures can be counted by reason * instead of inferred from error strings that the frontend also depends on. */ private logVerify( target: OtpTarget, mode: "simple" | "action", result: "ok" | "invalid" | "expired" | "exhausted" | "not_found", detail?: string, ) { const line = `otp.verify channels=${channelsOf(target).join( "+", )} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : "" }`; if (result === "ok") this.logger.log(line); else this.logger.warn(line); } /** * "No code for this target" phrased for whichever channels were named. A * dual-channel caller gets a neutral message — naming one channel would be * misleading when the code went to both. */ private notFoundMessage(target: OtpTarget, requested: boolean): string { const channels = channelsOf(target); if (channels.length !== 1) { return requested ? "No verification code was requested for this account" : "No verification code found for this account"; } if (target.email) { return requested ? "No verification code was requested for this email" : "Email address not found"; } return requested ? "No verification code was requested for this phone" : "Phone number not found"; } // --------------------------------------------------------------------------- // Verify OTP // --------------------------------------------------------------------------- async verifyOtp(rawTarget: OtpTarget, otp: string) { // Same canonicalisation as sendOtp so a code stored under +2519… is found // when verify is called with 09… (or any equivalent form). const target = normalizeOtpTarget(rawTarget); // Matches on ANY channel the caller named, so a code sent to both phone and // email verifies whichever one the user quotes back. const otpData = await this.otpRepository.findByTarget(target); // not found if (!otpData) { // No row for this target. Most often a normalisation mismatch or a code // that was already consumed/burned — not necessarily a caller who never // asked. this.logVerify(target, "simple", "not_found"); throw new BadRequestException(this.notFoundMessage(target, false)); } // Key the attempt budget on the ROW, not on the channels the caller happened // to name — otherwise guessing alternately by phone and by email would hand // an attacker two independent budgets against the same code. const key = otpData.id; // 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); this.logVerify( target, "simple", "expired", `ageMs=${ageMs} ttlMs=${this.ACTION_OTP_TTL_MS}`, ); 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); this.logVerify( target, "simple", "exhausted", `attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`, ); throw new BadRequestException( "Too many incorrect attempts. Request a new code.", ); } this.actionAttempts.set(key, attempts); this.logVerify( target, "simple", "invalid", `attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`, ); throw new BadRequestException("Invalid OTP"); } // single-use: consume the code on success so it can't be replayed. One row // covers every channel it was sent to, so this kills all of them at once. await this.otpRepository.deleteOtp(otpData); this.actionAttempts.delete(key); this.logVerify(target, "simple", "ok", `ageMs=${ageMs}`); return { success: true, message: "Verification successful", }; } // --------------------------------------------------------------------------- // 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(); async verifyOtpForAction( rawTarget: OtpTarget, otp: string, ttlMs: number = this.ACTION_OTP_TTL_MS, ) { const target = normalizeOtpTarget(rawTarget); const otpData = await this.otpRepository.findByTarget(target); if (!otpData) { this.logVerify(target, "action", "not_found"); throw new BadRequestException(this.notFoundMessage(target, true)); } // Row-keyed for the same reason as verifyOtp: one code, one budget. const key = otpData.id; const ageMs = Date.now() - new Date(otpData.updatedAt).getTime(); if (ageMs > ttlMs) { await this.otpRepository.deleteOtp(otpData); this.actionAttempts.delete(key); this.logVerify( target, "action", "expired", `ageMs=${ageMs} ttlMs=${ttlMs}`, ); 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); this.logVerify( target, "action", "exhausted", `attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`, ); throw new BadRequestException( "Too many incorrect attempts. Request a new code.", ); } this.actionAttempts.set(key, attempts); this.logVerify( target, "action", "invalid", `attempts=${attempts}/${this.MAX_ACTION_ATTEMPTS} ageMs=${ageMs}`, ); throw new BadRequestException("Invalid verification code"); } // single-use: consume on success await this.otpRepository.deleteOtp(otpData); this.actionAttempts.delete(key); this.logVerify(target, "action", "ok", `ageMs=${ageMs}`); return { success: true }; } }