feat: password reset flow

This commit is contained in:
Nathnael
2026-07-09 08:50:08 +00:00
parent d1652c1b96
commit e04b513b8f
31 changed files with 1350 additions and 250 deletions

View File

@@ -0,0 +1,48 @@
import {
Body,
Controller,
NotFoundException,
Param,
ParseUUIDPipe,
Post,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
import { CustomerResetService } from "./customer-reset.service";
/**
* Staff-triggered password reset. The customer receives the code and sets their
* own password — staff never see or handle a credential.
*/
@ApiTags("backoffice")
@Controller("backoffice/customers")
@ApiBearerAuth()
export class CustomerResetController {
constructor(private readonly customerResetService: CustomerResetService) {}
@Post(":companyId/reset-password")
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
@ApiOperation({
summary: "Send a password-reset code to a customer's primary contact",
})
async resetPassword(
@Param("companyId", ParseUUIDPipe) companyId: string,
@Body() dto: BackofficeResetPasswordDto,
) {
const maskedTarget = await this.customerResetService.sendResetToCustomer(
companyId,
dto.channel,
);
if (!maskedTarget) {
throw new NotFoundException(
`No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`,
);
}
return { channel: dto.channel, maskedTarget };
}
}

View File

@@ -0,0 +1,59 @@
import { Injectable, Logger } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { ExternalProfile } from "../companies/entities/external-profile.entity";
import { ResetChannel } from "./dto/forgot-password.dto";
import { ForgotPasswordService } from "./forgot-password.service";
@Injectable()
export class CustomerResetService {
private readonly logger = new Logger(CustomerResetService.name);
constructor(
@InjectRepository(ExternalProfile)
private readonly externalProfileRepository: Repository<ExternalProfile>,
private readonly forgotPasswordService: ForgotPasswordService,
) {}
/**
* Send a reset code to the company's primary contact. Returns the masked
* destination, or null when there is no eligible account for that channel.
*
* Unlike the public flow this reports failure honestly — the caller is an
* authenticated staff member, so there is nothing to enumerate.
*/
async sendResetToCustomer(
companyId: string,
channel: ResetChannel,
): Promise<string | null> {
const profile = await this.externalProfileRepository.findOne({
where: { companyId, isPrimaryContact: true },
});
if (!profile) {
this.logger.warn(`Company ${companyId} has no primary contact profile`);
return null;
}
// Resolve through the same active-account gate the public flow uses, so a
// suspended customer cannot be reactivated by a staff-triggered reset.
const user = await this.forgotPasswordService.resolveActiveUserById(
profile.userId,
);
if (!user) {
this.logger.warn(
`Primary contact ${profile.userId} of company ${companyId} is not an active account`,
);
return null;
}
const target = await this.forgotPasswordService.requestReset(user, channel);
if (!target) return null;
this.logger.log(
`Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`,
);
return this.forgotPasswordService.maskTarget(target);
}
}

View File

@@ -0,0 +1,35 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
/** The channel the reset code is delivered over. */
export enum ResetChannel {
Email = "email",
Phone = "phone",
}
export class ForgotPasswordRequestDto {
@ApiProperty({
description: "Email, username, or phone number of the account to reset",
example: "name@company.com",
})
@IsString()
@IsNotEmpty()
identifier!: string;
@ApiProperty({ enum: ResetChannel })
@IsEnum(ResetChannel)
channel!: ResetChannel;
}
export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto {
@ApiProperty({ description: "The 6-digit code sent to the chosen channel" })
@IsString()
@IsNotEmpty()
otp!: string;
}
export class BackofficeResetPasswordDto {
@ApiProperty({ enum: ResetChannel })
@IsEnum(ResetChannel)
channel!: ResetChannel;
}

View File

@@ -0,0 +1,69 @@
import { Body, Controller, Logger, Post } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Public } from "@edr/api-common";
import {
ForgotPasswordRequestDto,
ForgotPasswordVerifyDto,
} from "./dto/forgot-password.dto";
import { ForgotPasswordService, ResetTicket } from "./forgot-password.service";
/**
* Freight-owned reset flow. IAM ships a `forgot-password` route, but it only
* ever SMSes a magic link (no email channel, and it needs `FE_BASE_URL`, which
* this API does not set). These routes drive freight's own email-or-phone OTP
* service instead, then hand back a ticket for IAM's public `set-password`.
*/
@ApiTags("auth")
@Controller("auth")
@Public()
export class ForgotPasswordController {
private readonly logger = new Logger(ForgotPasswordController.name);
constructor(private readonly forgotPasswordService: ForgotPasswordService) {}
@Post("forgot-password/request")
@ApiOperation({
summary: "Send a password-reset code over email or SMS",
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.",
})
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);
} catch (error) {
// A delivery failure must not change the response shape either — log it
// and let the caller sit on the OTP screen.
this.logger.error(
`Reset code delivery failed for user ${user.id}: ${
error instanceof Error ? error.message : String(error)
}`,
error instanceof Error ? error.stack : undefined,
);
}
} else {
this.logger.log("Reset requested for an unknown or inactive account");
}
return { success: true };
}
@Post("forgot-password/verify")
@ApiOperation({
summary: "Exchange a valid reset code for a single-use set-password ticket",
description:
"The returned { userId, verificationCode } is the body for PATCH /api/auth/set-password, " +
"alongside the same identifier and the new password.",
})
verify(@Body() dto: ForgotPasswordVerifyDto): Promise<ResetTicket> {
return this.forgotPasswordService.verifyAndMintTicket(
dto.identifier,
dto.channel,
dto.otp,
);
}
}

