Browse Source

Add rate limiting to authentication and sign up endpoints

pull/7263/head
Thomas Kaul 4 days ago
parent
commit
f299e2a94d
  1. 32
      apps/api/src/app/app.module.ts
  2. 9
      apps/api/src/app/auth/auth.controller.ts
  3. 19
      apps/api/src/app/user/user.controller.ts
  4. 21
      apps/api/src/guards/custom-throttler.guard.ts
  5. 12
      apps/api/src/helper/redis.helper.ts
  6. 13
      apps/api/src/main.ts
  7. 15
      apps/client/src/app/components/header/header.component.ts
  8. 6
      apps/client/src/app/core/http-response.interceptor.ts
  9. 5
      libs/common/src/lib/config.ts

32
apps/api/src/app/app.module.ts

@ -1,4 +1,5 @@
import { EventsModule } from '@ghostfolio/api/events/events.module'; import { EventsModule } from '@ghostfolio/api/events/events.module';
import { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper';
import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware'; import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware';
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware'; import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
@ -14,7 +15,9 @@ import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/po
import { import {
BULL_BOARD_ROUTE, BULL_BOARD_ROUTE,
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES SUPPORTED_LANGUAGE_CODES,
THROTTLE_DEFAULT_LIMIT,
THROTTLE_DEFAULT_TTL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { ExpressAdapter } from '@bull-board/express'; import { ExpressAdapter } from '@bull-board/express';
@ -28,7 +31,6 @@ import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerModule } from '@nestjs/throttler';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import ms from 'ms';
import { join } from 'node:path'; import { join } from 'node:path';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
@ -99,12 +101,13 @@ import { UserModule } from './user/user.module';
middleware: BullBoardAuthMiddleware, middleware: BullBoardAuthMiddleware,
route: BULL_BOARD_ROUTE route: BULL_BOARD_ROUTE
}), }),
BullModule.forRoot({ BullModule.forRootAsync({
redis: { imports: [ConfigurationModule],
db: parseInt(process.env.REDIS_DB ?? '0', 10), inject: [ConfigurationService],
host: process.env.REDIS_HOST, useFactory: (configurationService: ConfigurationService) => {
password: process.env.REDIS_PASSWORD, return {
port: parseInt(process.env.REDIS_PORT ?? '6379', 10) redis: getRedisConnectionOptions(configurationService)
};
} }
}), }),
CacheModule, CacheModule,
@ -185,17 +188,14 @@ import { UserModule } from './user/user.module';
return !isRateLimitingEnabled; return !isRateLimitingEnabled;
}, },
storage: isRateLimitingEnabled storage: isRateLimitingEnabled
? new ThrottlerStorageRedisService({ ? new ThrottlerStorageRedisService(
db: configurationService.get('REDIS_DB'), getRedisConnectionOptions(configurationService)
host: configurationService.get('REDIS_HOST'), )
password: configurationService.get('REDIS_PASSWORD'),
port: configurationService.get('REDIS_PORT')
})
: undefined, : undefined,
throttlers: [ throttlers: [
{ {
limit: 10, limit: THROTTLE_DEFAULT_LIMIT,
ttl: ms('1 minute') ttl: THROTTLE_DEFAULT_TTL
} }
] ]
}; };

9
apps/api/src/app/auth/auth.controller.ts

