Files
edr-platform/apps/edr-freight-api/src/modules/otp/otp.service.ts
2026-08-04 15:32:49 +03:00

513 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 (100000999999). 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<DispatchOutcome> {
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<DispatchOutcome> {
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<string, number>();
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 };
}
}