View File

@@ -0,0 +1,169 @@
import { randomBytes } from "node:crypto";
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
import { DataSource, Repository } from "typeorm";
import { hashPassword } from "@tria-plc/api-common/utils/argon";
import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity";
import { OtpService, OtpTarget } from "../otp/otp.service";
import { ResetChannel } from "./dto/forgot-password.dto";
/**
* How long the reset ticket minted for `PATCH /api/auth/set-password` stays
* valid. The IAM `setPassword` handler enforces this via `expiresAt`.
*/
const RESET_TICKET_TTL_MS = 10 * 60 * 1000;
/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */
const RESET_OTP_TTL_MS = 10 * 60 * 1000;
export interface ResetTicket {
userId: string;
verificationCode: string;
}
@Injectable()
export class ForgotPasswordService {
private readonly logger = new Logger(ForgotPasswordService.name);
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly otpService: OtpService,
) {}
/**
* Resolve an account that is actually eligible for a password reset.
*
* IAM's `set-password` handler flips `isActive: true` on the user as a side
* effect, so a reset on a deactivated account would silently resurrect it.
* Gating here — rather than at the set-password call — is what keeps that
* from being reachable. Mirrors IAM's own login lookup: match on any of
* email / username / phone, and require an active credential row.
*/
async resolveActiveUser(identifier: string): Promise<User | null> {
const id = identifier.trim();
if (!id) return null;
return await this.activeUserQuery()
.andWhere(
"(LOWER(u.email) = LOWER(:id) OR u.username = :id OR u.phoneNumber = :id)",
{ id },
)
.getOne();
}
/** Same eligibility gate as {@link resolveActiveUser}, keyed by IAM user id. */
async resolveActiveUserById(userId: string): Promise<User | null> {
if (!userId) return null;
return await this.activeUserQuery()
.andWhere("u.id = :userId", { userId })
.getOne();
}
/**
* Base query for accounts eligible to reset. `.where()` is claimed here so
* callers must use `.andWhere()` — TypeORM's `.where()` resets the clause,
* which would silently drop the `isActive` gate.
*/
private activeUserQuery() {
return this.userRepository
.createQueryBuilder("u")
.innerJoin("u.userCredentials", "uc", "uc.isActive = true")
.where("u.isActive = true")
.orderBy("u.createdAt", "DESC");
}
/** The address the code goes to, taken from the account — never from input. */
private targetFor(user: User, channel: ResetChannel): OtpTarget | null {
if (channel === ResetChannel.Email) {
return user.email ? { email: user.email } : null;
}
return user.phoneNumber ? { phone: user.phoneNumber } : null;
}
/**
* 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.
*
* 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.
*/
async requestReset(
user: User,
channel: ResetChannel,
): Promise<OtpTarget | null> {
const target = this.targetFor(user, channel);
if (!target) return null;
await this.otpService.sendOtp(target);
return target;
}
/**
* Prove possession of the OTP, then mint an IAM reset ticket the caller can
* spend on the public `PATCH /api/auth/set-password`.
*
* Minting a `UserVerification` row rather than writing `UserCredential`
* ourselves keeps IAM as the single owner of the password write path (old
* credential deactivation, argon hashing, changed-at bookkeeping).
*/
async verifyAndMintTicket(
identifier: string,
channel: ResetChannel,
otp: string,
): Promise<ResetTicket> {
const user = await this.resolveActiveUser(identifier);
const target = user && this.targetFor(user, channel);
if (!user?.id || !target) {
// Same shape as a wrong code: a caller probing for accounts learns nothing
// beyond what the request step already (deliberately) refuses to tell them.
throw new BadRequestException("Invalid verification code");
}
await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS);
const code = randomBytes(24).toString("base64url");
const verificationCode = await hashPassword(code);
const userId = user.id;
await this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(UserVerification);
// Retire any outstanding codes so only the ticket we just minted can be
// spent — `findVerificationForPrimaryReset` reads the newest row.
await repo.update({ userId }, { isUsed: true });
await repo.insert({
userId,
otpType: EOtpType.RESET_PASSWORD,
verificationCode,
expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS),
isUsed: false,
attemptCount: 0,
});
});
this.logger.log(`Reset ticket minted for user ${userId}`);
return { userId, verificationCode: code };
}
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
maskTarget(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)}`;
}
}

View File

@@ -2,15 +2,35 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
import { ExternalProfile } from '../companies/entities/external-profile.entity';
import { OtpModule } from '../otp/otp.module';
import { CheckAvailabilityController } from './check-availability.controller';
import { CheckAvailabilityService } from './check-availability.service';
import { CustomerResetController } from './customer-reset.controller';
import { CustomerResetService } from './customer-reset.service';
import { ForgotPasswordController } from './forgot-password.controller';
import { ForgotPasswordService } from './forgot-password.service';
import { FreightMeController } from './freight-me.controller';
import { FreightMeService } from './freight-me.service';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [FreightMeController, CheckAvailabilityController],
providers: [FreightMeService, CheckAvailabilityService],
imports: [
TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]),
OtpModule,
],
controllers: [
FreightMeController,
CheckAvailabilityController,
ForgotPasswordController,
CustomerResetController,
],
providers: [
FreightMeService,
CheckAvailabilityService,
ForgotPasswordService,
CustomerResetService,
],
})
export class FreightAuthModule {}