diff --git a/src/auth/guards/rate-limit.guard.ts b/src/auth/guards/rate-limit.guard.ts index ce5d740..a591e1f 100644 --- a/src/auth/guards/rate-limit.guard.ts +++ b/src/auth/guards/rate-limit.guard.ts @@ -52,15 +52,16 @@ export class RateLimitGuard implements CanActivate { const endpoint = `${request.method} ${request.route?.path || request.url}`; try { - // Check by user if authenticated + const ip = this.getClientIp(request); + if (request.user?.id) { const userTier = request.user.tier || 'free'; - const userStatus = await this.rateLimitService.checkUserRateLimit( - request.user.id, - userTier, - ); - // Apply rate limit headers + const [userStatus, userIpStatus] = await Promise.all([ + this.rateLimitService.checkUserRateLimit(request.user.id, userTier), + this.rateLimitService.checkUserIpRateLimit(request.user.id, ip), + ]); + Object.entries(this.rateLimitService.getHeaders(userStatus)).forEach(([key, value]) => { response.setHeader(key, value); }); @@ -73,17 +74,24 @@ export class RateLimitGuard implements CanActivate { retryAfter: userStatus.retryAfter, }, HttpStatus.TOO_MANY_REQUESTS, + { cause: 'user_rate_limit_exceeded' }, + ); + } + + if (userIpStatus.isExceeded) { + throw new HttpException( { - cause: 'user_rate_limit_exceeded', + statusCode: HttpStatus.TOO_MANY_REQUESTS, + message: 'Too many requests from this account on this IP. Please try again later.', + retryAfter: userIpStatus.retryAfter, }, + HttpStatus.TOO_MANY_REQUESTS, + { cause: 'user_ip_rate_limit_exceeded' }, ); } } else { - // Check by IP for unauthenticated requests - const ip = this.getClientIp(request); const ipStatus = await this.rateLimitService.checkIpRateLimit(ip); - // Apply rate limit headers Object.entries(this.rateLimitService.getHeaders(ipStatus)).forEach(([key, value]) => { response.setHeader(key, value); }); @@ -96,9 +104,7 @@ export class RateLimitGuard implements CanActivate { retryAfter: ipStatus.retryAfter, }, HttpStatus.TOO_MANY_REQUESTS, - { - cause: 'ip_rate_limit_exceeded', - }, + { cause: 'ip_rate_limit_exceeded' }, ); } } diff --git a/src/auth/rate-limit.config.ts b/src/auth/rate-limit.config.ts index 6b70d74..bd1b0e2 100644 --- a/src/auth/rate-limit.config.ts +++ b/src/auth/rate-limit.config.ts @@ -146,6 +146,7 @@ export const RATE_LIMIT_KEYS = { ENDPOINT: (endpoint: string) => `rate-limit:endpoint:${endpoint}`, USER: (userId: string) => `rate-limit:user:${userId}`, IP: (ip: string) => `rate-limit:ip:${ip}`, + USER_IP: (userId: string, ip: string) => `rate-limit:user-ip:${userId}:${ip}`, API_KEY: (apiKey: string) => `rate-limit:api-key:${apiKey}`, }; diff --git a/src/auth/rate-limit.service.ts b/src/auth/rate-limit.service.ts index 7494309..e4be5c8 100644 --- a/src/auth/rate-limit.service.ts +++ b/src/auth/rate-limit.service.ts @@ -169,6 +169,18 @@ export class RateLimitService { await this.cacheManager.del(key); } + /** + * Check rate limit for a user tied to a specific IP + * Prevents abuse from multiple accounts on the same IP or a single user hopping IPs + */ + async checkUserIpRateLimit(userId: string, ip: string): Promise { + const key = RATE_LIMIT_KEYS.USER_IP(userId, ip); + const limit = 200; // Combined user+IP limit + const windowMs = 15 * 60 * 1000; // 15 minutes + + return this.checkRateLimit(key, limit, windowMs); + } + /** * Get rate limit status with headers */ diff --git a/src/email/email.module.ts b/src/email/email.module.ts index 0e53277..3aff01d 100644 --- a/src/email/email.module.ts +++ b/src/email/email.module.ts @@ -18,6 +18,13 @@ import { EmailProcessor } from './email.processor'; TrackingModule, BullModule.registerQueue({ name: 'mail', + defaultJobOptions: { + attempts: 3, + backoff: { + type: 'exponential', + delay: 1000, + }, + }, }), MailerModule.forRootAsync({ useFactory: (config: ConfigService) => ({ diff --git a/src/main.ts b/src/main.ts index 440e6e4..02895fd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,8 +12,10 @@ import { RateLimitGuard } from './auth/guards/rate-limit.guard'; import { RateLimitService } from './auth/rate-limit.service'; import { RateLimitHeadersInterceptor } from './auth/interceptors/rate-limit-headers.interceptor'; import { setupSwagger } from './config/swagger.config'; +import { validateEnvironment } from './utils/validate-env'; async function bootstrap() { + validateEnvironment(); const app = await NestFactory.create(AppModule); const logger = new Logger('Bootstrap'); diff --git a/src/utils/validate-env.ts b/src/utils/validate-env.ts new file mode 100644 index 0000000..5b1f866 --- /dev/null +++ b/src/utils/validate-env.ts @@ -0,0 +1,24 @@ +const REQUIRED_ENV_VARS = [ + 'DATABASE_URL', + 'JWT_SECRET', + 'JWT_REFRESH_SECRET', +] as const; + +export function validateEnvironment(): void { + const MISSING: string[] = []; + + for (const key of REQUIRED_ENV_VARS) { + if (!process.env[key]) { + MISSING.push(key); + } + } + + if (MISSING.length > 0) { + console.error( + `\n Fatal: Missing required environment variables:\n` + + MISSING.map((k) => ` - ${k}`).join('\n') + + `\n\n Please set them in .env or .env.local before starting the application.\n`, + ); + process.exit(1); + } +}