mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
194 lines
6.3 KiB
TypeScript
194 lines
6.3 KiB
TypeScript
// otp.service.ts
|
|
|
|
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
|
|
|
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 {
|
|
return Math.floor(100000 + Math.random() * 900000).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);
|
|
}
|
|
|
|
// A freshly issued code gets a fresh guess budget.
|
|
this.actionAttempts.delete(this.targetKey(target));
|
|
|
|
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);
|
|
|
|
// not found
|
|
if (!otpData) {
|
|
throw new BadRequestException(
|
|
target.email ? "Email address not found" : "Phone number not found",
|
|
);
|
|
}
|
|
|
|
// invalid otp
|
|
if (otpData.otp !== otp) {
|
|
throw new BadRequestException("Invalid OTP");
|
|
}
|
|
|
|
// mark verified
|
|
await this.otpRepository.markVerified(otpData);
|
|
|
|
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 };
|
|
}
|
|
}
|