mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
feat: otp double sending
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum, IsNotEmpty, IsString, IsUUID } from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator";
|
||||
|
||||
/** The channel the reset code is delivered over. */
|
||||
/**
|
||||
* The channel a reset LINK is delivered over. The OTP flow no longer picks one —
|
||||
* it sends to every contact on the account — but the staff-triggered link flow
|
||||
* still delivers over exactly one transport.
|
||||
*/
|
||||
export enum ResetChannel {
|
||||
Email = "email",
|
||||
Phone = "phone",
|
||||
@@ -16,13 +20,27 @@ export class ForgotPasswordRequestDto {
|
||||
@IsNotEmpty()
|
||||
identifier!: string;
|
||||
|
||||
@ApiProperty({ enum: ResetChannel })
|
||||
/**
|
||||
* Accepted and ignored. The code now goes to the account's email AND phone,
|
||||
* so there is nothing to choose — kept optional so clients still sending it
|
||||
* (older portal/backoffice builds) are not rejected outright.
|
||||
* @deprecated
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
enum: ResetChannel,
|
||||
deprecated: true,
|
||||
description: "Ignored — the code is sent to every contact on the account.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(ResetChannel)
|
||||
channel!: ResetChannel;
|
||||
channel?: ResetChannel;
|
||||
}
|
||||
|
||||
export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto {
|
||||
@ApiProperty({ description: "The 6-digit code sent to the chosen channel" })
|
||||
@ApiProperty({
|
||||
description:
|
||||
"The 6-digit code sent to the account's email and phone. Either delivery carries the same code.",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
otp!: string;
|
||||
|
||||
@@ -29,17 +29,19 @@ export class ForgotPasswordController {
|
||||
|
||||
@Post("forgot-password/request")
|
||||
@ApiOperation({
|
||||
summary: "Send a password-reset code over email or SMS",
|
||||
summary: "Send a password-reset code to the account's email AND phone",
|
||||
description:
|
||||
"Always reports success. An unknown, inactive, or channel-less account is " +
|
||||
"indistinguishable from a real one, so this cannot be used to enumerate accounts.",
|
||||
"One code, delivered over every contact the account has; either delivery " +
|
||||
"verifies it. Always reports success — an unknown, inactive, or contactless " +
|
||||
"account is indistinguishable from a real one, so this cannot be used to " +
|
||||
"enumerate accounts.",
|
||||
})
|
||||
async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> {
|
||||
const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier);
|
||||
|
||||
if (user) {
|
||||
try {
|
||||
await this.forgotPasswordService.requestReset(user, dto.channel);
|
||||
await this.forgotPasswordService.requestReset(user);
|
||||
} catch (error) {
|
||||
// A delivery failure must not change the response shape either — log it
|
||||
// and let the caller sit on the OTP screen.
|
||||
@@ -65,11 +67,7 @@ export class ForgotPasswordController {
|
||||
"alongside the same identifier and the new password.",
|
||||
})
|
||||
verify(@Body() dto: ForgotPasswordVerifyDto): Promise<ResetTicket> {
|
||||
return this.forgotPasswordService.verifyAndMintTicket(
|
||||
dto.identifier,
|
||||
dto.channel,
|
||||
dto.otp,
|
||||
);
|
||||
return this.forgotPasswordService.verifyAndMintTicket(dto.identifier, dto.otp);
|
||||
}
|
||||
|
||||
@Post("forgot-password/resolve-link")
|
||||
|
||||
@@ -102,7 +102,11 @@ export class ForgotPasswordService {
|
||||
.orderBy("u.createdAt", "DESC");
|
||||
}
|
||||
|
||||
/** The address the code goes to, taken from the account — never from input. */
|
||||
/**
|
||||
* A single channel of the account, for flows that genuinely deliver over one
|
||||
* transport (the staff-triggered reset LINK picks email or SMS). Taken from
|
||||
* the account — never from input.
|
||||
*/
|
||||
targetFor(user: User, channel: ResetChannel): OtpTarget | null {
|
||||
if (channel === ResetChannel.Email) {
|
||||
return user.email ? { email: user.email } : null;
|
||||
@@ -110,6 +114,19 @@ export class ForgotPasswordService {
|
||||
return user.phoneNumber ? { phone: user.phoneNumber } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every contact the account has. The reset OTP goes to all of them and any one
|
||||
* verifies it — a customer whose SMS never lands can finish from their inbox
|
||||
* without restarting the flow on a different channel. An account holding only
|
||||
* one of the two degrades to that channel; only a contactless account is null.
|
||||
*/
|
||||
targetsFor(user: User): OtpTarget | null {
|
||||
const target: OtpTarget = {};
|
||||
if (user.email) target.email = user.email;
|
||||
if (user.phoneNumber) target.phone = user.phoneNumber;
|
||||
return target.email || target.phone ? target : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value IAM's `set-password` will match this account on. It looks the user
|
||||
* up by email OR username OR phoneNumber (and lowercases whatever it is
|
||||
@@ -121,20 +138,17 @@ export class ForgotPasswordService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reset code to the account's own email/phone. Returns the target so
|
||||
* authenticated (backoffice) callers can echo a masked version; unauthenticated
|
||||
* callers must discard it.
|
||||
* Send one reset code to every contact on the account — email AND phone —
|
||||
* returning the target so authenticated (backoffice) callers can echo a masked
|
||||
* version; unauthenticated callers must discard it.
|
||||
*
|
||||
* Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp`
|
||||
* upserts. A reset request therefore overwrites any pending signup code for
|
||||
* the same address — last code sent wins. That is the pre-existing behaviour
|
||||
* between any two flows sharing this table.
|
||||
* replaces every row the target overlaps. A reset request therefore overwrites
|
||||
* any pending signup code for the same addresses — last code sent wins. That
|
||||
* is the pre-existing behaviour between any two flows sharing this table.
|
||||
*/
|
||||
async requestReset(
|
||||
user: User,
|
||||
channel: ResetChannel,
|
||||
): Promise<OtpTarget | null> {
|
||||
const target = this.targetFor(user, channel);
|
||||
async requestReset(user: User): Promise<OtpTarget | null> {
|
||||
const target = this.targetsFor(user);
|
||||
if (!target) return null;
|
||||
|
||||
await this.otpService.sendOtp(target);
|
||||
@@ -151,11 +165,12 @@ export class ForgotPasswordService {
|
||||
*/
|
||||
async verifyAndMintTicket(
|
||||
identifier: string,
|
||||
channel: ResetChannel,
|
||||
otp: string,
|
||||
): Promise<ResetTicket> {
|
||||
const user = await this.resolveActiveUser(identifier);
|
||||
const target = user && this.targetFor(user, channel);
|
||||
// Same set of contacts `requestReset` sent to, so the code resolves whichever
|
||||
// of the two the customer actually received it on.
|
||||
const target = user && this.targetsFor(user);
|
||||
|
||||
if (!user?.id || !target) {
|
||||
// Same shape as a wrong code: a caller probing for accounts learns nothing
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import { OtpTarget } from "../otp/otp.service";
|
||||
|
||||
function maskEmail(email: string): string {
|
||||
const [local, domain] = email.split("@");
|
||||
const head = local.slice(0, 1);
|
||||
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
||||
}
|
||||
|
||||
function maskPhone(phone: string): string {
|
||||
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask an OTP target for echoing back to the caller: `+251911234567` ->
|
||||
* `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to
|
||||
* a caller who has not yet proven possession of the channel.
|
||||
*
|
||||
* A dual-channel target masks both and joins them, so the UI can say exactly
|
||||
* where the code went ("a•@x.com and +251•••••4567") — a user who only checks
|
||||
* one of the two otherwise assumes the other never received anything.
|
||||
*/
|
||||
export function maskOtpTarget(target: OtpTarget): string {
|
||||
if (target.email) {
|
||||
const [local, domain] = target.email.split("@");
|
||||
const head = local.slice(0, 1);
|
||||
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
||||
}
|
||||
const phone = target.phone ?? "";
|
||||
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
|
||||
const parts: string[] = [];
|
||||
if (target.email) parts.push(maskEmail(target.email));
|
||||
if (target.phone) parts.push(maskPhone(target.phone));
|
||||
return parts.join(" and ");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user