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,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,
);
}
}