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",
|
"express": "^5.1.0",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.7.0",
|
"ioredis": "^5.7.0",
|
||||||
|
"rate-limiter-flexible": "^4.0.0",
|
||||||
"jsforce": "^3.10.4",
|
"jsforce": "^3.10.4",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"nestjs-pino": "^4.4.0",
|
"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),
|
BCRYPT_ROUNDS: z.coerce.number().int().min(10).max(16).default(12),
|
||||||
APP_BASE_URL: z.string().url().default("http://localhost:3000"),
|
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(),
|
CORS_ORIGIN: z.string().url().optional(),
|
||||||
TRUST_PROXY: z.enum(["true", "false"]).default("false"),
|
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),
|
RATE_LIMIT_LIMIT: z.coerce.number().int().positive().default(100),
|
||||||
AUTH_RATE_LIMIT_TTL: z.coerce.number().int().positive().default(900000),
|
AUTH_RATE_LIMIT_TTL: z.coerce.number().int().positive().default(900000),
|
||||||
AUTH_RATE_LIMIT_LIMIT: z.coerce.number().int().positive().default(3),
|
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"),
|
REDIS_URL: z.string().url().default("redis://localhost:6379"),
|
||||||
AUTH_ALLOW_REDIS_TOKEN_FAILOPEN: z.enum(["true", "false"]).default("false"),
|
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),
|
ttl: configService.get<number>("AUTH_RATE_LIMIT_TTL", 600000),
|
||||||
limit: configService.get<number>("AUTH_RATE_LIMIT_LIMIT", 3),
|
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 sessionId = req.user?.sessionId || this.extractSessionId(req);
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
|
|
||||||
// If we already have a valid secret, we don't need to generate a new token
|
const tokenData = this.csrfService.generateToken(existingSecret, sessionId || undefined, userId);
|
||||||
if (existingSecret) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new CSRF token
|
this.setCsrfSecretCookie(res, tokenData.secret, tokenData.expiresAt);
|
||||||
const tokenData = this.csrfService.generateToken(sessionId || undefined, userId);
|
|
||||||
|
|
||||||
// Set CSRF secret in secure, SameSite cookie
|
res.setHeader(this.csrfService.getHeaderName(), tokenData.token);
|
||||||
this.setCsrfSecretCookie(res, tokenData.secret);
|
|
||||||
|
|
||||||
// Set CSRF token in response header for client to use
|
|
||||||
res.setHeader("X-CSRF-Token", tokenData.token);
|
|
||||||
|
|
||||||
this.logger.debug("CSRF token generated and set", {
|
this.logger.debug("CSRF token generated and set", {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
@ -216,7 +208,8 @@ export class CsrfMiddleware implements NestMiddleware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extractSecretFromCookie(req: CsrfRequest): string | null {
|
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 {
|
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;
|
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 = {
|
const cookieOptions = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: this.isProduction,
|
secure: this.isProduction,
|
||||||
sameSite: "strict" as const,
|
sameSite: "strict" as const,
|
||||||
maxAge: 3600000, // 1 hour
|
maxAge: expiresAt ? Math.max(0, expiresAt.getTime() - Date.now()) : undefined,
|
||||||
path: "/",
|
path: "/",
|
||||||
};
|
};
|
||||||
|
|
||||||
res.cookie("csrf-secret", secret, cookieOptions);
|
res.cookie(this.csrfService.getCookieName(), secret, cookieOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,74 +14,76 @@ export interface CsrfTokenData {
|
|||||||
export interface CsrfValidationResult {
|
export interface CsrfValidationResult {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
tokenData?: CsrfTokenData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for CSRF token generation and validation
|
* Service for CSRF token generation and validation using deterministic HMAC tokens.
|
||||||
* Implements double-submit cookie pattern with additional security measures
|
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CsrfService {
|
export class CsrfService {
|
||||||
private readonly tokenExpiry: number; // Token expiry in milliseconds
|
private readonly tokenExpiry: number;
|
||||||
|
|
||||||
private readonly secretKey: string;
|
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(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@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.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")) {
|
if (!this.configService.get("CSRF_SECRET_KEY")) {
|
||||||
this.logger.warn(
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
getCookieName(): string {
|
||||||
* Generate a new CSRF token for a user session
|
return this.cookieName;
|
||||||
*/
|
}
|
||||||
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 = {
|
getHeaderName(): string {
|
||||||
token,
|
return this.headerName;
|
||||||
secret,
|
}
|
||||||
expiresAt,
|
|
||||||
sessionId,
|
|
||||||
userId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Store in cache for validation
|
getTokenTtl(): number {
|
||||||
this.tokenCache.set(token, tokenData);
|
return this.tokenExpiry;
|
||||||
|
}
|
||||||
|
|
||||||
// Prevent memory leaks
|
generateToken(
|
||||||
if (this.tokenCache.size > this.maxCacheSize) {
|
existingSecret?: string | null,
|
||||||
this.cleanupExpiredTokens();
|
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", {
|
this.logger.debug("CSRF token generated", {
|
||||||
tokenHash: this.hashToken(token),
|
tokenHash: this.hashToken(token),
|
||||||
sessionId,
|
sessionId,
|
||||||
userId,
|
userId,
|
||||||
expiresAt: expiresAt.toISOString(),
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
reusedSecret: Boolean(existingSecret),
|
||||||
});
|
});
|
||||||
|
|
||||||
return tokenData;
|
return {
|
||||||
|
token,
|
||||||
|
secret,
|
||||||
|
expiresAt,
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a CSRF token against the provided secret
|
|
||||||
*/
|
|
||||||
validateToken(
|
validateToken(
|
||||||
token: string,
|
token: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
@ -89,169 +91,54 @@ export class CsrfService {
|
|||||||
userId?: string
|
userId?: string
|
||||||
): CsrfValidationResult {
|
): CsrfValidationResult {
|
||||||
if (!token || !secret) {
|
if (!token || !secret) {
|
||||||
return {
|
return { isValid: false, reason: "Missing token or secret" };
|
||||||
isValid: false,
|
|
||||||
reason: "Missing token or secret",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token exists in cache
|
const parsed = this.parseToken(token);
|
||||||
const cachedTokenData = this.tokenCache.get(token);
|
if (!parsed) {
|
||||||
if (!cachedTokenData) {
|
this.logger.warn("CSRF token validation failed - malformed token", {
|
||||||
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", {
|
|
||||||
tokenHash: this.hashToken(token),
|
tokenHash: this.hashToken(token),
|
||||||
sessionId,
|
|
||||||
userId,
|
|
||||||
});
|
});
|
||||||
return {
|
return { isValid: false, reason: "Malformed token" };
|
||||||
isValid: false,
|
|
||||||
reason: "Invalid secret",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate session binding (if provided)
|
const { issuedAt } = parsed;
|
||||||
if (sessionId && cachedTokenData.sessionId && cachedTokenData.sessionId !== sessionId) {
|
if (Date.now() > issuedAt + this.tokenExpiry) {
|
||||||
this.logger.warn("CSRF token validation failed - session mismatch", {
|
return { isValid: false, reason: "Token expired" };
|
||||||
tokenHash: this.hashToken(token),
|
|
||||||
expectedSession: cachedTokenData.sessionId,
|
|
||||||
providedSession: sessionId,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
reason: "Session mismatch",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate user binding (if provided)
|
const expectedToken = this.signToken(secret, sessionId, userId, issuedAt);
|
||||||
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
|
|
||||||
if (!this.constantTimeEquals(token, expectedToken)) {
|
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),
|
tokenHash: this.hashToken(token),
|
||||||
sessionId,
|
sessionId,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
return {
|
return { isValid: false, reason: "Invalid token" };
|
||||||
isValid: false,
|
|
||||||
reason: "Invalid token",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug("CSRF token validated successfully", {
|
return { isValid: true };
|
||||||
tokenHash: this.hashToken(token),
|
|
||||||
sessionId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
isValid: true,
|
|
||||||
tokenData: cachedTokenData,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
invalidateToken(_token: string): void {
|
||||||
* Invalidate a specific token
|
// Stateless tokens are tied to the secret cookie; rotate cookie to invalidate.
|
||||||
*/
|
this.logger.debug("invalidateToken called for stateless CSRF token");
|
||||||
invalidateToken(token: string): void {
|
|
||||||
this.tokenCache.delete(token);
|
|
||||||
this.logger.debug("CSRF token invalidated", {
|
|
||||||
tokenHash: this.hashToken(token),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
invalidateSessionTokens(_sessionId: string): void {
|
||||||
* Invalidate all tokens for a specific session
|
this.logger.debug("invalidateSessionTokens called - rotate cookie to enforce");
|
||||||
*/
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
invalidateUserTokens(_userId: string): void {
|
||||||
* Invalidate all tokens for a specific user
|
this.logger.debug("invalidateUserTokens called - rotate cookie to enforce");
|
||||||
*/
|
|
||||||
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() {
|
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 {
|
return {
|
||||||
totalTokens: this.tokenCache.size,
|
mode: "stateless",
|
||||||
activeTokens,
|
totalTokens: 0,
|
||||||
expiredTokens,
|
activeTokens: 0,
|
||||||
cacheSize: this.tokenCache.size,
|
expiredTokens: 0,
|
||||||
maxCacheSize: this.maxCacheSize,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -259,11 +146,29 @@ export class CsrfService {
|
|||||||
return crypto.randomBytes(32).toString("base64url");
|
return crypto.randomBytes(32).toString("base64url");
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateTokenFromSecret(secret: string, sessionId?: string, userId?: string): string {
|
private signToken(
|
||||||
const data = [secret, sessionId || "", userId || ""].join("|");
|
secret: string,
|
||||||
const hmac = crypto.createHmac("sha256", this.secretKey);
|
sessionId: string | undefined,
|
||||||
hmac.update(data);
|
userId: string | undefined,
|
||||||
return hmac.digest("base64url");
|
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 {
|
private generateSecretKey(): string {
|
||||||
@ -275,7 +180,6 @@ export class CsrfService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private hashToken(token: string): string {
|
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);
|
return crypto.createHash("sha256").update(token).digest("hex").substring(0, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -291,23 +195,4 @@ export class CsrfService {
|
|||||||
|
|
||||||
return result === 0;
|
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,
|
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}`, {
|
this.logger.warn(`No products found for client ${clientId}`, {
|
||||||
responseStructure: response ? Object.keys(response) : 'null response',
|
responseStructure: response ? Object.keys(response) : "null response",
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
subscriptions: [],
|
subscriptions: [],
|
||||||
@ -77,8 +84,7 @@ export class WhmcsSubscriptionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform subscriptions
|
const subscriptions = products
|
||||||
const subscriptions = response.products.product
|
|
||||||
.map(whmcsProduct => {
|
.map(whmcsProduct => {
|
||||||
try {
|
try {
|
||||||
return this.subscriptionTransformer.transformSubscription(whmcsProduct);
|
return this.subscriptionTransformer.transformSubscription(whmcsProduct);
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { Logger } from "nestjs-pino";
|
|||||||
import { Invoice, InvoiceItem as BaseInvoiceItem } from "@customer-portal/domain";
|
import { Invoice, InvoiceItem as BaseInvoiceItem } from "@customer-portal/domain";
|
||||||
import type { WhmcsInvoice, WhmcsInvoiceItems } from "../../types/whmcs-api.types";
|
import type { WhmcsInvoice, WhmcsInvoiceItems } from "../../types/whmcs-api.types";
|
||||||
import { DataUtils } from "../utils/data-utils";
|
import { DataUtils } from "../utils/data-utils";
|
||||||
import { StatusNormalizer } from "../utils/status-normalizer";
|
|
||||||
import { TransformationValidator } from "../validators/transformation-validator";
|
import { TransformationValidator } from "../validators/transformation-validator";
|
||||||
import { WhmcsCurrencyService } from "../../services/whmcs-currency.service";
|
import { WhmcsCurrencyService } from "../../services/whmcs-currency.service";
|
||||||
|
|
||||||
@ -48,9 +47,7 @@ export class InvoiceTransformerService {
|
|||||||
const paidDate = DataUtils.formatDate(whmcsInvoice.datepaid);
|
const paidDate = DataUtils.formatDate(whmcsInvoice.datepaid);
|
||||||
const issuedAt = DataUtils.formatDate(whmcsInvoice.date || whmcsInvoice.datecreated);
|
const issuedAt = DataUtils.formatDate(whmcsInvoice.date || whmcsInvoice.datecreated);
|
||||||
|
|
||||||
// Calculate days overdue if applicable
|
const finalStatus = this.mapInvoiceStatus(whmcsInvoice.status);
|
||||||
const finalStatus = StatusNormalizer.determineInvoiceStatus(whmcsInvoice.status, dueDate);
|
|
||||||
const daysOverdue = finalStatus === "Overdue" ? StatusNormalizer.calculateDaysOverdue(dueDate) : undefined;
|
|
||||||
|
|
||||||
const invoice: Invoice = {
|
const invoice: Invoice = {
|
||||||
id: Number(invoiceId),
|
id: Number(invoiceId),
|
||||||
@ -66,7 +63,7 @@ export class InvoiceTransformerService {
|
|||||||
paidDate,
|
paidDate,
|
||||||
description: whmcsInvoice.notes || undefined,
|
description: whmcsInvoice.notes || undefined,
|
||||||
items: this.transformInvoiceItems(whmcsInvoice.items),
|
items: this.transformInvoiceItems(whmcsInvoice.items),
|
||||||
daysOverdue,
|
daysOverdue: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!this.validator.validateInvoice(invoice)) {
|
if (!this.validator.validateInvoice(invoice)) {
|
||||||
@ -77,8 +74,6 @@ export class InvoiceTransformerService {
|
|||||||
originalStatus: whmcsInvoice.status,
|
originalStatus: whmcsInvoice.status,
|
||||||
finalStatus: invoice.status,
|
finalStatus: invoice.status,
|
||||||
dueDate: invoice.dueDate,
|
dueDate: invoice.dueDate,
|
||||||
isOverdue: StatusNormalizer.isInvoiceOverdue(invoice.dueDate),
|
|
||||||
daysOverdue: StatusNormalizer.calculateDaysOverdue(invoice.dueDate),
|
|
||||||
total: invoice.total,
|
total: invoice.total,
|
||||||
currency: invoice.currency,
|
currency: invoice.currency,
|
||||||
itemCount: invoice.items?.length || 0,
|
itemCount: invoice.items?.length || 0,
|
||||||
@ -167,4 +162,22 @@ export class InvoiceTransformerService {
|
|||||||
|
|
||||||
return results;
|
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,
|
displayName: whmcsGateway.display_name,
|
||||||
type: whmcsGateway.type,
|
type: whmcsGateway.type,
|
||||||
isActive: whmcsGateway.active,
|
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)) {
|
if (!this.validator.validatePaymentGateway(gateway)) {
|
||||||
@ -52,33 +49,20 @@ export class PaymentTransformerService {
|
|||||||
id: whmcsPayMethod.id,
|
id: whmcsPayMethod.id,
|
||||||
type: whmcsPayMethod.type,
|
type: whmcsPayMethod.type,
|
||||||
description: whmcsPayMethod.description,
|
description: whmcsPayMethod.description,
|
||||||
gatewayName: whmcsPayMethod.gateway_name || "",
|
gatewayName: whmcsPayMethod.gateway_name ?? undefined,
|
||||||
isDefault: false, // Default value, can be set by calling service
|
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)) {
|
if (!this.validator.validatePaymentMethod(transformed)) {
|
||||||
throw new Error("Transformed payment method failed validation");
|
throw new Error("Transformed payment method failed validation");
|
||||||
}
|
}
|
||||||
@ -86,29 +70,6 @@ export class PaymentTransformerService {
|
|||||||
return transformed;
|
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
|
* Transform multiple payment methods in batch
|
||||||
*/
|
*/
|
||||||
@ -184,54 +145,4 @@ export class PaymentTransformerService {
|
|||||||
/**
|
/**
|
||||||
* Normalize gateway type to match our enum
|
* 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 { Subscription } from "@customer-portal/domain";
|
||||||
import type { WhmcsProduct, WhmcsCustomField } from "../../types/whmcs-api.types";
|
import type { WhmcsProduct, WhmcsCustomField } from "../../types/whmcs-api.types";
|
||||||
import { DataUtils } from "../utils/data-utils";
|
import { DataUtils } from "../utils/data-utils";
|
||||||
import { StatusNormalizer } from "../utils/status-normalizer";
|
|
||||||
import { TransformationValidator } from "../validators/transformation-validator";
|
import { TransformationValidator } from "../validators/transformation-validator";
|
||||||
import { WhmcsCurrencyService } from "../../services/whmcs-currency.service";
|
import { WhmcsCurrencyService } from "../../services/whmcs-currency.service";
|
||||||
|
|
||||||
@ -32,12 +31,7 @@ export class SubscriptionTransformerService {
|
|||||||
const firstPaymentAmount = DataUtils.parseAmount(whmcsProduct.firstpaymentamount);
|
const firstPaymentAmount = DataUtils.parseAmount(whmcsProduct.firstpaymentamount);
|
||||||
|
|
||||||
// Normalize billing cycle from WHMCS and apply safety overrides
|
// Normalize billing cycle from WHMCS and apply safety overrides
|
||||||
let normalizedCycle = StatusNormalizer.normalizeBillingCycle(whmcsProduct.billingcycle);
|
const billingCycle = this.mapBillingCycle(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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use WHMCS system default currency
|
// Use WHMCS system default currency
|
||||||
const defaultCurrency = this.currencyService.getDefaultCurrency();
|
const defaultCurrency = this.currencyService.getDefaultCurrency();
|
||||||
@ -47,8 +41,8 @@ export class SubscriptionTransformerService {
|
|||||||
serviceId: Number(whmcsProduct.id), // In WHMCS, product ID is the service ID
|
serviceId: Number(whmcsProduct.id), // In WHMCS, product ID is the service ID
|
||||||
productName: whmcsProduct.productname || whmcsProduct.name,
|
productName: whmcsProduct.productname || whmcsProduct.name,
|
||||||
domain: whmcsProduct.domain,
|
domain: whmcsProduct.domain,
|
||||||
status: StatusNormalizer.normalizeProductStatus(whmcsProduct.status),
|
status: this.mapSubscriptionStatus(whmcsProduct.status),
|
||||||
cycle: normalizedCycle,
|
cycle: billingCycle,
|
||||||
amount: this.getProductAmount(whmcsProduct),
|
amount: this.getProductAmount(whmcsProduct),
|
||||||
currency: defaultCurrency.code,
|
currency: defaultCurrency.code,
|
||||||
currencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
|
currencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
|
||||||
@ -93,15 +87,18 @@ export class SubscriptionTransformerService {
|
|||||||
* Get the appropriate amount for a product (recurring vs first payment)
|
* Get the appropriate amount for a product (recurring vs first payment)
|
||||||
*/
|
*/
|
||||||
private getProductAmount(whmcsProduct: WhmcsProduct): number {
|
private getProductAmount(whmcsProduct: WhmcsProduct): number {
|
||||||
// Prioritize recurring amount, fallback to first payment amount
|
|
||||||
const recurringAmount = DataUtils.parseAmount(whmcsProduct.recurringamount);
|
const recurringAmount = DataUtils.parseAmount(whmcsProduct.recurringamount);
|
||||||
const firstPaymentAmount = DataUtils.parseAmount(whmcsProduct.firstpaymentamount);
|
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(
|
private extractCustomFields(
|
||||||
customFields: WhmcsCustomField[] | undefined
|
customFields: WhmcsCustomField[] | undefined
|
||||||
@ -112,12 +109,9 @@ export class SubscriptionTransformerService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const fields: Record<string, string> = {};
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
for (const field of customFields) {
|
for (const field of customFields) {
|
||||||
if (field && typeof field === "object" && field.name && field.value) {
|
if (field && typeof field === "object" && field.name && field.value) {
|
||||||
// Normalize field name (remove special characters, convert to camelCase)
|
fields[field.name] = field.value;
|
||||||
const normalizedName = this.normalizeFieldName(field.name);
|
|
||||||
fields[normalizedName] = field.value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,11 +128,40 @@ export class SubscriptionTransformerService {
|
|||||||
/**
|
/**
|
||||||
* Normalize field name to camelCase
|
* Normalize field name to camelCase
|
||||||
*/
|
*/
|
||||||
private normalizeFieldName(name: string): string {
|
private mapSubscriptionStatus(status: string): Subscription["status"] {
|
||||||
return name
|
const allowed: Subscription["status"][] = [
|
||||||
.toLowerCase()
|
"Active",
|
||||||
.replace(/[^a-z0-9]+(.)/g, (_match: string, char: string) => char.toUpperCase())
|
"Suspended",
|
||||||
.replace(/^[^a-z]+/, "");
|
"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
|
* Check if subscription is active
|
||||||
*/
|
*/
|
||||||
isActiveSubscription(subscription: Subscription): boolean {
|
isActiveSubscription(subscription: Subscription): boolean {
|
||||||
return StatusNormalizer.isActiveStatus(subscription.status);
|
return subscription.status === "Active";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if subscription has one-time billing
|
* Check if subscription has one-time billing
|
||||||
*/
|
*/
|
||||||
isOneTimeSubscription(subscription: Subscription): boolean {
|
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;
|
promoid?: number;
|
||||||
producttype?: string;
|
producttype?: string;
|
||||||
modulename?: string;
|
modulename?: string;
|
||||||
billingcycle: string;
|
billingcycle:
|
||||||
|
| "Monthly"
|
||||||
|
| "Quarterly"
|
||||||
|
| "Semi-Annually"
|
||||||
|
| "Annually"
|
||||||
|
| "Biennially"
|
||||||
|
| "Triennially"
|
||||||
|
| "One-time"
|
||||||
|
| "Free";
|
||||||
nextduedate: string;
|
nextduedate: string;
|
||||||
status: string;
|
status: "Active" | "Suspended" | "Terminated" | "Cancelled" | "Pending" | "Completed";
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
subscriptionid?: string;
|
subscriptionid?: string;
|
||||||
@ -322,9 +330,6 @@ export interface WhmcsPaymentGateway {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
type: "merchant" | "thirdparty" | "tokenization" | "manual";
|
type: "merchant" | "thirdparty" | "tokenization" | "manual";
|
||||||
active: boolean;
|
active: boolean;
|
||||||
accepts_credit_cards?: boolean;
|
|
||||||
accepts_bank_account?: boolean;
|
|
||||||
supports_tokenization?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WhmcsPaymentGatewaysResponse {
|
export interface WhmcsPaymentGatewaysResponse {
|
||||||
@ -343,12 +348,12 @@ export interface WhmcsCreateInvoiceParams {
|
|||||||
userid: number;
|
userid: number;
|
||||||
status?:
|
status?:
|
||||||
| "Draft"
|
| "Draft"
|
||||||
| "Unpaid"
|
|
||||||
| "Paid"
|
| "Paid"
|
||||||
|
| "Unpaid"
|
||||||
| "Cancelled"
|
| "Cancelled"
|
||||||
| "Refunded"
|
| "Refunded"
|
||||||
| "Collections"
|
| "Collections"
|
||||||
| "Payment Pending";
|
| "Overdue";
|
||||||
sendnotification?: boolean;
|
sendnotification?: boolean;
|
||||||
paymentmethod?: string;
|
paymentmethod?: string;
|
||||||
taxrate?: number;
|
taxrate?: number;
|
||||||
@ -378,12 +383,12 @@ export interface WhmcsUpdateInvoiceParams {
|
|||||||
invoiceid: number;
|
invoiceid: number;
|
||||||
status?:
|
status?:
|
||||||
| "Draft"
|
| "Draft"
|
||||||
| "Unpaid"
|
|
||||||
| "Paid"
|
| "Paid"
|
||||||
|
| "Unpaid"
|
||||||
| "Cancelled"
|
| "Cancelled"
|
||||||
| "Refunded"
|
| "Refunded"
|
||||||
| "Collections"
|
| "Collections"
|
||||||
| "Payment Pending";
|
| "Overdue";
|
||||||
duedate?: string; // YYYY-MM-DD format
|
duedate?: string; // YYYY-MM-DD format
|
||||||
notes?: string;
|
notes?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|||||||
@ -268,7 +268,7 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: "Request password reset email" })
|
@ApiOperation({ summary: "Request password reset email" })
|
||||||
@ApiResponse({ status: 200, description: "Reset email sent if account exists" })
|
@ApiResponse({ status: 200, description: "Reset email sent if account exists" })
|
||||||
async requestPasswordReset(@Body() body: PasswordResetRequestInput) {
|
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" };
|
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 { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workflow.service";
|
||||||
import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard";
|
import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard";
|
||||||
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor";
|
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor";
|
||||||
|
import { AuthRateLimitService } from "./services/auth-rate-limit.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -49,6 +50,7 @@ import { LoginResultInterceptor } from "./interceptors/login-result.interceptor"
|
|||||||
PasswordWorkflowService,
|
PasswordWorkflowService,
|
||||||
WhmcsLinkWorkflowService,
|
WhmcsLinkWorkflowService,
|
||||||
FailedLoginThrottleGuard,
|
FailedLoginThrottleGuard,
|
||||||
|
AuthRateLimitService,
|
||||||
LoginResultInterceptor,
|
LoginResultInterceptor,
|
||||||
{
|
{
|
||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import type { User as PrismaUser } from "@prisma/client";
|
|||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
import { PrismaService } from "@bff/infra/database/prisma.service";
|
import { PrismaService } from "@bff/infra/database/prisma.service";
|
||||||
import { AuthTokenService } from "./services/token.service";
|
import { AuthTokenService } from "./services/token.service";
|
||||||
|
import { AuthRateLimitService } from "./services/auth-rate-limit.service";
|
||||||
import { SignupWorkflowService } from "./services/workflows/signup-workflow.service";
|
import { SignupWorkflowService } from "./services/workflows/signup-workflow.service";
|
||||||
import { PasswordWorkflowService } from "./services/workflows/password-workflow.service";
|
import { PasswordWorkflowService } from "./services/workflows/password-workflow.service";
|
||||||
import { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workflow.service";
|
import { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workflow.service";
|
||||||
@ -46,6 +47,7 @@ export class AuthService {
|
|||||||
private readonly passwordWorkflow: PasswordWorkflowService,
|
private readonly passwordWorkflow: PasswordWorkflowService,
|
||||||
private readonly whmcsLinkWorkflow: WhmcsLinkWorkflowService,
|
private readonly whmcsLinkWorkflow: WhmcsLinkWorkflowService,
|
||||||
private readonly tokenService: AuthTokenService,
|
private readonly tokenService: AuthTokenService,
|
||||||
|
private readonly authRateLimitService: AuthRateLimitService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -113,6 +115,9 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
request?: Request
|
request?: Request
|
||||||
) {
|
) {
|
||||||
|
if (request) {
|
||||||
|
await this.authRateLimitService.clearLoginAttempts(request);
|
||||||
|
}
|
||||||
// Update last login time and reset failed attempts
|
// Update last login time and reset failed attempts
|
||||||
await this.usersService.update(user.id, {
|
await this.usersService.update(user.id, {
|
||||||
lastLoginAt: new Date(),
|
lastLoginAt: new Date(),
|
||||||
@ -358,8 +363,8 @@ export class AuthService {
|
|||||||
return sanitizeWhmcsRedirectPath(path);
|
return sanitizeWhmcsRedirectPath(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestPasswordReset(email: string): Promise<void> {
|
async requestPasswordReset(email: string, request?: Request): Promise<void> {
|
||||||
await this.passwordWorkflow.requestPasswordReset(email);
|
await this.passwordWorkflow.requestPasswordReset(email, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetPassword(token: string, newPassword: string) {
|
async resetPassword(token: string, newPassword: string) {
|
||||||
|
|||||||
@ -1,75 +1,24 @@
|
|||||||
import { Injectable, ExecutionContext, Inject } from "@nestjs/common";
|
import { Injectable, ExecutionContext } from "@nestjs/common";
|
||||||
import { ThrottlerException } from "@nestjs/throttler";
|
|
||||||
import { Redis } from "ioredis";
|
|
||||||
import { createHash } from "crypto";
|
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
import { AuthRateLimitService } from "../services/auth-rate-limit.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FailedLoginThrottleGuard {
|
export class FailedLoginThrottleGuard {
|
||||||
constructor(@Inject("REDIS_CLIENT") private readonly redis: Redis) {}
|
constructor(private readonly authRateLimitService: AuthRateLimitService) {}
|
||||||
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
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
|
const outcome = await this.authRateLimitService.consumeLoginAttempt(request);
|
||||||
await this.redis.incr(tracker);
|
(request as any).__authRateLimit = outcome;
|
||||||
await this.redis.expire(tracker, ttlSeconds);
|
|
||||||
|
|
||||||
// Store tracker info for post-processing
|
|
||||||
(request as any).__failedLoginTracker = tracker;
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method to be called after login attempt to handle success/failure
|
// Method to be called after login attempt to handle success/failure
|
||||||
async handleLoginResult(request: Request, wasSuccessful: boolean): Promise<void> {
|
async handleLoginResult(request: Request, wasSuccessful: boolean): Promise<void> {
|
||||||
const tracker = (request as any).__failedLoginTracker;
|
|
||||||
|
|
||||||
if (!tracker) return;
|
|
||||||
|
|
||||||
if (wasSuccessful) {
|
if (wasSuccessful) {
|
||||||
// Reset failed attempts counter on successful login
|
await this.authRateLimitService.clearLoginAttempts(request);
|
||||||
await this.redis.del(tracker);
|
|
||||||
}
|
}
|
||||||
// For failed logins, we keep the incremented counter
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,20 +18,18 @@ export class LoginResultInterceptor implements NestInterceptor {
|
|||||||
const request = context.switchToHttp().getRequest<Request>();
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
tap(async (result) => {
|
tap(async () => {
|
||||||
// Login was successful
|
|
||||||
await this.failedLoginGuard.handleLoginResult(request, true);
|
await this.failedLoginGuard.handleLoginResult(request, true);
|
||||||
}),
|
}),
|
||||||
catchError(async (error) => {
|
catchError(async error => {
|
||||||
// Check if this is an authentication error (failed login)
|
const message = typeof error?.message === "string" ? error.message.toLowerCase() : "";
|
||||||
const isAuthError =
|
const isAuthError =
|
||||||
error instanceof UnauthorizedException ||
|
error instanceof UnauthorizedException ||
|
||||||
error?.status === 401 ||
|
error?.status === 401 ||
|
||||||
error?.message?.toLowerCase().includes('invalid') ||
|
message.includes("invalid") ||
|
||||||
error?.message?.toLowerCase().includes('unauthorized');
|
message.includes("unauthorized");
|
||||||
|
|
||||||
if (isAuthError) {
|
if (isAuthError) {
|
||||||
// Login failed - keep the failed attempt count
|
|
||||||
await this.failedLoginGuard.handleLoginResult(request, false);
|
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 { EmailService } from "@bff/infra/email/email.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import { AuthTokenService } from "../token.service";
|
import { AuthTokenService } from "../token.service";
|
||||||
|
import { AuthRateLimitService } from "../auth-rate-limit.service";
|
||||||
import { type AuthTokens, type UserProfile } from "@customer-portal/domain";
|
import { type AuthTokens, type UserProfile } from "@customer-portal/domain";
|
||||||
import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util";
|
import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util";
|
||||||
|
|
||||||
@ -26,6 +27,7 @@ export class PasswordWorkflowService {
|
|||||||
private readonly emailService: EmailService,
|
private readonly emailService: EmailService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly tokenService: AuthTokenService,
|
private readonly tokenService: AuthTokenService,
|
||||||
|
private readonly authRateLimitService: AuthRateLimitService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -73,8 +75,10 @@ export class PasswordWorkflowService {
|
|||||||
tokens,
|
tokens,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
async requestPasswordReset(email: string, request?: Request): Promise<void> {
|
||||||
async requestPasswordReset(email: string): Promise<void> {
|
if (request) {
|
||||||
|
await this.authRateLimitService.consumePasswordReset(request);
|
||||||
|
}
|
||||||
const user = await this.usersService.findByEmailInternal(email);
|
const user = await this.usersService.findByEmailInternal(email);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
|||||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
||||||
import { PrismaService } from "@bff/infra/database/prisma.service";
|
import { PrismaService } from "@bff/infra/database/prisma.service";
|
||||||
import { AuthTokenService } from "../token.service";
|
import { AuthTokenService } from "../token.service";
|
||||||
|
import { AuthRateLimitService } from "../auth-rate-limit.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import {
|
import {
|
||||||
signupRequestSchema,
|
signupRequestSchema,
|
||||||
@ -48,6 +49,7 @@ export class SignupWorkflowService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditService: AuditService,
|
private readonly auditService: AuditService,
|
||||||
private readonly tokenService: AuthTokenService,
|
private readonly tokenService: AuthTokenService,
|
||||||
|
private readonly authRateLimitService: AuthRateLimitService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -136,6 +138,9 @@ export class SignupWorkflowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async signup(signupData: SignupRequestInput, request?: Request): Promise<SignupResult> {
|
async signup(signupData: SignupRequestInput, request?: Request): Promise<SignupResult> {
|
||||||
|
if (request) {
|
||||||
|
await this.authRateLimitService.consumeSignupAttempt(request);
|
||||||
|
}
|
||||||
this.validateSignupData(signupData);
|
this.validateSignupData(signupData);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@ -33,10 +33,7 @@ import type {
|
|||||||
PaymentGatewayList,
|
PaymentGatewayList,
|
||||||
InvoicePaymentLink,
|
InvoicePaymentLink,
|
||||||
} from "@customer-portal/domain";
|
} from "@customer-portal/domain";
|
||||||
import {
|
import { invoiceSchema, invoiceListSchema } from "@customer-portal/domain";
|
||||||
invoiceSchema,
|
|
||||||
invoiceListSchema,
|
|
||||||
} from "@customer-portal/domain/validation/shared/entities";
|
|
||||||
|
|
||||||
// ✅ CLEAN: DTOs only for OpenAPI generation
|
// ✅ CLEAN: DTOs only for OpenAPI generation
|
||||||
class InvoiceDto extends createZodDto(invoiceSchema) {}
|
class InvoiceDto extends createZodDto(invoiceSchema) {}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
invoiceListSchema,
|
invoiceListSchema,
|
||||||
invoiceSchema as sharedInvoiceSchema,
|
invoiceSchema as sharedInvoiceSchema,
|
||||||
} from "@customer-portal/domain/validation/shared/entities";
|
} from "@customer-portal/domain/validation/shared/entities";
|
||||||
|
import { INVOICE_STATUS } from "@customer-portal/domain";
|
||||||
|
|
||||||
const emptyInvoiceList: InvoiceList = {
|
const emptyInvoiceList: InvoiceList = {
|
||||||
invoices: [],
|
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 = {
|
const emptyPaymentMethods: PaymentMethodList = {
|
||||||
paymentMethods: [],
|
paymentMethods: [],
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
@ -66,7 +91,8 @@ async function fetchInvoices(params?: InvoiceQueryParams): Promise<InvoiceList>
|
|||||||
params ? { params: { query: params as Record<string, unknown> } } : undefined
|
params ? { params: { query: params as Record<string, unknown> } } : undefined
|
||||||
);
|
);
|
||||||
const data = getDataOrDefault<InvoiceList>(response, emptyInvoiceList);
|
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> {
|
async function fetchInvoice(id: string): Promise<Invoice> {
|
||||||
@ -74,7 +100,8 @@ async function fetchInvoice(id: string): Promise<Invoice> {
|
|||||||
params: { path: { id } },
|
params: { path: { id } },
|
||||||
});
|
});
|
||||||
const invoice = getDataOrThrow<Invoice>(response, "Invoice not found");
|
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> {
|
async function fetchPaymentMethods(): Promise<PaymentMethodList> {
|
||||||
|
|||||||
@ -128,8 +128,8 @@ export function SubscriptionDetailContainer() {
|
|||||||
|
|
||||||
const formatCurrency = (amount: number) =>
|
const formatCurrency = (amount: number) =>
|
||||||
sharedFormatCurrency(amount || 0, {
|
sharedFormatCurrency(amount || 0, {
|
||||||
currency: subscription.currency,
|
currency: subscription?.currency ?? "JPY",
|
||||||
locale: getCurrencyLocale(subscription.currency),
|
locale: getCurrencyLocale(subscription?.currency ?? "JPY"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatBillingLabel = (cycle: string) => {
|
const formatBillingLabel = (cycle: string) => {
|
||||||
|
|||||||
@ -5,7 +5,6 @@ export interface PaymentMethod extends WhmcsEntity {
|
|||||||
type: "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount" | "Manual";
|
type: "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount" | "Manual";
|
||||||
description: string;
|
description: string;
|
||||||
gatewayName?: string;
|
gatewayName?: string;
|
||||||
gatewayDisplayName?: string;
|
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
lastFour?: string;
|
lastFour?: string;
|
||||||
expiryDate?: string;
|
expiryDate?: string;
|
||||||
@ -51,9 +50,6 @@ export interface PaymentGateway {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
type: "merchant" | "thirdparty" | "tokenization" | "manual";
|
type: "merchant" | "thirdparty" | "tokenization" | "manual";
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
acceptsCreditCards: boolean;
|
|
||||||
acceptsBankAccount: boolean;
|
|
||||||
supportsTokenization: boolean;
|
|
||||||
configuration?: Record<string, unknown>;
|
configuration?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user