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:
parent
a9bff8c823
commit
336fe2cf59
@ -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",
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
const tokenData: CsrfTokenData = {
|
||||
token,
|
||||
secret,
|
||||
expiresAt,
|
||||
sessionId,
|
||||
userId,
|
||||
};
|
||||
|
||||
// Store in cache for validation
|
||||
this.tokenCache.set(token, tokenData);
|
||||
|
||||
// Prevent memory leaks
|
||||
if (this.tokenCache.size > this.maxCacheSize) {
|
||||
this.cleanupExpiredTokens();
|
||||
getCookieName(): string {
|
||||
return this.cookieName;
|
||||
}
|
||||
|
||||
getHeaderName(): string {
|
||||
return this.headerName;
|
||||
}
|
||||
|
||||
getTokenTtl(): number {
|
||||
return this.tokenExpiry;
|
||||
}
|
||||
|
||||
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++;
|
||||
}
|
||||
invalidateSessionTokens(_sessionId: string): void {
|
||||
this.logger.debug("invalidateSessionTokens called - rotate cookie to enforce");
|
||||
}
|
||||
|
||||
this.logger.debug("CSRF tokens invalidated for session", {
|
||||
sessionId,
|
||||
invalidatedCount,
|
||||
});
|
||||
invalidateUserTokens(_userId: string): void {
|
||||
this.logger.debug("invalidateUserTokens 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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
// For failed logins, we keep the incremented counter
|
||||
await this.authRateLimitService.clearLoginAttempts(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
214
apps/bff/src/modules/auth/services/auth-rate-limit.service.ts
Normal file
214
apps/bff/src/modules/auth/services/auth-rate-limit.service.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {}
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user