diff --git a/apps/bff/package.json b/apps/bff/package.json index 9eb27e0b..de1962d5 100644 --- a/apps/bff/package.json +++ b/apps/bff/package.json @@ -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", diff --git a/apps/bff/src/core/config/env.validation.ts b/apps/bff/src/core/config/env.validation.ts index 35fb9c8d..6cc929ff 100644 --- a/apps/bff/src/core/config/env.validation.ts +++ b/apps/bff/src/core/config/env.validation.ts @@ -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"), diff --git a/apps/bff/src/core/config/throttler.config.ts b/apps/bff/src/core/config/throttler.config.ts index 7823b9de..865b64c5 100644 --- a/apps/bff/src/core/config/throttler.config.ts +++ b/apps/bff/src/core/config/throttler.config.ts @@ -11,4 +11,9 @@ export const createThrottlerConfig = (configService: ConfigService): ThrottlerMo ttl: configService.get("AUTH_RATE_LIMIT_TTL", 600000), limit: configService.get("AUTH_RATE_LIMIT_LIMIT", 3), }, + { + name: "auth-refresh", + ttl: configService.get("AUTH_REFRESH_RATE_LIMIT_TTL", 300000), + limit: configService.get("AUTH_REFRESH_RATE_LIMIT_LIMIT", 10), + }, ]; diff --git a/apps/bff/src/core/security/middleware/csrf.middleware.ts b/apps/bff/src/core/security/middleware/csrf.middleware.ts index 45dc5548..854b52f0 100644 --- a/apps/bff/src/core/security/middleware/csrf.middleware.ts +++ b/apps/bff/src/core/security/middleware/csrf.middleware.ts @@ -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); } } diff --git a/apps/bff/src/core/security/services/csrf.service.ts b/apps/bff/src/core/security/services/csrf.service.ts index 711c2cd2..0cbdb89f 100644 --- a/apps/bff/src/core/security/services/csrf.service.ts +++ b/apps/bff/src/core/security/services/csrf.service.ts @@ -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(); - 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, - }); - } - } } diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts index 66d910f6..b1b7248a 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts @@ -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); diff --git a/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts index f07f59bf..cc6a47c0 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts @@ -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}`); + } } diff --git a/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts index 543bca75..0ee269bb 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts @@ -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"; - } } diff --git a/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts index 09af9aea..c461b5da 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts @@ -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 = {}; - 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"; } /** diff --git a/apps/bff/src/integrations/whmcs/transformers/utils/status-normalizer.ts b/apps/bff/src/integrations/whmcs/transformers/utils/status-normalizer.ts index 6fa926a8..8b137891 100644 --- a/apps/bff/src/integrations/whmcs/transformers/utils/status-normalizer.ts +++ b/apps/bff/src/integrations/whmcs/transformers/utils/status-normalizer.ts @@ -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 = { - 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 = { - 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 = { - 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)); - } -} diff --git a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts index 427ee25b..ae8f78af 100644 --- a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts +++ b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts @@ -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; diff --git a/apps/bff/src/modules/auth/auth-zod.controller.ts b/apps/bff/src/modules/auth/auth-zod.controller.ts index defd9d44..07013291 100644 --- a/apps/bff/src/modules/auth/auth-zod.controller.ts +++ b/apps/bff/src/modules/auth/auth-zod.controller.ts @@ -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" }; } diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index 2aa6522e..3839ab5a 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -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, diff --git a/apps/bff/src/modules/auth/auth.service.ts b/apps/bff/src/modules/auth/auth.service.ts index 2aded862..89a53378 100644 --- a/apps/bff/src/modules/auth/auth.service.ts +++ b/apps/bff/src/modules/auth/auth.service.ts @@ -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 { - await this.passwordWorkflow.requestPasswordReset(email); + async requestPasswordReset(email: string, request?: Request): Promise { + await this.passwordWorkflow.requestPasswordReset(email, request); } async resetPassword(token: string, newPassword: string) { diff --git a/apps/bff/src/modules/auth/guards/failed-login-throttle.guard.ts b/apps/bff/src/modules/auth/guards/failed-login-throttle.guard.ts index 284c2f45..a61bdc0f 100644 --- a/apps/bff/src/modules/auth/guards/failed-login-throttle.guard.ts +++ b/apps/bff/src/modules/auth/guards/failed-login-throttle.guard.ts @@ -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 { const request = context.switchToHttp().getRequest(); - 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); + const outcome = await this.authRateLimitService.consumeLoginAttempt(request); + (request as any).__authRateLimit = outcome; - // Store tracker info for post-processing - (request as any).__failedLoginTracker = tracker; - return true; } // Method to be called after login attempt to handle success/failure async handleLoginResult(request: Request, wasSuccessful: boolean): Promise { - 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 } } diff --git a/apps/bff/src/modules/auth/interceptors/login-result.interceptor.ts b/apps/bff/src/modules/auth/interceptors/login-result.interceptor.ts index ca31a3f9..40d09d8a 100644 --- a/apps/bff/src/modules/auth/interceptors/login-result.interceptor.ts +++ b/apps/bff/src/modules/auth/interceptors/login-result.interceptor.ts @@ -18,20 +18,18 @@ export class LoginResultInterceptor implements NestInterceptor { const request = context.switchToHttp().getRequest(); 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) - const isAuthError = + 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); } diff --git a/apps/bff/src/modules/auth/services/auth-rate-limit.service.ts b/apps/bff/src/modules/auth/services/auth-rate-limit.service.ts new file mode 100644 index 00000000..197ea6d3 --- /dev/null +++ b/apps/bff/src/modules/auth/services/auth-rate-limit.service.ts @@ -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("LOGIN_RATE_LIMIT_LIMIT", 5); + const loginTtlMs = this.configService.get("LOGIN_RATE_LIMIT_TTL", 900000); + + const signupLimit = this.configService.get("SIGNUP_RATE_LIMIT_LIMIT", 5); + const signupTtlMs = this.configService.get("SIGNUP_RATE_LIMIT_TTL", 900000); + + const passwordResetLimit = this.configService.get("PASSWORD_RESET_RATE_LIMIT_LIMIT", 5); + const passwordResetTtlMs = this.configService.get("PASSWORD_RESET_RATE_LIMIT_TTL", 900000); + + const refreshLimit = this.configService.get("AUTH_REFRESH_RATE_LIMIT_LIMIT", 10); + const refreshTtlMs = this.configService.get("AUTH_REFRESH_RATE_LIMIT_TTL", 300000); + + this.loginCaptchaThreshold = this.configService.get( + "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 { + 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 { + const key = this.buildKey("login", request); + await this.deleteKey(this.loginLimiter, key, "login"); + } + + async consumeSignupAttempt(request: Request): Promise { + const key = this.buildKey("signup", request); + const outcome = await this.consume(this.signupLimiter, key, "signup"); + return { ...outcome, needsCaptcha: false }; + } + + async consumePasswordReset(request: Request): Promise { + 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 { + 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 { + 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 { + 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"); + } +} + diff --git a/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts b/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts index bf3d9255..b457eabe 100644 --- a/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts +++ b/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts @@ -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 { + async requestPasswordReset(email: string, request?: Request): Promise { + if (request) { + await this.authRateLimitService.consumePasswordReset(request); + } const user = await this.usersService.findByEmailInternal(email); if (!user) { return; diff --git a/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts index 299f12a8..f1846249 100644 --- a/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts +++ b/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts @@ -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 { + if (request) { + await this.authRateLimitService.consumeSignupAttempt(request); + } this.validateSignupData(signupData); const { diff --git a/apps/bff/src/modules/invoices/invoices.controller.ts b/apps/bff/src/modules/invoices/invoices.controller.ts index 2d91b303..3cd7f6fb 100644 --- a/apps/bff/src/modules/invoices/invoices.controller.ts +++ b/apps/bff/src/modules/invoices/invoices.controller.ts @@ -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) {} diff --git a/apps/portal/src/features/billing/hooks/useBilling.ts b/apps/portal/src/features/billing/hooks/useBilling.ts index a2b054b5..0293117d 100644 --- a/apps/portal/src/features/billing/hooks/useBilling.ts +++ b/apps/portal/src/features/billing/hooks/useBilling.ts @@ -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 params ? { params: { query: params as Record } } : undefined ); const data = getDataOrDefault(response, emptyInvoiceList); - return invoiceListSchema.parse(data); + const parsed = invoiceListSchema.parse(data); + return normalizeInvoiceList(parsed); } async function fetchInvoice(id: string): Promise { @@ -74,7 +100,8 @@ async function fetchInvoice(id: string): Promise { params: { path: { id } }, }); const invoice = getDataOrThrow(response, "Invoice not found"); - return sharedInvoiceSchema.parse(invoice); + const parsed = sharedInvoiceSchema.parse(invoice); + return ensureInvoiceStatus(parsed); } async function fetchPaymentMethods(): Promise { diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx index 675c3633..6d0c1ce5 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx @@ -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) => { diff --git a/packages/domain/src/entities/payment.ts b/packages/domain/src/entities/payment.ts index a7d1708b..63412947 100644 --- a/packages/domain/src/entities/payment.ts +++ b/packages/domain/src/entities/payment.ts @@ -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; }