@ -1,4 +1,5 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
@ -22,7 +23,6 @@ import {
VERSION_NEUTRAL VERSION_NEUTRAL
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { ThrottlerGuard } from '@nestjs/throttler';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@ -40,7 +40,7 @@ export class AuthController {
* @deprecated * @deprecated
*/ */
@Get('anonymous/:accessToken') @Get('anonymous/:accessToken')
@UseGuards(ThrottlerGuard) @UseGuards(CustomThrottlerGuard)
public async accessTokenLoginGet( public async accessTokenLoginGet(
@Param('accessToken') accessToken: string @Param('accessToken') accessToken: string
): Promise<OAuthResponse> { ): Promise<OAuthResponse> {
@ -57,7 +57,7 @@ export class AuthController {
} }
@Post('anonymous') @Post('anonymous')
@UseGuards(ThrottlerGuard) @UseGuards(CustomThrottlerGuard)
public async accessTokenLogin( public async accessTokenLogin(
@Body() body: { accessToken: string } @Body() body: { accessToken: string }
): Promise<OAuthResponse> { ): Promise<OAuthResponse> {
@ -138,6 +138,7 @@ export class AuthController {
} }
@Post('webauthn/generate-authentication-options') @Post('webauthn/generate-authentication-options')
@UseGuards(CustomThrottlerGuard)
public async generateAuthenticationOptions( public async generateAuthenticationOptions(
@Body() body: { deviceId: string } @Body() body: { deviceId: string }
) { ) {
@ -159,7 +160,7 @@ export class AuthController {
} }
@Post('webauthn/verify-authentication') @Post('webauthn/verify-authentication')
@UseGuards(ThrottlerGuard) @UseGuards(CustomThrottlerGuard)
public async verifyAuthentication( public async verifyAuthentication(
@Body() body: { deviceId: string; credential: AssertionCredentialJSON } @Body() body: { deviceId: string; credential: AssertionCredentialJSON }
) { ) {

19
apps/api/src/app/user/user.controller.ts

@ -1,11 +1,16 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import {
HEADER_KEY_IMPERSONATION,
THROTTLE_SIGNUP_LIMIT,
THROTTLE_SIGNUP_TTL
} from '@ghostfolio/common/config';
import { import {
DeleteOwnUserDto, DeleteOwnUserDto,
UpdateOwnAccessTokenDto, UpdateOwnAccessTokenDto,
@ -37,11 +42,10 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; import { Throttle } from '@nestjs/throttler';
import { User as UserModel } from '@prisma/client'; import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { merge, size } from 'lodash'; import { merge, size } from 'lodash';
import ms from 'ms';
import { UserService } from './user.service'; import { UserService } from './user.service';
@ -130,8 +134,13 @@ export class UserController {
} }
@Post() @Post()
@Throttle({ default: { limit: 5, ttl: ms('1 hour') } }) @Throttle({
@UseGuards(ThrottlerGuard) default: {
limit: THROTTLE_SIGNUP_LIMIT,
ttl: THROTTLE_SIGNUP_TTL
}
})
@UseGuards(CustomThrottlerGuard)
public async signupUser(): Promise<UserItem> { public async signupUser(): Promise<UserItem> {
const isUserSignupEnabled = const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled(); await this.propertyService.isUserSignupEnabled();

21
apps/api/src/guards/custom-throttler.guard.ts

@ -0,0 +1,21 @@
import { ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { ThrottlerException, ThrottlerGuard } from '@nestjs/throttler';
@Injectable()
export class CustomThrottlerGuard extends ThrottlerGuard {
private readonly logger = new Logger(CustomThrottlerGuard.name);
public async canActivate(context: ExecutionContext): Promise<boolean> {
try {
return await super.canActivate(context);
} catch (error) {
if (error instanceof ThrottlerException) {
throw error;
}
this.logger.error(error);
return true;
}
}
}

12
apps/api/src/helper/redis.helper.ts

@ -0,0 +1,12 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
export function getRedisConnectionOptions(
configurationService: ConfigurationService
) {
return {
db: configurationService.get('REDIS_DB'),
host: configurationService.get('REDIS_HOST'),
password: configurationService.get('REDIS_PASSWORD'),
port: configurationService.get('REDIS_PORT')
};
}

13
apps/api/src/main.ts

@ -39,6 +39,8 @@ async function bootstrap() {
) as LogLevel[]; ) as LogLevel[];
} catch {} } catch {}
await configApp.close();
const app = await NestFactory.create<NestExpressApplication>(AppModule, { const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: logger:
customLogLevels ?? customLogLevels ??
@ -104,6 +106,8 @@ async function bootstrap() {
if (/^\d+$/.test(TRUST_PROXY)) { if (/^\d+$/.test(TRUST_PROXY)) {
trustProxy = Number(TRUST_PROXY); trustProxy = Number(TRUST_PROXY);
} else if (TRUST_PROXY === 'false') {
trustProxy = false;
} else if (TRUST_PROXY === 'true') { } else if (TRUST_PROXY === 'true') {
trustProxy = true; trustProxy = true;
} }
@ -111,6 +115,15 @@ async function bootstrap() {
app.set('trust proxy', trustProxy); app.set('trust proxy', trustProxy);
} }
if (
configService.get<string>('ENABLE_FEATURE_RATE_LIMITING') === 'true' &&
!TRUST_PROXY
) {
logger.warn(
'Rate limiting is enabled, but TRUST_PROXY is not set. If the Ghostfolio application runs behind a reverse proxy, the rate limits are shared across all clients.'
);
}
const HOST = configService.get<string>('HOST') || DEFAULT_HOST; const HOST = configService.get<string>('HOST') || DEFAULT_HOST;
const PORT = configService.get<number>('PORT') || DEFAULT_PORT; const PORT = configService.get<number>('PORT') || DEFAULT_PORT;

15
apps/client/src/app/components/header/header.component.ts

@ -22,6 +22,7 @@ import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { HttpErrorResponse } from '@angular/common/http';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@ -42,6 +43,7 @@ import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
import { Router, RouterModule } from '@angular/router'; import { Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { StatusCodes } from 'http-status-codes';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
closeOutline, closeOutline,
@ -315,10 +317,15 @@ export class GfHeaderComponent implements OnChanges {
this.dataService this.dataService
.loginAnonymous(data?.accessToken) .loginAnonymous(data?.accessToken)
.pipe( .pipe(
catchError(() => { catchError((error: HttpErrorResponse) => {
this.notificationService.alert({ if (error.status !== StatusCodes.TOO_MANY_REQUESTS) {
title: $localize`Oops! Incorrect Security Token.` // The notification for too many requests is handled in the
}); // HttpResponseInterceptor
this.notificationService.alert({
title: $localize`Oops! Incorrect Security Token.`
});
}
return EMPTY; return EMPTY;
}), }),

6
apps/client/src/app/core/http-response.interceptor.ts

@ -100,7 +100,11 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} else if (error.status === StatusCodes.TOO_MANY_REQUESTS) { } else if (error.status === StatusCodes.TOO_MANY_REQUESTS) {
if (!this.snackBarRef) { if (!this.snackBarRef) {
this.snackBarRef = this.snackBar.open( this.snackBarRef = this.snackBar.open(
$localize`Oops! It looks like you’re making too many requests. Please slow down a bit.` $localize`Oops! It looks like you’re making too many requests. Please slow down a bit.`,
undefined,
{
duration: ms('6 seconds')
}
); );
this.snackBarRef?.afterDismissed().subscribe(() => { this.snackBarRef?.afterDismissed().subscribe(() => {

5
libs/common/src/lib/config.ts

@ -329,4 +329,9 @@ export const TAG_ID_EXCLUDE_FROM_ANALYSIS =
'f2e868af-8333-459f-b161-cbc6544c24bd'; 'f2e868af-8333-459f-b161-cbc6544c24bd';
export const TAG_ID_DEMO = 'efa08cb3-9b9d-4974-ac68-db13a19c4874'; export const TAG_ID_DEMO = 'efa08cb3-9b9d-4974-ac68-db13a19c4874';
export const THROTTLE_DEFAULT_LIMIT = 10;
export const THROTTLE_DEFAULT_TTL = ms('1 minute');
export const THROTTLE_SIGNUP_LIMIT = 5;
export const THROTTLE_SIGNUP_TTL = ms('1 hour');
export const UNKNOWN_KEY = 'UNKNOWN'; export const UNKNOWN_KEY = 'UNKNOWN';

Loading…
Cancel
Save