mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
import { Injectable, Logger } from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { InjectRepository } from "@nestjs/typeorm";
|
|
import { Repository } from "typeorm";
|
|
|
|
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
|
import { EmailClientService } from "../notifications/email-client.service";
|
|
import { SmsClientService } from "../notifications/sms-client.service";
|
|
import { ResetChannel } from "./dto/forgot-password.dto";
|
|
import {
|
|
ForgotPasswordService,
|
|
RESET_LINK_TTL_MS,
|
|
type ResetTicket,
|
|
} from "./forgot-password.service";
|
|
import { maskOtpTarget } from "./mask-target.util";
|
|
import { isDomesticPhone, type OtpTarget } from "../otp/otp.service";
|
|
|
|
/** The account a staff-triggered reset would land on. */
|
|
export interface CustomerResetTarget {
|
|
userId: string;
|
|
name: string;
|
|
email: string | null;
|
|
phone: string | null;
|
|
/**
|
|
* Whether the SMS gateway (domestic-only) can reach `phone`. `null` when
|
|
* there is no phone. The backoffice uses this to disable the SMS channel for
|
|
* foreign numbers instead of sending a link that will never arrive.
|
|
*/
|
|
phoneIsDomestic: boolean | null;
|
|
}
|
|
|
|
export interface SentResetLink {
|
|
channel: ResetChannel;
|
|
maskedTarget: string;
|
|
expiresAt: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class CustomerResetService {
|
|
private readonly logger = new Logger(CustomerResetService.name);
|
|
|
|
constructor(
|
|
@InjectRepository(ExternalProfile)
|
|
private readonly externalProfileRepository: Repository<ExternalProfile>,
|
|
private readonly forgotPasswordService: ForgotPasswordService,
|
|
private readonly emailClient: EmailClientService,
|
|
private readonly smsClient: SmsClientService,
|
|
private readonly config: ConfigService,
|
|
) {}
|
|
|
|
/**
|
|
* The IAM account a reset would actually reach. The backoffice shows these
|
|
* values rather than `company.email` / `company.phone`: the company row holds
|
|
* business contact detail, while the link is delivered to the primary
|
|
* contact's own login credentials — the two drift apart routinely, and showing
|
|
* the wrong one has staff telling customers to check an inbox nothing was sent
|
|
* to.
|
|
*/
|
|
async getResetTarget(companyId: string): Promise<CustomerResetTarget | null> {
|
|
const resolved = await this.resolvePrimaryContactUser(companyId);
|
|
if (!resolved) return null;
|
|
|
|
const { profile, user, userId } = resolved;
|
|
return {
|
|
userId,
|
|
name: `${profile.firstName} ${profile.lastName}`.trim(),
|
|
email: user.email ?? null,
|
|
phone: user.phoneNumber ?? null,
|
|
phoneIsDomestic: user.phoneNumber
|
|
? isDomesticPhone(user.phoneNumber)
|
|
: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Mint a password-reset link and send it to the company's primary contact.
|
|
* Returns the masked destination, or null when there is no eligible account
|
|
* for that channel.
|
|
*
|
|
* Unlike the public flow this reports failure honestly — the caller is an
|
|
* authenticated staff member, so there is nothing to enumerate.
|
|
*/
|
|
async sendResetLinkToCustomer(
|
|
companyId: string,
|
|
channel: ResetChannel,
|
|
): Promise<SentResetLink | null> {
|
|
const resolved = await this.resolvePrimaryContactUser(companyId);
|
|
if (!resolved) return null;
|
|
|
|
return this.sendResetLinkToUser(resolved.userId, channel, {
|
|
scope: `company ${companyId}`,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mint and deliver a reset link to a specific IAM account.
|
|
*
|
|
* The delivery half of {@link sendResetLinkToCustomer}, split out so callers
|
|
* that resolve their target differently can reuse it: a customer is found via
|
|
* the company's primary contact, while a shipping line has no contact row at
|
|
* all and resolves straight off its own record. Everything below the lookup —
|
|
* active-account gating, the domestic-SMS rule, mint-before-send, the
|
|
* undelivered-link diagnostic — is identical for both and must stay that way.
|
|
*
|
|
* `scope` only labels the log line with whatever the caller resolved from.
|
|
*
|
|
* `allowWithoutCredential` relaxes the lookup for first-time activation:
|
|
* the default gate requires an existing active credential (so a reset cannot
|
|
* revive a suspended account), but an account that has never set a password
|
|
* has no credential row yet and would be excluded from its own activation
|
|
* link. Callers pass it only when the account is expected to be
|
|
* password-less — see ShippingLineCompaniesService.
|
|
*/
|
|
async sendResetLinkToUser(
|
|
userId: string,
|
|
channel: ResetChannel,
|
|
options?: { scope?: string; allowWithoutCredential?: boolean },
|
|
): Promise<SentResetLink | null> {
|
|
const sent = await this.sendResetLinkToUserOnChannels(userId, [channel], options);
|
|
return sent[0] ?? null;
|
|
}
|
|
|
|
/**
|
|
* One ticket, several channels. Minting retires every earlier ticket for the
|
|
* user (`mintResetTicket`), so sending email and SMS as two separate mints
|
|
* makes the first link dead on arrival — the same link must go to both.
|
|
* Returns one entry per channel that was actually sent (unreachable channels
|
|
* are skipped, not errors).
|
|
*/
|
|
async sendResetLinkToUserOnChannels(
|
|
userId: string,
|
|
channels: ResetChannel[],
|
|
options?: { scope?: string; allowWithoutCredential?: boolean },
|
|
): Promise<SentResetLink[]> {
|
|
const user = options?.allowWithoutCredential
|
|
? await this.forgotPasswordService.resolveActivatableUserById(userId)
|
|
: await this.forgotPasswordService.resolveActiveUserById(userId);
|
|
|
|
if (!user?.id) {
|
|
this.logger.warn(
|
|
`User ${userId} is not an active account${
|
|
options?.allowWithoutCredential
|
|
? ""
|
|
: " (or has no active credential — pass allowWithoutCredential for first-time activation)"
|
|
}`,
|
|
);
|
|
return [];
|
|
}
|
|
|
|
// Mint once, before any send: a failed send leaves an unused ticket that
|
|
// simply expires, whereas sending a link before the ticket exists would
|
|
// hand the customer a URL that is dead on arrival.
|
|
let ticket: ResetTicket | null = null;
|
|
const sent: SentResetLink[] = [];
|
|
for (const channel of channels) {
|
|
const target = this.forgotPasswordService.targetFor(user, channel);
|
|
if (!target) continue;
|
|
// The gateway silently drops foreign numbers — treat like a missing phone
|
|
// rather than reporting "link sent" for a message that will never arrive.
|
|
// The backoffice disables the channel up front via `phoneIsDomestic`; this
|
|
// guards direct API calls.
|
|
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
|
|
this.logger.warn(
|
|
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
|
|
);
|
|
continue;
|
|
}
|
|
ticket ??= await this.forgotPasswordService.mintResetTicket(
|
|
user.id,
|
|
RESET_LINK_TTL_MS,
|
|
);
|
|
const result = await this.deliverResetLink(
|
|
target,
|
|
user.id,
|
|
channel,
|
|
ticket,
|
|
options?.scope,
|
|
);
|
|
if (result) sent.push(result);
|
|
}
|
|
return sent;
|
|
}
|
|
|
|
/**
|
|
* Shared tail: send the already-minted ticket to a resolved target → report.
|
|
*/
|
|
private async deliverResetLink(
|
|
target: OtpTarget,
|
|
userId: string,
|
|
channel: ResetChannel,
|
|
ticket: ResetTicket,
|
|
scope?: string,
|
|
): Promise<SentResetLink | null> {
|
|
|
|
// A foreign number is unreachable by the domestic-only SMS gateway — treat
|
|
// it like a missing phone rather than reporting "link sent" for a message
|
|
const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
|
|
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
|
|
|
|
const { queued } = target.email
|
|
? await this.emailClient.sendEmail({
|
|
to: target.email,
|
|
subject: "Reset your EDR Freight password",
|
|
text:
|
|
"A password reset was started for your EDR Freight account.\n\n" +
|
|
`Open this link to choose a new password:\n${link}\n\n` +
|
|
"The link expires in 24 hours and can only be used once. If you did " +
|
|
"not expect this, ignore this message — your password stays unchanged.",
|
|
})
|
|
: await this.smsClient.sendSms({
|
|
to: target.phone as string,
|
|
message: `Reset your EDR Freight password: ${link} (expires in 24 hours, single use)`,
|
|
});
|
|
|
|
this.logger.log(
|
|
`Staff-triggered shipping line ${channel} reset link sent to user ${userId}${
|
|
scope ? ` (${scope})` : ""
|
|
} queued=${queued}`,
|
|
);
|
|
|
|
// SECURITY: logs a live password-reset credential in cleartext. Anyone with
|
|
// read access to the log stream can set the password for the account named
|
|
// on the same line — including on sends that succeeded, not just failures.
|
|
// Kept deliberately: log aggregation is the debugging path for flaky
|
|
// email/SMS here, the same tradeoff otp.service.ts makes for OTP codes. If
|
|
// that is ever revisited, gate this on an env flag rather than deleting it,
|
|
// so dev keeps its workflow.
|
|
this.logger.warn(
|
|
`reset-link.cleartext channel=${channel} user=${userId}${
|
|
scope ? ` (${scope})` : ""
|
|
} link=${link}`,
|
|
);
|
|
|
|
if (!queued) {
|
|
// The ticket is committed and the backoffice is about to say "link sent",
|
|
// but nothing left this process — with RABBITMQ_ENABLED=false both clients
|
|
// are no-ops. Without this line the only symptom is a customer who never
|
|
// receives anything, indistinguishable from carrier loss.
|
|
this.logger.error(
|
|
`reset-link.dispatch.dropped channel=${channel} user=${userId} rabbitmqEnabled=${
|
|
process.env.RABBITMQ_ENABLED ?? "unset"
|
|
} — transport reported no hand-off; no link will arrive`,
|
|
);
|
|
// SECURITY: logs a live password-reset credential in cleartext. Same
|
|
// deliberate tradeoff the OTP service makes — this is the only way to
|
|
// complete a reset on an environment with no broker. Only reached when
|
|
// delivery already failed.
|
|
this.logger.warn(`Undelivered reset link for user ${userId}: ${link}`);
|
|
}
|
|
|
|
return {
|
|
channel,
|
|
maskedTarget: maskOtpTarget(target),
|
|
expiresAt: expiresAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The company's primary contact, gated on the same active-account rule the
|
|
* public flow uses — so a suspended customer cannot be reactivated by a
|
|
* staff-triggered reset (IAM's `set-password` flips `isActive` back on).
|
|
*/
|
|
private async resolvePrimaryContactUser(companyId: string) {
|
|
const profile = await this.externalProfileRepository.findOne({
|
|
where: { companyId, isPrimaryContact: true },
|
|
});
|
|
|
|
if (!profile) {
|
|
this.logger.warn(`Company ${companyId} has no primary contact profile`);
|
|
return null;
|
|
}
|
|
|
|
const user = await this.forgotPasswordService.resolveActiveUserById(
|
|
profile.userId,
|
|
);
|
|
if (!user?.id) {
|
|
this.logger.warn(
|
|
`Primary contact ${profile.userId} of company ${companyId} is not an active account`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
return { profile, user, userId: user.id };
|
|
}
|
|
|
|
/**
|
|
* The portal route that trades the token for a set-password form. Params are
|
|
* URL-encoded because the token is base64url — safe as-is, but the encoding
|
|
* keeps this correct if the token format ever changes.
|
|
*/
|
|
private buildResetLink(userId: string, token: string): string {
|
|
const base = this.config.get<string>("app.portalBaseUrl");
|
|
return `${base}/reset-password?uid=${encodeURIComponent(
|
|
userId,
|
|
)}&token=${encodeURIComponent(token)}`;
|
|
}
|
|
}
|