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, verifyPassword } 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"; import { maskOtpTarget } from "./mask-target.util"; /** * 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; /** * A staff-triggered reset link lives longer than a typed OTP: the customer may * only see the SMS/email hours after the call that prompted it. */ export const RESET_LINK_TTL_MS = 24 * 60 * 60 * 1000; /** IAM refuses a ticket once its row hits this many failed attempts. */ const MAX_TICKET_ATTEMPTS = 5; export interface ResetTicket { userId: string; verificationCode: string; } /** * What a valid reset link resolves to. `identifier` is the value IAM's * `set-password` matches the user on (it accepts email / username / phone), so * the portal can spend the ticket without the customer typing anything. */ export interface ResetLinkAccount { userId: string; identifier: string; maskedIdentifier: 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(); } /** * Active account by id, WITHOUT requiring an existing credential. * * {@link activeUserQuery} inner-joins an active `user_credentials` row, which * is right for a *reset*: it stops a staff-triggered link from reactivating a * suspended account. But an account that has never set a password has no * credential row yet, so that join excludes exactly the accounts a first-time * *activation* link is for — shipping lines are created deliberately without * one (see ShippingLineCompaniesService.register). * * The `isActive` gate is kept; only the credential requirement is dropped. */ async resolveActivatableUserById(userId: string): Promise { if (!userId) return null; return await this.userRepository .createQueryBuilder("u") .where("u.isActive = true") .andWhere("u.id = :userId", { userId }) .orderBy("u.createdAt", "DESC") .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"); } /** * 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; } 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 * given), so prefer email, then phone, and fall back to username last — * a mixed-case username would not survive that lowercasing. */ private identifierFor(user: User): string | null { return user.email ?? user.phoneNumber ?? user.username ?? null; } /** * 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` * 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): Promise { const target = this.targetsFor(user); 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, otp: string, ): Promise { const user = await this.resolveActiveUser(identifier); // 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 // 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); return await this.mintResetTicket(user.id, RESET_TICKET_TTL_MS); } /** * Mint a single-use IAM reset ticket. Shared by the OTP flow (where the code * is the proof of possession) and the staff-triggered link flow (where the * ticket travels in the link and delivery to the account's own inbox/handset * is the proof). */ async mintResetTicket(userId: string, ttlMs: number): Promise { // Hex, not base64url: the token rides in an SMS, and the GSM-7 alphabet has // no "_" — gateways substitute a space and the link arrives broken. const code = randomBytes(24).toString("hex"); const verificationCode = await hashPassword(code); 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() + ttlMs), isUsed: false, attemptCount: 0, }); }); this.logger.log(`Reset ticket minted for user ${userId}`); return { userId, verificationCode: code }; } /** * Validate a reset link and hand back everything the portal needs to spend it * on IAM's `PATCH /api/auth/set-password`. * * The checks mirror IAM's own — newest row, unused, unexpired, attempts left, * argon match — so a link that resolves here is one IAM will honour. Doing * them up front is what lets the page say "this link has expired" before the * customer types a password rather than after. * * Every rejection is the same message: a link is a bearer credential, and the * holder of a bad one learns nothing about why it failed or whether the user * id exists. */ async resolveResetLink( userId: string, token: string, ): Promise { const invalid = new BadRequestException( "This password-reset link is invalid or has expired. Request a new one.", ); // Credential-less on purpose: this resolves links for *setting* a password, // which includes first-time activation of an account that has never had one // (shipping lines are created without a credential row). Requiring one here // rejected a perfectly valid activation link before its token was ever // checked. The ticket checks below are what actually authorise the reset. const user = await this.resolveActivatableUserById(userId); const identifier = user && this.identifierFor(user); if (!user || !identifier) { // Logged because the early return above bypasses the rejection warning // below — without this, an account that fails the lookup produces no // diagnostic at all and looks identical to a bad token. this.logger.warn( `Reset link rejected for user ${userId} — no active account or no usable identifier`, ); throw invalid; } const verification = await this.dataSource .getRepository(UserVerification) .findOne({ where: { userId, otpType: EOtpType.RESET_PASSWORD }, order: { createdAt: "DESC" }, }); // `expiresAt` / `attemptCount` are optional on IAM's entity but always // written by `mintResetTicket`. A row missing either is malformed, so treat // it as expired rather than letting it through unchecked. if ( !verification || verification.isUsed || !verification.expiresAt || verification.expiresAt < new Date() || (verification.attemptCount ?? 0) >= MAX_TICKET_ATTEMPTS || !(await verifyPassword(token, verification.verificationCode)) ) { this.logger.warn(`Reset link rejected for user ${userId}`); throw invalid; } return { userId, identifier, maskedIdentifier: maskOtpTarget( identifier.includes("@") ? { email: identifier } : { phone: identifier }, ), verificationCode: token, }; } /** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */ maskTarget(target: OtpTarget): string { return maskOtpTarget(target); } }