mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 05:55:02 +00:00
feat: add email to notification and otp
This commit is contained in:
@@ -1,15 +1,24 @@
|
||||
// otp.controller.ts
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
|
||||
|
||||
import { OtpService } from "./otp.service";
|
||||
import { OtpService, OtpTarget } from "./otp.service";
|
||||
import { Public } from "@edr/api-common";
|
||||
|
||||
// Exactly one of phone/email must be present per request — the channel the
|
||||
// code is sent through / checked against.
|
||||
function toTarget(phone?: string, email?: string): OtpTarget {
|
||||
if (email) return { email };
|
||||
if (phone) return { phone };
|
||||
throw new BadRequestException("phone or email is required");
|
||||
}
|
||||
|
||||
@Controller("otp")
|
||||
@Public()
|
||||
export class OtpController {
|
||||
@@ -24,9 +33,12 @@ export class OtpController {
|
||||
@Post("send")
|
||||
async sendOtp(
|
||||
@Body("phone")
|
||||
phone: string
|
||||
phone?: string,
|
||||
|
||||
@Body("email")
|
||||
email?: string
|
||||
) {
|
||||
return this.otpService.sendOtp(phone);
|
||||
return this.otpService.sendOtp(toTarget(phone, email));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -36,13 +48,16 @@ export class OtpController {
|
||||
@Post("verify")
|
||||
async verifyOtp(
|
||||
@Body("phone")
|
||||
phone: string,
|
||||
phone: string | undefined,
|
||||
|
||||
@Body("email")
|
||||
email: string | undefined,
|
||||
|
||||
@Body("otp")
|
||||
otp: string
|
||||
) {
|
||||
return this.otpService.verifyOtp(
|
||||
phone,
|
||||
toTarget(phone, email),
|
||||
otp
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,19 @@ import { BaseEntity } from "@edr/api-common";
|
||||
name: "otp_verifications",
|
||||
})
|
||||
export class OtpVerification extends BaseEntity{
|
||||
// Exactly one of phone/email is set per row — the channel the code was sent
|
||||
// through.
|
||||
@Column({
|
||||
unique: true,
|
||||
nullable: true,
|
||||
})
|
||||
phone!: string;
|
||||
phone?: string;
|
||||
|
||||
@Column({
|
||||
unique: true,
|
||||
nullable: true,
|
||||
})
|
||||
email?: string;
|
||||
|
||||
@Column()
|
||||
otp!: string;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module";
|
||||
|
||||
exports: [
|
||||
OtpRepository,
|
||||
OtpService,
|
||||
],
|
||||
})
|
||||
export class OtpModule {}
|
||||
@@ -31,17 +31,44 @@ export class OtpRepository {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Find By Email
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async findByEmail(
|
||||
email: string
|
||||
) {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Find By Target (either channel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async findByTarget(
|
||||
target: { phone?: string; email?: string }
|
||||
) {
|
||||
return target.email
|
||||
? this.findByEmail(target.email)
|
||||
: this.findByPhone(target.phone!);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create OTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async createOtp(
|
||||
phone: string,
|
||||
target: { phone?: string; email?: string },
|
||||
otp: string
|
||||
) {
|
||||
const entity =
|
||||
this.repository.create({
|
||||
phone,
|
||||
phone: target.phone,
|
||||
email: target.email,
|
||||
otp,
|
||||
verified: false,
|
||||
});
|
||||
@@ -70,10 +97,10 @@ export class OtpRepository {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verify Phone
|
||||
// Mark Verified
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async verifyPhone(
|
||||
async markVerified(
|
||||
otpVerification: OtpVerification
|
||||
) {
|
||||
otpVerification.verified =
|
||||
@@ -83,4 +110,18 @@ export class OtpRepository {
|
||||
otpVerification
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete OTP (single-use consume)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Hard delete so the unique `phone` row is freed and a fresh code can be
|
||||
// requested for the same number on the next action.
|
||||
async deleteOtp(
|
||||
otpVerification: OtpVerification
|
||||
) {
|
||||
return this.repository.remove(
|
||||
otpVerification
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,18 @@ import {
|
||||
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 {
|
||||
constructor(
|
||||
private readonly otpRepository: OtpRepository,
|
||||
private readonly smsClient: SmsClientService
|
||||
private readonly smsClient: SmsClientService,
|
||||
private readonly emailClient: EmailClientService
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -30,38 +36,47 @@ export class OtpService {
|
||||
// Send OTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async sendOtp(phone: string) {
|
||||
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.
|
||||
// recipient of the SMS/email.
|
||||
const otp = this.generateOtp();
|
||||
|
||||
// find existing phone
|
||||
const existingPhone =
|
||||
await this.otpRepository.findByPhone(
|
||||
phone
|
||||
// find existing row for this channel
|
||||
const existing =
|
||||
await this.otpRepository.findByTarget(
|
||||
target
|
||||
);
|
||||
|
||||
// update existing otp
|
||||
if (existingPhone) {
|
||||
if (existing) {
|
||||
await this.otpRepository.updateOtp(
|
||||
existingPhone,
|
||||
existing,
|
||||
otp
|
||||
);
|
||||
} else {
|
||||
// create new otp
|
||||
await this.otpRepository.createOtp(
|
||||
phone,
|
||||
target,
|
||||
otp
|
||||
);
|
||||
}
|
||||
|
||||
// send sms (queued to RabbitMQ via the shared SMS service)
|
||||
await this.smsClient.sendSms({
|
||||
to: phone,
|
||||
message: `Your verification code is ${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}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -83,19 +98,21 @@ export class OtpService {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async verifyOtp(
|
||||
phone: string,
|
||||
target: OtpTarget,
|
||||
otp: string
|
||||
) {
|
||||
// find phone
|
||||
// find the channel's row
|
||||
const otpData =
|
||||
await this.otpRepository.findByPhone(
|
||||
phone
|
||||
await this.otpRepository.findByTarget(
|
||||
target
|
||||
);
|
||||
|
||||
// phone not found
|
||||
// not found
|
||||
if (!otpData) {
|
||||
throw new BadRequestException(
|
||||
"Phone number not found"
|
||||
target.email
|
||||
? "Email address not found"
|
||||
: "Phone number not found"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,8 +123,8 @@ export class OtpService {
|
||||
);
|
||||
}
|
||||
|
||||
// verify phone
|
||||
await this.otpRepository.verifyPhone(
|
||||
// mark verified
|
||||
await this.otpRepository.markVerified(
|
||||
otpData
|
||||
);
|
||||
|
||||
@@ -115,7 +132,65 @@ export class OtpService {
|
||||
success: true,
|
||||
|
||||
message:
|
||||
"Phone verified successfully",
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user