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