Files
edr-platform/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts

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