mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 22:30:55 +00:00
513 lines
20 KiB
TypeScript
513 lines
20 KiB
TypeScript
// 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<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 };
|
||
}
|
||
}
|