diff --git a/docs/configuration.md b/docs/configuration.md index 01a67b715..3e4a5806a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -46,6 +46,25 @@ Alternately, if you want to use AWS SES, you should set these instead (note that To generate an access/secret key pair, you can create an IAM user with the permission `AmazonSESFullAccess`. For more details, read the article [Creating an IAM user in your AWS account](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_users_create.html#id_users_create_console) on the AWS website. +### Rate limiting + +Staart API has three types of rate limits. When an endpoint is accessed, 1 point is consumed. There are also some endpoints that consume additional points (like logging in or creating an account consumes 10 points). The types of rate limits are: + +1. "Public" for unauthenticated requests (250 points/hour) +2. "Authenticated" for requests with a user access token (5k points/hour) +3. "API key" for (automated) requests using an API key (10k points/hour) + +You can set the rate limits for each of these categories. By default, the rate limit resets after one hour: + +| Environment variable | Description | Default | +| ----------------------------------- | -------------------------------- | ------- | +| `RATE_LIMIT_PUBLIC_POINTS` | Maximum points for public | 250 | +| `RATE_LIMIT_PUBLIC_DURATION` | Reset duration for public | 3600 | +| `RATE_LIMIT_AUTHENTICATED_POINTS` | Maximum points for authenticated | 5000 | +| `RATE_LIMIT_AUTHENTICATED_DURATION` | Reset duration for authenticated | 3600 | +| `RATE_LIMIT_API_KEY_POINTS` | Maximum points for API key | 10000 | +| `RATE_LIMIT_API_KEY_DURATION` | Reset duration for API key | 3600 | + ## Optional services ### ElasticSearch diff --git a/package-lock.json b/package-lock.json index 51af6dd9a..639970aca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,129 +4,6 @@ "lockfileVersion": 1, "requires": true, "dependencies": { - "@angular-devkit/core": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-10.2.0.tgz", - "integrity": "sha512-XAszFhSF3mZw1VjoOsYGbArr5NJLcStjOvcCGjBPl1UBM2AKpuCQXHxI9XJGYKL3B93Vp5G58d8qkHvamT53OA==", - "dev": true, - "requires": { - "ajv": "6.12.4", - "fast-json-stable-stringify": "2.1.0", - "magic-string": "0.25.7", - "rxjs": "6.6.2", - "source-map": "0.7.3" - }, - "dependencies": { - "rxjs": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.2.tgz", - "integrity": "sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg==", - "dev": true, - "requires": { - "tslib": "^1.9.0" - } - }, - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - } - } - }, - "@angular-devkit/schematics": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-10.2.0.tgz", - "integrity": "sha512-TQI5NnE6iM3ChF5gZQ9qb+lZgMWa7aLoF5ksOyT3zrmOuICiQYJhA6SsjV95q7J4M55qYymwBib8KTqU/xuQww==", - "dev": true, - "requires": { - "@angular-devkit/core": "10.2.0", - "ora": "5.0.0", - "rxjs": "6.6.2" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "ora": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.0.0.tgz", - "integrity": "sha512-s26qdWqke2kjN/wC4dy+IQPBIMWBJlSU/0JZhk30ZDBLelW25rv66yutUWARMigpGPzcXHb+Nac5pNhN/WsARw==", - "dev": true, - "requires": { - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.4.0", - "is-interactive": "^1.0.0", - "log-symbols": "^4.0.0", - "mute-stream": "0.0.8", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - } - }, - "rxjs": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.2.tgz", - "integrity": "sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg==", - "dev": true, - "requires": { - "tslib": "^1.9.0" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - } - } - }, "@angular-devkit/schematics-cli": { "version": "0.1100.1", "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-0.1100.1.tgz", @@ -11785,14 +11662,6 @@ "resolved": "https://registry.npmjs.org/nerf-dart/-/nerf-dart-1.0.0.tgz", "integrity": "sha1-5tq3/r9a2Bbqgc9cYpxaDr3nLBo=" }, - "nestjs-rate-limiter": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/nestjs-rate-limiter/-/nestjs-rate-limiter-2.5.6.tgz", - "integrity": "sha512-daBDy6HS1PKrFVxbwQRYkcQ/m0Jh4Th9V2qmYE7t44H3t00dK9z55pmdzSvqQ/4ITHxmlTe0KbcapeUbz4Wahg==", - "requires": { - "rate-limiter-flexible": "2.1.10" - } - }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -12873,7 +12742,7 @@ "dependencies": { "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" } } @@ -13197,7 +13066,7 @@ "dependencies": { "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" } } @@ -16555,9 +16424,9 @@ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" }, "rate-limiter-flexible": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-2.1.10.tgz", - "integrity": "sha512-Pa+8TPD4xYaiCUB5K4a/+j2FHDUe4HP1g49JmKEmkOkhqPaeVqxJsZuuVaza/svSCOT+V73vtsyBiSFK/e1yXw==" + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-2.1.13.tgz", + "integrity": "sha512-EDzvV/ee/rCBKNL5Jw0Rr0rjneT/L4zLGgVS9xB6ShfVMkV5iviWKr+2tjzgBg5kd9by5F08C9DfXfH6v/kz3w==" }, "raw-body": { "version": "2.4.0", diff --git a/package.json b/package.json index a341547db..617c64fe3 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,6 @@ "maxmind": "^4.3.1", "mem": "^8.0.0", "minimatch": "^3.0.4", - "nestjs-rate-limiter": "^2.5.6", "nodemailer": "^6.4.15", "normalize-email": "^1.1.1", "object-literal-parse": "^2.1.0", @@ -68,6 +67,7 @@ "qrcode": "^1.4.4", "quick-lru": "^5.1.1", "randomcolor": "^0.6.2", + "rate-limiter-flexible": "^2.1.13", "reflect-metadata": "^0.1.13", "request-ip": "^2.1.3", "response-time": "^2.3.2", diff --git a/src/app.module.ts b/src/app.module.ts index 8560546f4..d1f7e71a1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,9 +7,9 @@ import { import { ConfigModule } from '@nestjs/config'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; -import { RateLimiterInterceptor, RateLimiterModule } from 'nestjs-rate-limiter'; import configuration from './config/configuration'; import { AuditLogger } from './interceptors/audit-log.interceptor'; +import { RateLimitInterceptor } from './interceptors/rate-limit.interceptor'; import { ApiLoggerMiddleware } from './middleware/api-logger.middleware'; import { JsonBodyMiddleware } from './middleware/json-body.middleware'; import { RawBodyMiddleware } from './middleware/raw-body.middleware'; @@ -53,10 +53,6 @@ import { TasksModule } from './providers/tasks/tasks.module'; TasksModule, UsersModule, AuthModule, - RateLimiterModule.register({ - points: 100, - duration: 60, - }), MailModule, SessionsModule, EmailsModule, @@ -84,7 +80,7 @@ import { TasksModule } from './providers/tasks/tasks.module'; providers: [ { provide: APP_INTERCEPTOR, - useClass: RateLimiterInterceptor, + useClass: RateLimitInterceptor, }, { provide: APP_GUARD, 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 new file mode 100644 index 000000000..973a6a56c --- /dev/null +++ b/src/interceptors/rate-limit.interceptor.ts @@ -0,0 +1,72 @@ +import { + CallHandler, + ExecutionContext, + HttpException, + HttpStatus, + Injectable, + 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 { 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 { + 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, + private configService: ConfigService, + ) {} + + 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/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index d5a8e7b14..94b654f6a 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,6 +1,5 @@ import { Body, Controller, Headers, Ip, Post } from '@nestjs/common'; import { users } from '@prisma/client'; -import { RateLimit } from 'nestjs-rate-limiter'; import { Expose } from '../../providers/prisma/prisma.interface'; import { ForgotPasswordDto, @@ -14,6 +13,7 @@ import { import { TokenResponse, TotpTokenResponse } from './auth.interface'; import { AuthService } from './auth.service'; import { Public } from './public.decorator'; +import { RateLimit } from './rate-limit.decorator'; @Controller('auth') @Public() @@ -21,11 +21,7 @@ export class AuthController { constructor(private authService: AuthService) {} @Post('login') - @RateLimit({ - points: 10, - duration: 60, - errorMessage: 'Wait for 60 seconds before trying to login again', - }) + @RateLimit(10) async login( @Body() data: LoginDto, @Ip() ip: string, @@ -41,11 +37,7 @@ export class AuthController { } @Post('register') - @RateLimit({ - points: 10, - duration: 60, - errorMessage: 'Wait for 60 seconds before trying to create an account', - }) + @RateLimit(10) async register( @Ip() ip: string, @Body() data: RegisterDto, @@ -54,11 +46,7 @@ export class AuthController { } @Post('refresh') - @RateLimit({ - points: 5, - duration: 60, - errorMessage: 'Wait for 60 seconds before trying to login again', - }) + @RateLimit(5) async refresh( @Ip() ip: string, @Headers('User-Agent') userAgent: string, @@ -68,21 +56,13 @@ export class AuthController { } @Post('logout') - @RateLimit({ - points: 5, - duration: 60, - errorMessage: 'Wait for 60 seconds before trying to logout again', - }) + @RateLimit(5) async logout(@Body('token') refreshToken: string): Promise { return this.authService.logout(refreshToken); } @Post('approve-subnet') - @RateLimit({ - points: 5, - duration: 60, - errorMessage: 'Wait for 60 seconds before trying to logout again', - }) + @RateLimit(5) async approveSubnet( @Ip() ip: string, @Headers('User-Agent') userAgent: string, @@ -92,11 +72,7 @@ export class AuthController { } @Post('resend-email-verification') - @RateLimit({ - points: 1, - duration: 60, - errorMessage: 'Wait for 60 seconds before requesting another email', - }) + @RateLimit(10) async resendVerify(@Body() data: ResendEmailVerificationDto) { return this.authService.sendEmailVerification(data.email, true); } @@ -111,21 +87,13 @@ export class AuthController { } @Post('forgot-password') - @RateLimit({ - points: 10, - duration: 60, - errorMessage: 'Wait for 60 seconds before resetting another password', - }) + @RateLimit(10) async forgotPassword(@Body() data: ForgotPasswordDto) { return this.authService.requestPasswordReset(data.email); } @Post('reset-password') - @RateLimit({ - points: 10, - duration: 60, - errorMessage: 'Wait for 60 seconds before resetting another password', - }) + @RateLimit(10) async resetPassword( @Ip() ip: string, @Headers('User-Agent') userAgent: string, @@ -141,11 +109,7 @@ export class AuthController { } @Post('login/totp') - @RateLimit({ - points: 10, - duration: 60, - errorMessage: 'Wait for 60 seconds before trying to login again', - }) + @RateLimit(10) async totpLogin( @Body() data: TotpLoginDto, @Ip() ip: string, @@ -155,11 +119,7 @@ export class AuthController { } @Post('login/token') - @RateLimit({ - points: 10, - duration: 60, - errorMessage: 'Wait for 60 seconds before trying to login again', - }) + @RateLimit(10) async emailTokenLoginPost( @Body('token') token: string, @Ip() ip: string, @@ -169,11 +129,7 @@ export class AuthController { } @Post('merge-accounts') - @RateLimit({ - points: 10, - duration: 60, - errorMessage: 'Wait for 60 seconds before trying to merge accounts again', - }) + @RateLimit(10) async merge(@Body('token') token: string): Promise<{ success: true }> { return this.authService.mergeUsers(token); } diff --git a/src/modules/auth/rate-limit.decorator.ts b/src/modules/auth/rate-limit.decorator.ts new file mode 100644 index 000000000..0dd302187 --- /dev/null +++ b/src/modules/auth/rate-limit.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const RateLimit = (rateLimit: number) => + SetMetadata('rateLimit', rateLimit); diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index f3fd9e6c8..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'; @@ -61,11 +61,7 @@ export class UserController { @Post(':userId/merge-request') @Scopes('user-{userId}:merge') - @RateLimit({ - points: 10, - duration: 60, - errorMessage: 'Wait for 60 seconds before trying to merge again', - }) + @RateLimit(10) async mergeRequest( @Param('userId', ParseIntPipe) id: number, @Body('email') email: string,