mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 06:13:39 +00:00
feat(auth): implement staff-triggered password-reset links
This commit is contained in:
@@ -4,7 +4,7 @@ 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 { 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";
|
||||
@@ -22,11 +22,32 @@ 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);
|
||||
@@ -82,13 +103,23 @@ export class ForgotPasswordService {
|
||||
}
|
||||
|
||||
/** The address the code goes to, taken from the account — never from input. */
|
||||
private targetFor(user: User, channel: ResetChannel): OtpTarget | null {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 a reset code to the account's own email/phone. Returns the target so
|
||||
* authenticated (backoffice) callers can echo a masked version; unauthenticated
|
||||
@@ -134,9 +165,18 @@ export class ForgotPasswordService {
|
||||
|
||||
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> {
|
||||
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);
|
||||
@@ -147,7 +187,7 @@ export class ForgotPasswordService {
|
||||
userId,
|
||||
otpType: EOtpType.RESET_PASSWORD,
|
||||
verificationCode,
|
||||
expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS),
|
||||
expiresAt: new Date(Date.now() + ttlMs),
|
||||
isUsed: false,
|
||||
attemptCount: 0,
|
||||
});
|
||||
@@ -157,6 +197,63 @@ export class ForgotPasswordService {
|
||||
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.",
|
||||
);
|
||||
|
||||
const user = await this.resolveActiveUserById(userId);
|
||||
const identifier = user && this.identifierFor(user);
|
||||
if (!user || !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);
|
||||
|
||||
Reference in New Issue
Block a user