mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 13:10:56 +00:00
314 lines
12 KiB
TypeScript
314 lines
12 KiB
TypeScript
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<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();
|
|
}
|
|
|
|
/**
|
|
* 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<User | null> {
|
|
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<OtpTarget | null> {
|
|
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<ResetTicket> {
|
|
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<ResetTicket> {
|
|
// 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<ResetLinkAccount> {
|
|
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);
|
|
}
|
|
}
|