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