mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
feat: password reset flow
This commit is contained in:
169
apps/edr-freight-api/src/modules/auth/forgot-password.service.ts
Normal file
169
apps/edr-freight-api/src/modules/auth/forgot-password.service.ts
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user