Add CSRF protection and rate limiting features in BFF. Introduce new environment variables for CSRF configuration and enhance CSRF middleware for better token management. Implement rate limiting for authentication and login processes, including new throttler configurations. Update package.json to include rate-limiter-flexible dependency. Refactor related services and controllers to support these security enhancements.

This commit is contained in:
barsa 2025-10-02 14:16:46 +09:00
parent a9bff8c823
commit 336fe2cf59
23 changed files with 497 additions and 596 deletions

View File

@ -52,6 +52,7 @@
"express": "^5.1.0",
"helmet": "^8.1.0",
"ioredis": "^5.7.0",
"rate-limiter-flexible": "^4.0.0",
"jsforce": "^3.10.4",
"jsonwebtoken": "^9.0.2",
"nestjs-pino": "^4.4.0",

View File

@ -11,6 +11,11 @@ export const envSchema = z.object({
BCRYPT_ROUNDS: z.coerce.number().int().min(10).max(16).default(12),
APP_BASE_URL: z.string().url().default("http://localhost:3000"),
CSRF_TOKEN_EXPIRY: z.coerce.number().int().positive().default(3600000),
CSRF_SECRET_KEY: z.string().min(32, "CSRF secret key must be at least 32 characters").optional(),
CSRF_COOKIE_NAME: z.string().default("csrf-secret"),
CSRF_HEADER_NAME: z.string().default("X-CSRF-Token"),
CORS_ORIGIN: z.string().url().optional(),
TRUST_PROXY: z.enum(["true", "false"]).default("false"),
@ -18,6 +23,20 @@ export const envSchema = z.object({
RATE_LIMIT_LIMIT: z.coerce.number().int().positive().default(100),
AUTH_RATE_LIMIT_TTL: z.coerce.number().int().positive().default(900000),
AUTH_RATE_LIMIT_LIMIT: z.coerce.number().int().positive().default(3),
AUTH_REFRESH_RATE_LIMIT_TTL: z.coerce.number().int().positive().default(300000),
AUTH_REFRESH_RATE_LIMIT_LIMIT: z.coerce.number().int().positive().default(10),
AUTH_CAPTCHA_PROVIDER: z.enum(["none", "turnstile", "hcaptcha"]).default("none"),
AUTH_CAPTCHA_SECRET: z.string().optional(),
AUTH_CAPTCHA_THRESHOLD: z.coerce.number().min(0).default(0),
AUTH_CAPTCHA_ALWAYS_ON: z.enum(["true", "false"]).default("false"),
LOGIN_RATE_LIMIT_TTL: z.coerce.number().int().positive().default(900000),
LOGIN_RATE_LIMIT_LIMIT: z.coerce.number().int().positive().default(5),
LOGIN_CAPTCHA_AFTER_ATTEMPTS: z.coerce.number().int().nonnegative().default(3),
SIGNUP_RATE_LIMIT_TTL: z.coerce.number().int().positive().default(900000),
SIGNUP_RATE_LIMIT_LIMIT: z.coerce.number().int().positive().default(5),
PASSWORD_RESET_RATE_LIMIT_TTL: z.coerce.number().int().positive().default(900000),
PASSWORD_RESET_RATE_LIMIT_LIMIT: z.coerce.number().int().positive().default(5),
REDIS_URL: z.string().url().default("redis://localhost:6379"),
AUTH_ALLOW_REDIS_TOKEN_FAILOPEN: z.enum(["true", "false"]).default("false"),

View File

@ -11,4 +11,9 @@ export const createThrottlerConfig = (configService: ConfigService): ThrottlerMo
ttl: configService.get<number>("AUTH_RATE_LIMIT_TTL", 600000),
limit: configService.get<number>("AUTH_RATE_LIMIT_LIMIT", 3),
},
{
name: "auth-refresh",
ttl: configService.get<number>("AUTH_REFRESH_RATE_LIMIT_TTL", 300000),
limit: configService.get<number>("AUTH_REFRESH_RATE_LIMIT_LIMIT", 10),
},
];

View File

@ -159,19 +159,11 @@ export class CsrfMiddleware implements NestMiddleware {
const sessionId = req.user?.sessionId || this.extractSessionId(req);
const userId = req.user?.id;
// If we already have a valid secret, we don't need to generate a new token
if (existingSecret) {
return next();
}
const tokenData = this.csrfService.generateToken(existingSecret, sessionId || undefined, userId);
// Generate new CSRF token
const tokenData = this.csrfService.generateToken(sessionId || undefined, userId);
this.setCsrfSecretCookie(res, tokenData.secret, tokenData.expiresAt);
// Set CSRF secret in secure, SameSite cookie
this.setCsrfSecretCookie(res, tokenData.secret);
// Set CSRF token in response header for client to use
res.setHeader("X-CSRF-Token", tokenData.token);
res.setHeader(this.csrfService.getHeaderName(), tokenData.token);
this.logger.debug("CSRF token generated and set", {
method: req.method,
@ -216,7 +208,8 @@ export class CsrfMiddleware implements NestMiddleware {
}
private extractSecretFromCookie(req: CsrfRequest): string | null {
return req.cookies?.["csrf-secret"] || null;
const cookieName = this.csrfService.getCookieName();
return req.cookies?.[cookieName] || null;
}
private extractSessionId(req: CsrfRequest): string | null {
@ -224,15 +217,15 @@ export class CsrfMiddleware implements NestMiddleware {
return req.cookies?.["session-id"] || req.cookies?.["connect.sid"] || req.sessionID || null;
}
private setCsrfSecretCookie(res: Response, secret: string): void {
private setCsrfSecretCookie(res: Response, secret: string, expiresAt?: Date): void {
const cookieOptions = {
httpOnly: true,
secure: this.isProduction,
sameSite: "strict" as const,
maxAge: 3600000, // 1 hour
maxAge: expiresAt ? Math.max(0, expiresAt.getTime() - Date.now()) : undefined,
path: "/",
};
res.cookie("csrf-secret", secret, cookieOptions);
res.cookie(this.csrfService.getCookieName(), secret, cookieOptions);
}
}

View File

@ -14,74 +14,76 @@ export interface CsrfTokenData {
export interface CsrfValidationResult {
isValid: boolean;
reason?: string;
tokenData?: CsrfTokenData;
}
/**
* Service for CSRF token generation and validation
* Implements double-submit cookie pattern with additional security measures
* Service for CSRF token generation and validation using deterministic HMAC tokens.
*/
@Injectable()
export class CsrfService {
private readonly tokenExpiry: number; // Token expiry in milliseconds
private readonly tokenExpiry: number;
private readonly secretKey: string;
private readonly tokenCache = new Map<string, CsrfTokenData>();
private readonly maxCacheSize = 10000; // Prevent memory leaks
private readonly cookieName: string;
private readonly headerName: string;
constructor(
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {
this.tokenExpiry = Number(this.configService.get("CSRF_TOKEN_EXPIRY", "3600000")); // 1 hour default
this.tokenExpiry = Number(this.configService.get("CSRF_TOKEN_EXPIRY", "3600000"));
this.secretKey = this.configService.get("CSRF_SECRET_KEY") || this.generateSecretKey();
this.cookieName = this.configService.get("CSRF_COOKIE_NAME", "csrf-secret");
this.headerName = this.configService.get("CSRF_HEADER_NAME", "X-CSRF-Token");
if (!this.configService.get("CSRF_SECRET_KEY")) {
this.logger.warn(
"CSRF_SECRET_KEY not configured, using generated key (not suitable for production)"
"CSRF_SECRET_KEY not configured, using ephemeral key (not suitable for production)"
);
}
// Clean up expired tokens periodically
setInterval(() => this.cleanupExpiredTokens(), 300000); // Every 5 minutes
}
/**
* Generate a new CSRF token for a user session
*/
generateToken(sessionId?: string, userId?: string): CsrfTokenData {
const secret = this.generateSecret();
const token = this.generateTokenFromSecret(secret, sessionId, userId);
const expiresAt = new Date(Date.now() + this.tokenExpiry);
getCookieName(): string {
return this.cookieName;
}
const tokenData: CsrfTokenData = {
token,
secret,
expiresAt,
sessionId,
userId,
};
getHeaderName(): string {
return this.headerName;
}
// Store in cache for validation
this.tokenCache.set(token, tokenData);
getTokenTtl(): number {
return this.tokenExpiry;
}
// Prevent memory leaks
if (this.tokenCache.size > this.maxCacheSize) {
this.cleanupExpiredTokens();
}
generateToken(
existingSecret?: string | null,
sessionId?: string,
userId?: string
): CsrfTokenData {
const issuedAt = Date.now();
const secret = existingSecret ?? this.generateSecret();
const token = this.signToken(secret, sessionId, userId, issuedAt);
const expiresAt = new Date(issuedAt + this.tokenExpiry);
this.logger.debug("CSRF token generated", {
tokenHash: this.hashToken(token),
sessionId,
userId,
expiresAt: expiresAt.toISOString(),
reusedSecret: Boolean(existingSecret),
});
return tokenData;
return {
token,
secret,
expiresAt,
sessionId,
userId,
};
}
/**
* Validate a CSRF token against the provided secret
*/
validateToken(
token: string,
secret: string,
@ -89,169 +91,54 @@ export class CsrfService {
userId?: string
): CsrfValidationResult {
if (!token || !secret) {
return {
isValid: false,
reason: "Missing token or secret",
};
return { isValid: false, reason: "Missing token or secret" };
}
// Check if token exists in cache
const cachedTokenData = this.tokenCache.get(token);
if (!cachedTokenData) {
return {
isValid: false,
reason: "Token not found or expired",
};
}
// Check expiry
if (cachedTokenData.expiresAt < new Date()) {
this.tokenCache.delete(token);
return {
isValid: false,
reason: "Token expired",
};
}
// Validate secret matches
if (cachedTokenData.secret !== secret) {
this.logger.warn("CSRF token validation failed - secret mismatch", {
const parsed = this.parseToken(token);
if (!parsed) {
this.logger.warn("CSRF token validation failed - malformed token", {
tokenHash: this.hashToken(token),
sessionId,
userId,
});
return {
isValid: false,
reason: "Invalid secret",
};
return { isValid: false, reason: "Malformed token" };
}
// Validate session binding (if provided)
if (sessionId && cachedTokenData.sessionId && cachedTokenData.sessionId !== sessionId) {
this.logger.warn("CSRF token validation failed - session mismatch", {
tokenHash: this.hashToken(token),
expectedSession: cachedTokenData.sessionId,
providedSession: sessionId,
});
return {
isValid: false,
reason: "Session mismatch",
};
const { issuedAt } = parsed;
if (Date.now() > issuedAt + this.tokenExpiry) {
return { isValid: false, reason: "Token expired" };
}
// Validate user binding (if provided)
if (userId && cachedTokenData.userId && cachedTokenData.userId !== userId) {
this.logger.warn("CSRF token validation failed - user mismatch", {
tokenHash: this.hashToken(token),
expectedUser: cachedTokenData.userId,
providedUser: userId,
});
return {
isValid: false,
reason: "User mismatch",
};
}
// Regenerate expected token to prevent timing attacks
const expectedToken = this.generateTokenFromSecret(
cachedTokenData.secret,
cachedTokenData.sessionId,
cachedTokenData.userId
);
// Constant-time comparison
const expectedToken = this.signToken(secret, sessionId, userId, issuedAt);
if (!this.constantTimeEquals(token, expectedToken)) {
this.logger.warn("CSRF token validation failed - token mismatch", {
this.logger.warn("CSRF token validation failed - signature mismatch", {
tokenHash: this.hashToken(token),
sessionId,
userId,
});
return {
isValid: false,
reason: "Invalid token",
};
return { isValid: false, reason: "Invalid token" };
}
this.logger.debug("CSRF token validated successfully", {
tokenHash: this.hashToken(token),
sessionId,
userId,
});
return {
isValid: true,
tokenData: cachedTokenData,
};
return { isValid: true };
}
/**
* Invalidate a specific token
*/
invalidateToken(token: string): void {
this.tokenCache.delete(token);
this.logger.debug("CSRF token invalidated", {
tokenHash: this.hashToken(token),
});
invalidateToken(_token: string): void {
// Stateless tokens are tied to the secret cookie; rotate cookie to invalidate.
this.logger.debug("invalidateToken called for stateless CSRF token");
}
/**
* Invalidate all tokens for a specific session
*/
invalidateSessionTokens(sessionId: string): void {
let invalidatedCount = 0;
for (const [token, tokenData] of this.tokenCache.entries()) {
if (tokenData.sessionId === sessionId) {
this.tokenCache.delete(token);
invalidatedCount++;
}
}
this.logger.debug("CSRF tokens invalidated for session", {
sessionId,
invalidatedCount,
});
invalidateSessionTokens(_sessionId: string): void {
this.logger.debug("invalidateSessionTokens called - rotate cookie to enforce");
}
/**
* Invalidate all tokens for a specific user
*/
invalidateUserTokens(userId: string): void {
let invalidatedCount = 0;
for (const [token, tokenData] of this.tokenCache.entries()) {
if (tokenData.userId === userId) {
this.tokenCache.delete(token);
invalidatedCount++;
}
}
this.logger.debug("CSRF tokens invalidated for user", {
userId,
invalidatedCount,
});
invalidateUserTokens(_userId: string): void {
this.logger.debug("invalidateUserTokens called - rotate cookie to enforce");
}
/**
* Get token statistics for monitoring
*/
getTokenStats() {
const now = new Date();
let activeTokens = 0;
let expiredTokens = 0;
for (const tokenData of this.tokenCache.values()) {
if (tokenData.expiresAt > now) {
activeTokens++;
} else {
expiredTokens++;
}
}
return {
totalTokens: this.tokenCache.size,
activeTokens,
expiredTokens,
cacheSize: this.tokenCache.size,
maxCacheSize: this.maxCacheSize,
mode: "stateless",
totalTokens: 0,
activeTokens: 0,
expiredTokens: 0,
};
}
@ -259,11 +146,29 @@ export class CsrfService {
return crypto.randomBytes(32).toString("base64url");
}
private generateTokenFromSecret(secret: string, sessionId?: string, userId?: string): string {
const data = [secret, sessionId || "", userId || ""].join("|");
const hmac = crypto.createHmac("sha256", this.secretKey);
hmac.update(data);
return hmac.digest("base64url");
private signToken(
secret: string,
sessionId: string | undefined,
userId: string | undefined,
issuedAt: number
): string {
const payload = [issuedAt.toString(), secret, sessionId || "", userId || ""].join(":");
const hmac = crypto.createHmac("sha256", this.secretKey).update(payload).digest("base64url");
return `${issuedAt}.${hmac}`;
}
private parseToken(token: string): { issuedAt: number } | null {
const [issuedAtRaw, signature] = token.split(".");
if (!issuedAtRaw || !signature) {
return null;
}
const issuedAt = Number(issuedAtRaw);
if (!Number.isFinite(issuedAt)) {
return null;
}
return { issuedAt };
}
private generateSecretKey(): string {
@ -275,7 +180,6 @@ export class CsrfService {
}
private hashToken(token: string): string {
// Create a hash of the token for logging (never log the actual token)
return crypto.createHash("sha256").update(token).digest("hex").substring(0, 16);
}
@ -291,23 +195,4 @@ export class CsrfService {
return result === 0;
}
private cleanupExpiredTokens(): void {
const now = new Date();
let cleanedCount = 0;
for (const [token, tokenData] of this.tokenCache.entries()) {
if (tokenData.expiresAt < now) {
this.tokenCache.delete(token);
cleanedCount++;
}
}
if (cleanedCount > 0) {
this.logger.debug("Cleaned up expired CSRF tokens", {
cleanedCount,
remainingTokens: this.tokenCache.size,
});
}
}
}

View File

@ -67,9 +67,16 @@ export class WhmcsSubscriptionService {
productCount: Array.isArray((response as any)?.products?.product) ? (response as any).products.product.length : 0,
});
if (!response.products?.product) {
const productData = response.products?.product;
const products = Array.isArray(productData)
? productData
: productData
? [productData]
: [];
if (products.length === 0) {
this.logger.warn(`No products found for client ${clientId}`, {
responseStructure: response ? Object.keys(response) : 'null response',
responseStructure: response ? Object.keys(response) : "null response",
});
return {
subscriptions: [],
@ -77,8 +84,7 @@ export class WhmcsSubscriptionService {
};
}
// Transform subscriptions
const subscriptions = response.products.product
const subscriptions = products
.map(whmcsProduct => {
try {
return this.subscriptionTransformer.transformSubscription(whmcsProduct);

View File

@ -3,7 +3,6 @@ import { Logger } from "nestjs-pino";
import { Invoice, InvoiceItem as BaseInvoiceItem } from "@customer-portal/domain";
import type { WhmcsInvoice, WhmcsInvoiceItems } from "../../types/whmcs-api.types";
import { DataUtils } from "../utils/data-utils";
import { StatusNormalizer } from "../utils/status-normalizer";
import { TransformationValidator } from "../validators/transformation-validator";
import { WhmcsCurrencyService } from "../../services/whmcs-currency.service";
@ -48,9 +47,7 @@ export class InvoiceTransformerService {
const paidDate = DataUtils.formatDate(whmcsInvoice.datepaid);
const issuedAt = DataUtils.formatDate(whmcsInvoice.date || whmcsInvoice.datecreated);
// Calculate days overdue if applicable
const finalStatus = StatusNormalizer.determineInvoiceStatus(whmcsInvoice.status, dueDate);
const daysOverdue = finalStatus === "Overdue" ? StatusNormalizer.calculateDaysOverdue(dueDate) : undefined;
const finalStatus = this.mapInvoiceStatus(whmcsInvoice.status);
const invoice: Invoice = {
id: Number(invoiceId),
@ -66,7 +63,7 @@ export class InvoiceTransformerService {
paidDate,
description: whmcsInvoice.notes || undefined,
items: this.transformInvoiceItems(whmcsInvoice.items),
daysOverdue,
daysOverdue: undefined,
};
if (!this.validator.validateInvoice(invoice)) {
@ -77,8 +74,6 @@ export class InvoiceTransformerService {
originalStatus: whmcsInvoice.status,
finalStatus: invoice.status,
dueDate: invoice.dueDate,
isOverdue: StatusNormalizer.isInvoiceOverdue(invoice.dueDate),
daysOverdue: StatusNormalizer.calculateDaysOverdue(invoice.dueDate),
total: invoice.total,
currency: invoice.currency,
itemCount: invoice.items?.length || 0,
@ -167,4 +162,22 @@ export class InvoiceTransformerService {
return results;
}
private mapInvoiceStatus(status: string): Invoice["status"] {
const allowed: Invoice["status"][] = [
"Draft",
"Unpaid",
"Paid",
"Cancelled",
"Refunded",
"Collections",
"Overdue",
];
if (allowed.includes(status as Invoice["status"])) {
return status as Invoice["status"];
}
throw new Error(`Unsupported WHMCS invoice status: ${status}`);
}
}

View File

@ -25,9 +25,6 @@ export class PaymentTransformerService {
displayName: whmcsGateway.display_name,
type: whmcsGateway.type,
isActive: whmcsGateway.active,
acceptsCreditCards: whmcsGateway.accepts_credit_cards || false,
acceptsBankAccount: whmcsGateway.accepts_bank_account || false,
supportsTokenization: whmcsGateway.supports_tokenization || false,
};
if (!this.validator.validatePaymentGateway(gateway)) {
@ -52,33 +49,20 @@ export class PaymentTransformerService {
id: whmcsPayMethod.id,
type: whmcsPayMethod.type,
description: whmcsPayMethod.description,
gatewayName: whmcsPayMethod.gateway_name || "",
isDefault: false, // Default value, can be set by calling service
gatewayName: whmcsPayMethod.gateway_name ?? undefined,
isDefault: false,
lastFour: whmcsPayMethod.card_last_four ?? undefined,
expiryDate: whmcsPayMethod.expiry_date ?? undefined,
bankName: whmcsPayMethod.bank_name ?? undefined,
accountType: whmcsPayMethod.account_type ?? undefined,
remoteToken: whmcsPayMethod.remote_token ?? undefined,
ccType: whmcsPayMethod.card_type ?? undefined,
cardBrand: whmcsPayMethod.card_type ?? undefined,
billingContactId: whmcsPayMethod.billing_contact_id ?? undefined,
createdAt: whmcsPayMethod.created_at ?? undefined,
updatedAt: whmcsPayMethod.updated_at ?? undefined,
};
// Add credit card specific fields
if (whmcsPayMethod.card_last_four) {
transformed.lastFour = whmcsPayMethod.card_last_four;
}
if (whmcsPayMethod.card_type) {
transformed.ccType = whmcsPayMethod.card_type;
transformed.cardBrand = whmcsPayMethod.card_type;
}
if (whmcsPayMethod.expiry_date) {
transformed.expiryDate = this.normalizeExpiryDate(whmcsPayMethod.expiry_date);
}
// Add bank account specific fields
if (whmcsPayMethod.account_type) {
transformed.accountType = whmcsPayMethod.account_type;
}
if (whmcsPayMethod.bank_name) {
transformed.bankName = whmcsPayMethod.bank_name;
}
if (!this.validator.validatePaymentMethod(transformed)) {
throw new Error("Transformed payment method failed validation");
}
@ -86,29 +70,6 @@ export class PaymentTransformerService {
return transformed;
}
/**
* Normalize expiry date to MM/YY format
*/
private normalizeExpiryDate(expiryDate: string): string {
if (!expiryDate) return "";
// Handle various formats: MM/YY, MM/YYYY, MMYY, MMYYYY
const cleaned = expiryDate.replace(/\D/g, "");
if (cleaned.length === 4) {
// MMYY format
return `${cleaned.substring(0, 2)}/${cleaned.substring(2, 4)}`;
}
if (cleaned.length === 6) {
// MMYYYY format - convert to MM/YY
return `${cleaned.substring(0, 2)}/${cleaned.substring(4, 6)}`;
}
// Return as-is if we can't parse it
return expiryDate;
}
/**
* Transform multiple payment methods in batch
*/
@ -184,54 +145,4 @@ export class PaymentTransformerService {
/**
* Normalize gateway type to match our enum
*/
private normalizeGatewayType(
type: string
): "merchant" | "thirdparty" | "tokenization" | "manual" {
const normalizedType = type.toLowerCase();
switch (normalizedType) {
case "merchant":
case "credit_card":
case "creditcard":
return "merchant";
case "thirdparty":
case "third_party":
case "external":
return "thirdparty";
case "tokenization":
case "token":
return "tokenization";
default:
return "manual";
}
}
/**
* Normalize payment method type to match our enum
*/
private normalizePaymentType(
gatewayName?: string
): "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount" | "Manual" {
if (!gatewayName) return "Manual";
const normalized = gatewayName.toLowerCase();
if (
normalized.includes("credit") ||
normalized.includes("card") ||
normalized.includes("visa") ||
normalized.includes("mastercard")
) {
return "CreditCard";
}
if (
normalized.includes("bank") ||
normalized.includes("ach") ||
normalized.includes("account")
) {
return "BankAccount";
}
if (normalized.includes("remote") || normalized.includes("token")) {
return "RemoteCreditCard";
}
return "Manual";
}
}

View File

@ -3,7 +3,6 @@ import { Logger } from "nestjs-pino";
import { Subscription } from "@customer-portal/domain";
import type { WhmcsProduct, WhmcsCustomField } from "../../types/whmcs-api.types";
import { DataUtils } from "../utils/data-utils";
import { StatusNormalizer } from "../utils/status-normalizer";
import { TransformationValidator } from "../validators/transformation-validator";
import { WhmcsCurrencyService } from "../../services/whmcs-currency.service";
@ -32,12 +31,7 @@ export class SubscriptionTransformerService {
const firstPaymentAmount = DataUtils.parseAmount(whmcsProduct.firstpaymentamount);
// Normalize billing cycle from WHMCS and apply safety overrides
let normalizedCycle = StatusNormalizer.normalizeBillingCycle(whmcsProduct.billingcycle);
// Safety override: If we have no recurring amount but have first payment, treat as one-time
if (recurringAmount === 0 && firstPaymentAmount > 0) {
normalizedCycle = "Monthly"; // Default to Monthly for one-time payments
}
const billingCycle = this.mapBillingCycle(whmcsProduct.billingcycle);
// Use WHMCS system default currency
const defaultCurrency = this.currencyService.getDefaultCurrency();
@ -47,8 +41,8 @@ export class SubscriptionTransformerService {
serviceId: Number(whmcsProduct.id), // In WHMCS, product ID is the service ID
productName: whmcsProduct.productname || whmcsProduct.name,
domain: whmcsProduct.domain,
status: StatusNormalizer.normalizeProductStatus(whmcsProduct.status),
cycle: normalizedCycle,
status: this.mapSubscriptionStatus(whmcsProduct.status),
cycle: billingCycle,
amount: this.getProductAmount(whmcsProduct),
currency: defaultCurrency.code,
currencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
@ -93,15 +87,18 @@ export class SubscriptionTransformerService {
* Get the appropriate amount for a product (recurring vs first payment)
*/
private getProductAmount(whmcsProduct: WhmcsProduct): number {
// Prioritize recurring amount, fallback to first payment amount
const recurringAmount = DataUtils.parseAmount(whmcsProduct.recurringamount);
const firstPaymentAmount = DataUtils.parseAmount(whmcsProduct.firstpaymentamount);
return recurringAmount > 0 ? recurringAmount : firstPaymentAmount;
if (recurringAmount > 0) {
return recurringAmount;
}
return firstPaymentAmount;
}
/**
* Extract and normalize custom fields from WHMCS format
* Extract custom fields from WHMCS format without renaming
*/
private extractCustomFields(
customFields: WhmcsCustomField[] | undefined
@ -112,12 +109,9 @@ export class SubscriptionTransformerService {
try {
const fields: Record<string, string> = {};
for (const field of customFields) {
if (field && typeof field === "object" && field.name && field.value) {
// Normalize field name (remove special characters, convert to camelCase)
const normalizedName = this.normalizeFieldName(field.name);
fields[normalizedName] = field.value;
fields[field.name] = field.value;
}
}
@ -134,11 +128,40 @@ export class SubscriptionTransformerService {
/**
* Normalize field name to camelCase
*/
private normalizeFieldName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+(.)/g, (_match: string, char: string) => char.toUpperCase())
.replace(/^[^a-z]+/, "");
private mapSubscriptionStatus(status: string): Subscription["status"] {
const allowed: Subscription["status"][] = [
"Active",
"Suspended",
"Terminated",
"Cancelled",
"Pending",
"Completed",
];
if (allowed.includes(status as Subscription["status"])) {
return status as Subscription["status"];
}
throw new Error(`Unsupported WHMCS subscription status: ${status}`);
}
private mapBillingCycle(cycle: string): Subscription["cycle"] {
const allowed: Subscription["cycle"][] = [
"Monthly",
"Quarterly",
"Semi-Annually",
"Annually",
"Biennially",
"Triennially",
"One-time",
"Free",
];
if (allowed.includes(cycle as Subscription["cycle"])) {
return cycle as Subscription["cycle"];
}
throw new Error(`Unsupported WHMCS billing cycle: ${cycle}`);
}
/**
@ -179,14 +202,14 @@ export class SubscriptionTransformerService {
* Check if subscription is active
*/
isActiveSubscription(subscription: Subscription): boolean {
return StatusNormalizer.isActiveStatus(subscription.status);
return subscription.status === "Active";
}
/**
* Check if subscription has one-time billing
*/
isOneTimeSubscription(subscription: Subscription): boolean {
return StatusNormalizer.isOneTimeBilling(subscription.cycle);
return subscription.cycle === "One-time";
}
/**

View File

@ -1,158 +1 @@
import type {
InvoiceStatus,
SubscriptionStatus,
SubscriptionBillingCycle,
} from "@customer-portal/domain";
/**
* Utility class for normalizing WHMCS status values to domain enums
*/
export class StatusNormalizer {
/**
* Normalize invoice status to our standard values
*/
static normalizeInvoiceStatus(status: string): InvoiceStatus {
const statusMap: Record<string, InvoiceStatus> = {
paid: "Paid",
unpaid: "Unpaid",
cancelled: "Cancelled",
refunded: "Refunded",
overdue: "Overdue",
collections: "Collections",
draft: "Draft",
"payment pending": "Pending",
};
return statusMap[status?.toLowerCase()] || "Unpaid";
}
/**
* Determine the correct invoice status based on WHMCS status and due date
* This handles the case where WHMCS doesn't automatically update status to "Overdue"
*/
static determineInvoiceStatus(whmcsStatus: string, dueDate?: string): InvoiceStatus {
const normalizedStatus = this.normalizeInvoiceStatus(whmcsStatus);
// If already marked as paid, cancelled, refunded, etc., keep that status
if (["Paid", "Cancelled", "Refunded", "Collections", "Draft", "Pending"].includes(normalizedStatus)) {
return normalizedStatus;
}
// For unpaid invoices, check if they're actually overdue
if (normalizedStatus === "Unpaid" && dueDate) {
const dueDateObj = new Date(dueDate);
const today = new Date();
// Set time to start of day for accurate comparison
today.setHours(0, 0, 0, 0);
dueDateObj.setHours(0, 0, 0, 0);
if (dueDateObj < today) {
return "Overdue";
}
}
return normalizedStatus;
}
/**
* Normalize product status to our standard values
*/
static normalizeProductStatus(status: string): SubscriptionStatus {
const statusMap: Record<string, SubscriptionStatus> = {
active: "Active",
suspended: "Suspended",
terminated: "Terminated",
cancelled: "Cancelled",
pending: "Pending",
completed: "Completed",
};
return statusMap[status?.toLowerCase()] || "Pending";
}
/**
* Normalize billing cycle to our standard values
*/
static normalizeBillingCycle(cycle: string): SubscriptionBillingCycle {
const cycleMap: Record<string, SubscriptionBillingCycle> = {
monthly: "Monthly",
quarterly: "Quarterly",
semiannually: "Semi-Annually",
"semi-annually": "Semi-Annually",
annually: "Annually",
biennially: "Biennially",
triennially: "Triennially",
onetime: "One-time",
"one time": "One-time",
free: "Free",
};
return cycleMap[cycle?.toLowerCase()] || "Monthly";
}
/**
* Check if billing cycle represents a one-time payment
*/
static isOneTimeBilling(cycle: string): boolean {
const oneTimeCycles = ["onetime", "one time", "one-time", "free"];
return oneTimeCycles.includes(cycle?.toLowerCase());
}
/**
* Check if status represents an active state
*/
static isActiveStatus(status: string): boolean {
const activeStatuses = ["active", "paid"];
return activeStatuses.includes(status?.toLowerCase());
}
/**
* Check if status represents a terminated/cancelled state
*/
static isTerminatedStatus(status: string): boolean {
const terminatedStatuses = ["terminated", "cancelled", "refunded"];
return terminatedStatuses.includes(status?.toLowerCase());
}
/**
* Check if status represents a pending state
*/
static isPendingStatus(status: string): boolean {
const pendingStatuses = ["pending", "draft", "payment pending"];
return pendingStatuses.includes(status?.toLowerCase());
}
/**
* Check if an invoice is overdue based on due date
*/
static isInvoiceOverdue(dueDate?: string): boolean {
if (!dueDate) return false;
const dueDateObj = new Date(dueDate);
const today = new Date();
// Set time to start of day for accurate comparison
today.setHours(0, 0, 0, 0);
dueDateObj.setHours(0, 0, 0, 0);
return dueDateObj < today;
}
/**
* Calculate days overdue for an invoice
*/
static calculateDaysOverdue(dueDate?: string): number {
if (!dueDate || !this.isInvoiceOverdue(dueDate)) return 0;
const dueDateObj = new Date(dueDate);
const today = new Date();
// Set time to start of day for accurate comparison
today.setHours(0, 0, 0, 0);
dueDateObj.setHours(0, 0, 0, 0);
const diffTime = today.getTime() - dueDateObj.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
}

View File

@ -145,9 +145,17 @@ export interface WhmcsProduct {
promoid?: number;
producttype?: string;
modulename?: string;
billingcycle: string;
billingcycle:
| "Monthly"
| "Quarterly"
| "Semi-Annually"
| "Annually"
| "Biennially"
| "Triennially"
| "One-time"
| "Free";
nextduedate: string;
status: string;
status: "Active" | "Suspended" | "Terminated" | "Cancelled" | "Pending" | "Completed";
username?: string;
password?: string;
subscriptionid?: string;
@ -322,9 +330,6 @@ export interface WhmcsPaymentGateway {
display_name: string;
type: "merchant" | "thirdparty" | "tokenization" | "manual";
active: boolean;
accepts_credit_cards?: boolean;
accepts_bank_account?: boolean;
supports_tokenization?: boolean;
}
export interface WhmcsPaymentGatewaysResponse {
@ -343,12 +348,12 @@ export interface WhmcsCreateInvoiceParams {
userid: number;
status?:
| "Draft"
| "Unpaid"
| "Paid"
| "Unpaid"
| "Cancelled"
| "Refunded"
| "Collections"
| "Payment Pending";
| "Overdue";
sendnotification?: boolean;
paymentmethod?: string;
taxrate?: number;
@ -378,12 +383,12 @@ export interface WhmcsUpdateInvoiceParams {
invoiceid: number;
status?:
| "Draft"
| "Unpaid"
| "Paid"
| "Unpaid"
| "Cancelled"
| "Refunded"
| "Collections"
| "Payment Pending";
| "Overdue";
duedate?: string; // YYYY-MM-DD format
notes?: string;
[key: string]: unknown;

View File

@ -268,7 +268,7 @@ export class AuthController {
@ApiOperation({ summary: "Request password reset email" })
@ApiResponse({ status: 200, description: "Reset email sent if account exists" })
async requestPasswordReset(@Body() body: PasswordResetRequestInput) {
await this.authService.requestPasswordReset(body.email);
await this.authService.requestPasswordReset(body.email, req);
return { message: "If an account exists, a reset email has been sent" };
}

View File

@ -21,6 +21,7 @@ import { PasswordWorkflowService } from "./services/workflows/password-workflow.
import { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workflow.service";
import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard";
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor";
import { AuthRateLimitService } from "./services/auth-rate-limit.service";
@Module({
imports: [
@ -49,6 +50,7 @@ import { LoginResultInterceptor } from "./interceptors/login-result.interceptor"
PasswordWorkflowService,
WhmcsLinkWorkflowService,
FailedLoginThrottleGuard,
AuthRateLimitService,
LoginResultInterceptor,
{
provide: APP_GUARD,

View File

@ -22,6 +22,7 @@ import type { User as PrismaUser } from "@prisma/client";
import type { Request } from "express";
import { PrismaService } from "@bff/infra/database/prisma.service";
import { AuthTokenService } from "./services/token.service";
import { AuthRateLimitService } from "./services/auth-rate-limit.service";
import { SignupWorkflowService } from "./services/workflows/signup-workflow.service";
import { PasswordWorkflowService } from "./services/workflows/password-workflow.service";
import { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workflow.service";
@ -46,6 +47,7 @@ export class AuthService {
private readonly passwordWorkflow: PasswordWorkflowService,
private readonly whmcsLinkWorkflow: WhmcsLinkWorkflowService,
private readonly tokenService: AuthTokenService,
private readonly authRateLimitService: AuthRateLimitService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -113,6 +115,9 @@ export class AuthService {
},
request?: Request
) {
if (request) {
await this.authRateLimitService.clearLoginAttempts(request);
}
// Update last login time and reset failed attempts
await this.usersService.update(user.id, {
lastLoginAt: new Date(),
@ -358,8 +363,8 @@ export class AuthService {
return sanitizeWhmcsRedirectPath(path);
}
async requestPasswordReset(email: string): Promise<void> {
await this.passwordWorkflow.requestPasswordReset(email);
async requestPasswordReset(email: string, request?: Request): Promise<void> {
await this.passwordWorkflow.requestPasswordReset(email, request);
}
async resetPassword(token: string, newPassword: string) {

View File

@ -1,75 +1,24 @@
import { Injectable, ExecutionContext, Inject } from "@nestjs/common";
import { ThrottlerException } from "@nestjs/throttler";
import { Redis } from "ioredis";
import { createHash } from "crypto";
import { Injectable, ExecutionContext } from "@nestjs/common";
import type { Request } from "express";
import { AuthRateLimitService } from "../services/auth-rate-limit.service";
@Injectable()
export class FailedLoginThrottleGuard {
constructor(@Inject("REDIS_CLIENT") private readonly redis: Redis) {}
private getTracker(req: Request): string {
// Track by IP address + User Agent for failed login attempts only
const forwarded = req.headers["x-forwarded-for"];
const forwardedIp = Array.isArray(forwarded) ? forwarded[0] : forwarded;
const ip =
(typeof forwardedIp === "string" ? forwardedIp.split(",")[0]?.trim() : undefined) ||
(req.headers["x-real-ip"] as string | undefined) ||
req.socket?.remoteAddress ||
req.ip ||
"unknown";
const userAgent = req.headers["user-agent"] || "unknown";
const userAgentHash = createHash("sha256").update(userAgent).digest("hex").slice(0, 16);
const normalizedIp = ip.replace(/^::ffff:/, "");
return `failed_login_${normalizedIp}_${userAgentHash}`;
}
constructor(private readonly authRateLimitService: AuthRateLimitService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const tracker = this.getTracker(request);
// Get throttle configuration (10 attempts per 10 minutes)
const limit = 10;
const ttlSeconds = 600; // 10 minutes in seconds
// Check current failed attempts count
const currentCount = await this.redis.get(tracker);
const attempts = currentCount ? parseInt(currentCount, 10) : 0;
// If over limit, block the request with remaining time info
if (attempts >= limit) {
const ttl = await this.redis.ttl(tracker);
const remainingTime = Math.max(ttl, 0);
const minutes = Math.floor(remainingTime / 60);
const seconds = remainingTime % 60;
const timeMessage = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
throw new ThrottlerException(`Too many failed login attempts. Try again in ${timeMessage}.`);
}
// Increment counter for this attempt
await this.redis.incr(tracker);
await this.redis.expire(tracker, ttlSeconds);
// Store tracker info for post-processing
(request as any).__failedLoginTracker = tracker;
const outcome = await this.authRateLimitService.consumeLoginAttempt(request);
(request as any).__authRateLimit = outcome;
return true;
}
// Method to be called after login attempt to handle success/failure
async handleLoginResult(request: Request, wasSuccessful: boolean): Promise<void> {
const tracker = (request as any).__failedLoginTracker;
if (!tracker) return;
if (wasSuccessful) {
// Reset failed attempts counter on successful login
await this.redis.del(tracker);
await this.authRateLimitService.clearLoginAttempts(request);
}
// For failed logins, we keep the incremented counter
}
}

View File

@ -18,20 +18,18 @@ export class LoginResultInterceptor implements NestInterceptor {
const request = context.switchToHttp().getRequest<Request>();
return next.handle().pipe(
tap(async (result) => {
// Login was successful
tap(async () => {
await this.failedLoginGuard.handleLoginResult(request, true);
}),
catchError(async (error) => {
// Check if this is an authentication error (failed login)
catchError(async error => {
const message = typeof error?.message === "string" ? error.message.toLowerCase() : "";
const isAuthError =
error instanceof UnauthorizedException ||
error?.status === 401 ||
error?.message?.toLowerCase().includes('invalid') ||
error?.message?.toLowerCase().includes('unauthorized');
message.includes("invalid") ||
message.includes("unauthorized");
if (isAuthError) {
// Login failed - keep the failed attempt count
await this.failedLoginGuard.handleLoginResult(request, false);
}

View File

@ -0,0 +1,214 @@
import { Inject, Injectable, InternalServerErrorException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ThrottlerException } from "@nestjs/throttler";
import { Logger } from "nestjs-pino";
import type { Request } from "express";
import { RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible";
import { createHash } from "crypto";
import type { Redis } from "ioredis";
interface RateLimitOutcome {
key: string;
remainingPoints: number;
consumedPoints: number;
msBeforeNext: number;
needsCaptcha: boolean;
}
interface EnsureResult extends RateLimitOutcome {
headerValue?: string;
}
@Injectable()
export class AuthRateLimitService {
private readonly loginLimiter: RateLimiterRedis;
private readonly refreshLimiter: RateLimiterRedis;
private readonly signupLimiter: RateLimiterRedis;
private readonly passwordResetLimiter: RateLimiterRedis;
private readonly loginCaptchaThreshold: number;
private readonly captchaAlwaysOn: boolean;
constructor(
@Inject("REDIS_CLIENT") private readonly redis: Redis,
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {
const loginLimit = this.configService.get<number>("LOGIN_RATE_LIMIT_LIMIT", 5);
const loginTtlMs = this.configService.get<number>("LOGIN_RATE_LIMIT_TTL", 900000);
const signupLimit = this.configService.get<number>("SIGNUP_RATE_LIMIT_LIMIT", 5);
const signupTtlMs = this.configService.get<number>("SIGNUP_RATE_LIMIT_TTL", 900000);
const passwordResetLimit = this.configService.get<number>("PASSWORD_RESET_RATE_LIMIT_LIMIT", 5);
const passwordResetTtlMs = this.configService.get<number>("PASSWORD_RESET_RATE_LIMIT_TTL", 900000);
const refreshLimit = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_LIMIT", 10);
const refreshTtlMs = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_TTL", 300000);
this.loginCaptchaThreshold = this.configService.get<number>(
"LOGIN_CAPTCHA_AFTER_ATTEMPTS",
3
);
this.captchaAlwaysOn = this.configService.get("AUTH_CAPTCHA_ALWAYS_ON", "false") === "true";
this.loginLimiter = this.createLimiter("auth-login", loginLimit, loginTtlMs);
this.signupLimiter = this.createLimiter("auth-signup", signupLimit, signupTtlMs);
this.passwordResetLimiter = this.createLimiter(
"auth-password-reset",
passwordResetLimit,
passwordResetTtlMs
);
this.refreshLimiter = this.createLimiter("auth-refresh", refreshLimit, refreshTtlMs);
}
async consumeLoginAttempt(request: Request): Promise<RateLimitOutcome> {
const key = this.buildKey("login", request);
const result = await this.consume(this.loginLimiter, key, "login");
return {
...result,
needsCaptcha: this.captchaAlwaysOn || this.requiresCaptcha(result.consumedPoints),
};
}
async clearLoginAttempts(request: Request): Promise<void> {
const key = this.buildKey("login", request);
await this.deleteKey(this.loginLimiter, key, "login");
}
async consumeSignupAttempt(request: Request): Promise<RateLimitOutcome> {
const key = this.buildKey("signup", request);
const outcome = await this.consume(this.signupLimiter, key, "signup");
return { ...outcome, needsCaptcha: false };
}
async consumePasswordReset(request: Request): Promise<RateLimitOutcome> {
const key = this.buildKey("password-reset", request);
const outcome = await this.consume(this.passwordResetLimiter, key, "password-reset");
return { ...outcome, needsCaptcha: false };
}
async consumeRefreshAttempt(request: Request, refreshToken: string): Promise<RateLimitOutcome> {
const tokenHash = this.hashToken(refreshToken);
const key = this.buildKey("refresh", request, tokenHash);
return this.consume(this.refreshLimiter, key, "refresh");
}
getCaptchaHeaderValue(needsCaptcha: boolean): string {
if (needsCaptcha || this.captchaAlwaysOn) {
return "required";
}
return "optional";
}
private requiresCaptcha(consumedPoints: number): boolean {
if (this.loginCaptchaThreshold <= 0) {
return false;
}
return consumedPoints >= this.loginCaptchaThreshold;
}
private buildKey(type: string, request: Request, suffix?: string): string {
const ip = this.extractIp(request);
const userAgent = request.headers["user-agent"] || "unknown";
const uaHash = createHash("sha256").update(String(userAgent)).digest("hex").slice(0, 16);
return ["auth", type, ip, uaHash, suffix].filter(Boolean).join(":");
}
private extractIp(request: Request): string {
const forwarded = request.headers["x-forwarded-for"];
const forwardedIp = Array.isArray(forwarded) ? forwarded[0] : forwarded;
const rawIp =
(typeof forwardedIp === "string" ? forwardedIp.split(",")[0]?.trim() : undefined) ||
(request.headers["x-real-ip"] as string | undefined) ||
request.socket?.remoteAddress ||
request.ip ||
"unknown";
return rawIp.replace(/^::ffff:/, "");
}
private createLimiter(prefix: string, limit: number, ttlMs: number): RateLimiterRedis {
const duration = Math.max(1, Math.ceil(ttlMs / 1000));
try {
return new RateLimiterRedis({
storeClient: this.redis,
keyPrefix: prefix,
points: limit,
duration,
inmemoryBlockOnConsumed: limit + 1,
insuranceLimiter: undefined,
});
} catch (error) {
this.logger.error(error, `Failed to initialize rate limiter: ${prefix}`);
throw new InternalServerErrorException("Rate limiter initialization failed");
}
}
private async consume(
limiter: RateLimiterRedis,
key: string,
context: string
): Promise<RateLimitOutcome> {
try {
const res = await limiter.consume(key);
const consumedPoints = Math.max(0, limiter.points - res.remainingPoints);
return {
key,
remainingPoints: res.remainingPoints,
consumedPoints,
msBeforeNext: res.msBeforeNext,
needsCaptcha: false,
};
} catch (error) {
if (error instanceof RateLimiterRes) {
const retryAfterMs = error.msBeforeNext || 0;
const message = this.buildThrottleMessage(context, retryAfterMs);
this.logger.warn({ key, context, retryAfterMs }, "Auth rate limit reached");
throw new ThrottlerException(message);
}
this.logger.error(error, "Rate limiter failure");
throw new ThrottlerException("Authentication temporarily unavailable");
}
}
private async deleteKey(
limiter: RateLimiterRedis,
key: string,
context: string
): Promise<void> {
try {
await limiter.delete(key);
} catch (error) {
this.logger.warn({ key, context, error }, "Failed to reset rate limiter key");
}
}
private buildThrottleMessage(context: string, retryAfterMs: number): string {
const seconds = Math.ceil(retryAfterMs / 1000);
if (seconds <= 60) {
return `Too many ${context} attempts. Try again in ${seconds}s.`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
const timeMessage = remainingSeconds
? `${minutes}m ${remainingSeconds}s`
: `${minutes}m`;
return `Too many ${context} attempts. Try again in ${timeMessage}.`;
}
private hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
}

View File

@ -9,6 +9,7 @@ import { AuditService, AuditAction } from "@bff/infra/audit/audit.service";
import { EmailService } from "@bff/infra/email/email.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { AuthTokenService } from "../token.service";
import { AuthRateLimitService } from "../auth-rate-limit.service";
import { type AuthTokens, type UserProfile } from "@customer-portal/domain";
import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util";
@ -26,6 +27,7 @@ export class PasswordWorkflowService {
private readonly emailService: EmailService,
private readonly jwtService: JwtService,
private readonly tokenService: AuthTokenService,
private readonly authRateLimitService: AuthRateLimitService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -73,8 +75,10 @@ export class PasswordWorkflowService {
tokens,
};
}
async requestPasswordReset(email: string): Promise<void> {
async requestPasswordReset(email: string, request?: Request): Promise<void> {
if (request) {
await this.authRateLimitService.consumePasswordReset(request);
}
const user = await this.usersService.findByEmailInternal(email);
if (!user) {
return;

View File

@ -16,6 +16,7 @@ import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
import { PrismaService } from "@bff/infra/database/prisma.service";
import { AuthTokenService } from "../token.service";
import { AuthRateLimitService } from "../auth-rate-limit.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import {
signupRequestSchema,
@ -48,6 +49,7 @@ export class SignupWorkflowService {
private readonly prisma: PrismaService,
private readonly auditService: AuditService,
private readonly tokenService: AuthTokenService,
private readonly authRateLimitService: AuthRateLimitService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -136,6 +138,9 @@ export class SignupWorkflowService {
}
async signup(signupData: SignupRequestInput, request?: Request): Promise<SignupResult> {
if (request) {
await this.authRateLimitService.consumeSignupAttempt(request);
}
this.validateSignupData(signupData);
const {

View File

@ -33,10 +33,7 @@ import type {
PaymentGatewayList,
InvoicePaymentLink,
} from "@customer-portal/domain";
import {
invoiceSchema,
invoiceListSchema,
} from "@customer-portal/domain/validation/shared/entities";
import { invoiceSchema, invoiceListSchema } from "@customer-portal/domain";
// ✅ CLEAN: DTOs only for OpenAPI generation
class InvoiceDto extends createZodDto(invoiceSchema) {}

View File

@ -20,6 +20,7 @@ import {
invoiceListSchema,
invoiceSchema as sharedInvoiceSchema,
} from "@customer-portal/domain/validation/shared/entities";
import { INVOICE_STATUS } from "@customer-portal/domain";
const emptyInvoiceList: InvoiceList = {
invoices: [],
@ -30,6 +31,30 @@ const emptyInvoiceList: InvoiceList = {
},
};
type InvoiceStatus = (typeof INVOICE_STATUS)[keyof typeof INVOICE_STATUS];
const FALLBACK_STATUS: InvoiceStatus = INVOICE_STATUS.DRAFT;
function ensureInvoiceStatus(invoice: Invoice): Invoice {
return {
...invoice,
status: (invoice.status as InvoiceStatus | undefined) ?? FALLBACK_STATUS,
};
}
function normalizeInvoiceList(list: InvoiceList): InvoiceList {
return {
...list,
invoices: list.invoices.map(ensureInvoiceStatus),
pagination: {
page: list.pagination?.page ?? 1,
totalItems: list.pagination?.totalItems ?? 0,
totalPages: list.pagination?.totalPages ?? 0,
nextCursor: list.pagination?.nextCursor,
},
};
}
const emptyPaymentMethods: PaymentMethodList = {
paymentMethods: [],
totalCount: 0,
@ -66,7 +91,8 @@ async function fetchInvoices(params?: InvoiceQueryParams): Promise<InvoiceList>
params ? { params: { query: params as Record<string, unknown> } } : undefined
);
const data = getDataOrDefault<InvoiceList>(response, emptyInvoiceList);
return invoiceListSchema.parse(data);
const parsed = invoiceListSchema.parse(data);
return normalizeInvoiceList(parsed);
}
async function fetchInvoice(id: string): Promise<Invoice> {
@ -74,7 +100,8 @@ async function fetchInvoice(id: string): Promise<Invoice> {
params: { path: { id } },
});
const invoice = getDataOrThrow<Invoice>(response, "Invoice not found");
return sharedInvoiceSchema.parse(invoice);
const parsed = sharedInvoiceSchema.parse(invoice);
return ensureInvoiceStatus(parsed);
}
async function fetchPaymentMethods(): Promise<PaymentMethodList> {

View File

@ -128,8 +128,8 @@ export function SubscriptionDetailContainer() {
const formatCurrency = (amount: number) =>
sharedFormatCurrency(amount || 0, {
currency: subscription.currency,
locale: getCurrencyLocale(subscription.currency),
currency: subscription?.currency ?? "JPY",
locale: getCurrencyLocale(subscription?.currency ?? "JPY"),
});
const formatBillingLabel = (cycle: string) => {

View File

@ -5,7 +5,6 @@ export interface PaymentMethod extends WhmcsEntity {
type: "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount" | "Manual";
description: string;
gatewayName?: string;
gatewayDisplayName?: string;
isDefault?: boolean;
lastFour?: string;
expiryDate?: string;
@ -51,9 +50,6 @@ export interface PaymentGateway {
displayName: string;
type: "merchant" | "thirdparty" | "tokenization" | "manual";
isActive: boolean;
acceptsCreditCards: boolean;
acceptsBankAccount: boolean;
supportsTokenization: boolean;
configuration?: Record<string, unknown>;
}