mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 04:20:55 +00:00
152 lines
4.6 KiB
TypeScript
152 lines
4.6 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);
|
|
}
|
|
|
|
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) {
|
|
console.log(error);
|
|
|
|
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). Unlike verifyOtp above — which marks a phone verified
|
|
// and leaves the code in place — this enforces a short TTL and consumes the
|
|
// code on success so it can never be replayed.
|
|
private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000;
|
|
|
|
async verifyOtpForAction(phone: string, otp: string) {
|
|
const otpData = await this.otpRepository.findByPhone(phone);
|
|
|
|
if (!otpData) {
|
|
throw new BadRequestException(
|
|
"No verification code was requested for this phone",
|
|
);
|
|
}
|
|
|
|
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
|
|
|
if (ageMs > this.ACTION_OTP_TTL_MS) {
|
|
await this.otpRepository.deleteOtp(otpData);
|
|
|
|
throw new BadRequestException(
|
|
"Verification code has expired. Request a new one.",
|
|
);
|
|
}
|
|
|
|
if (otpData.otp !== otp) {
|
|
throw new BadRequestException("Invalid verification code");
|
|
}
|
|
|
|
// single-use: consume on success
|
|
await this.otpRepository.deleteOtp(otpData);
|
|
|
|
return { success: true };
|
|
}
|
|
}
|