diff --git a/src/config/configuration.interface.ts b/src/config/configuration.interface.ts index 5277bd6b3..2aae36981 100644 --- a/src/config/configuration.interface.ts +++ b/src/config/configuration.interface.ts @@ -14,6 +14,12 @@ export interface Configuration { apiKeyLruSize: number; }; + rateLimit: { + public: { points: number; duration: number }; + authenticated: { points: number; duration: number }; + apiKey: { points: number; duration: number }; + }; + security: { saltRounds: number; jwtSecret: string; diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 390debe24..5b9e125d9 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -16,6 +16,20 @@ const configuration: Configuration = { domainVerificationFile: process.env.DOMAIN_VERIFICATION_FILE ?? 'staart-verify.txt', }, + rateLimit: { + public: { + points: int(process.env.RATE_LIMIT_PUBLIC_POINTS, 250), + duration: int(process.env.RATE_LIMIT_PUBLIC_DURATION, 3600), + }, + authenticated: { + points: int(process.env.RATE_LIMIT_AUTHENTICATED_POINTS, 5000), + duration: int(process.env.RATE_LIMIT_AUTHENTICATED_DURATION, 3600), + }, + apiKey: { + points: int(process.env.RATE_LIMIT_API_KEY_POINTS, 10000), + duration: int(process.env.RATE_LIMIT_API_KEY_DURATION, 3600), + }, + }, caching: { geolocationLruSize: int(process.env.GEOLOCATION_LRU_SIZE, 100), apiKeyLruSize: int(process.env.API_KEY_LRU_SIZE, 100), diff --git a/src/errors/errors.constants.ts b/src/errors/errors.constants.ts index 5610fcd9e..b29d0a7a1 100644 --- a/src/errors/errors.constants.ts +++ b/src/errors/errors.constants.ts @@ -55,3 +55,5 @@ export const BILLING_ACCOUNT_CREATED_CONFLICT = export const MFA_ENABLED_CONFLICT = '409004: Multi-factor authentication is already enabled'; export const MERGE_USER_CONFLICT = '409005: Cannot merge the same user'; + +export const RATE_LIMIT_EXCEEDED = '429000: Rate limit exceeded'; diff --git a/src/interceptors/rate-limit.interceptor.ts b/src/interceptors/rate-limit.interceptor.ts index 3f159a379..973a6a56c 100644 --- a/src/interceptors/rate-limit.interceptor.ts +++ b/src/interceptors/rate-limit.interceptor.ts @@ -1,24 +1,72 @@ import { CallHandler, ExecutionContext, + HttpException, + HttpStatus, Injectable, - Logger, NestInterceptor, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Reflector } from '@nestjs/core'; +import { RateLimiterMemory } from 'rate-limiter-flexible'; +import { getClientIp } from 'request-ip'; import { Observable } from 'rxjs'; -import { STAART_AUDIT_LOG_DATA } from '../modules/audit-logs/audit-log.constants'; +import { Configuration } from '../config/configuration.interface'; +import { RATE_LIMIT_EXCEEDED } from '../errors/errors.constants'; +import { UserRequest } from '../modules/auth/auth.interface'; @Injectable() export class RateLimitInterceptor implements NestInterceptor { - logger = new Logger(RateLimitInterceptor.name); + private rateLimiterPublic = new RateLimiterMemory( + this.configService.get( + 'rateLimit.public', + ), + ); + private rateLimiterAuthenticated = new RateLimiterMemory( + this.configService.get( + 'rateLimit.authenticated', + ), + ); + private rateLimiterApiKey = new RateLimiterMemory( + this.configService.get( + 'rateLimit.apiKey', + ), + ); - constructor(private readonly reflector: Reflector) {} + constructor( + private readonly reflector: Reflector, + private configService: ConfigService, + ) {} - intercept(context: ExecutionContext, next: CallHandler): Observable { - let auditLog = this.reflector.get( - STAART_AUDIT_LOG_DATA, - context.getHandler(), - ); + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const points = + this.reflector.get('rateLimit', context.getHandler()) ?? 1; + const request = context.switchToHttp().getRequest() as UserRequest; + const response = context.switchToHttp().getResponse(); + let limiter = this.rateLimiterPublic; + if (request.user.type === 'api-key') limiter = this.rateLimiterApiKey; + else if (request.user.type === 'user') + limiter = this.rateLimiterAuthenticated; + try { + const ip = getClientIp(request); + const result = await limiter.consume(ip.replace(/^.*:/, ''), points); + response.header('Retry-After', Math.ceil(result.msBeforeNext / 1000)); + response.header('X-RateLimit-Limit', points); + response.header('X-Retry-Remaining', result.remainingPoints); + response.header( + 'X-Retry-Reset', + new Date(Date.now() + result.msBeforeNext).toUTCString(), + ); + } catch (result) { + response.header('Retry-After', Math.ceil(result.msBeforeNext / 1000)); + throw new HttpException( + RATE_LIMIT_EXCEEDED, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + return next.handle(); } } diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 6e6911e5d..f4718e6b6 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -10,12 +10,12 @@ import { Query, } from '@nestjs/common'; import { users } from '@prisma/client'; -import { RateLimit } from 'nestjs-rate-limiter'; -import { Expose } from '../../providers/prisma/prisma.interface'; import { CursorPipe } from '../../pipes/cursor.pipe'; import { OptionalIntPipe } from '../../pipes/optional-int.pipe'; import { OrderByPipe } from '../../pipes/order-by.pipe'; import { WherePipe } from '../../pipes/where.pipe'; +import { Expose } from '../../providers/prisma/prisma.interface'; +import { RateLimit } from '../auth/rate-limit.decorator'; import { Scopes } from '../auth/scope.decorator'; import { UpdateUserDto } from './users.dto'; import { UsersService } from './users.service';