Files
edr-platform/apps/edr-freight-api/src/modules/otp/otp.service.ts
2026-07-16 00:33:31 +00:00

225 lines
7.9 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 { 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 (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(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<string, number>();
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 };
}
}