Browse Source

Add rate limiting to authentication and sign up endpoints

pull/7263/head
Thomas Kaul 1 week ago
parent
commit
07be4ccf42
  1. 43
      README.md
  2. 24
      apps/api/src/app/app.module.ts
  3. 4
      apps/api/src/app/auth/auth.controller.ts
  4. 4
      apps/api/src/app/user/user.controller.ts
  5. 14
      apps/api/src/main.ts
  6. 1
      apps/api/src/services/configuration/configuration.service.ts
  7. 1
      apps/api/src/services/interfaces/environment.interface.ts
  8. 46
      package-lock.json
  9. 2
      package.json

43
README.md

@ -85,27 +85,28 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables ### Supported Environment Variables
| Name | Type | Default Value | Description | | Name | Type | Default Value | Description |
| --------------------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | | `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | | `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | | `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | | `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` |
| `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) | | `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) |
| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | | `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | | `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | | `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | | `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | | `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | | `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | | `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | | `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running | | `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | | `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running | | `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | | `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | | `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. |
| `TRUST_PROXY` | `string` (optional) | | The [trust proxy](https://expressjs.com/en/guide/behind-proxies.html) setting of _Express.js_ to determine the client IP address for rate limiting, e.g. `1` if the Ghostfolio application runs behind a single reverse proxy |
#### OpenID Connect OIDC (experimental) #### OpenID Connect OIDC (experimental)

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

@ -2,6 +2,7 @@ import { EventsModule } from '@ghostfolio/api/events/events.module';
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';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CronModule } from '@ghostfolio/api/services/cron/cron.module'; import { CronModule } from '@ghostfolio/api/services/cron/cron.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -18,13 +19,16 @@ import {
import { ExpressAdapter } from '@bull-board/express'; import { ExpressAdapter } from '@bull-board/express';
import { BullBoardModule } from '@bull-board/nestjs'; import { BullBoardModule } from '@bull-board/nestjs';
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
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';
@ -168,6 +172,26 @@ import { UserModule } from './user/user.module';
SubscriptionModule, SubscriptionModule,
SymbolModule, SymbolModule,
TagsModule, TagsModule,
ThrottlerModule.forRootAsync({
imports: [ConfigurationModule],
inject: [ConfigurationService],
useFactory: (configurationService: ConfigurationService) => {
return {
storage: new ThrottlerStorageRedisService({
db: configurationService.get('REDIS_DB'),
host: configurationService.get('REDIS_HOST'),
password: configurationService.get('REDIS_PASSWORD'),
port: configurationService.get('REDIS_PORT')
}),
throttlers: [
{
limit: 10,
ttl: ms('1 minute')
}
]
};
}
}),
UserModule, UserModule,
WatchlistModule WatchlistModule
], ],

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

@ -22,6 +22,7 @@ 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';
@ -39,6 +40,7 @@ export class AuthController {
* @deprecated * @deprecated
*/ */
@Get('anonymous/:accessToken') @Get('anonymous/:accessToken')
@UseGuards(ThrottlerGuard)
public async accessTokenLoginGet( public async accessTokenLoginGet(
@Param('accessToken') accessToken: string @Param('accessToken') accessToken: string
): Promise<OAuthResponse> { ): Promise<OAuthResponse> {
@ -55,6 +57,7 @@ export class AuthController {
} }
@Post('anonymous') @Post('anonymous')
@UseGuards(ThrottlerGuard)
public async accessTokenLogin( public async accessTokenLogin(
@Body() body: { accessToken: string } @Body() body: { accessToken: string }
): Promise<OAuthResponse> { ): Promise<OAuthResponse> {
@ -156,6 +159,7 @@ export class AuthController {
} }
@Post('webauthn/verify-authentication') @Post('webauthn/verify-authentication')
@UseGuards(ThrottlerGuard)
public async verifyAuthentication( public async verifyAuthentication(
@Body() body: { deviceId: string; credential: AssertionCredentialJSON } @Body() body: { deviceId: string; credential: AssertionCredentialJSON }
) { ) {

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

@ -37,9 +37,11 @@ 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 { 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';
@ -128,6 +130,8 @@ export class UserController {
} }
@Post() @Post()
@Throttle({ default: { limit: 5, ttl: ms('1 hour') } })
@UseGuards(ThrottlerGuard)
public async signupUser(): Promise<UserItem> { public async signupUser(): Promise<UserItem> {
const isUserSignupEnabled = const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled(); await this.propertyService.isUserSignupEnabled();

14
apps/api/src/main.ts

@ -97,6 +97,20 @@ async function bootstrap() {
}); });
} }
const TRUST_PROXY = configService.get<string>('TRUST_PROXY');
if (TRUST_PROXY) {
let trustProxy: boolean | number | string = TRUST_PROXY;
if (/^\d+$/.test(TRUST_PROXY)) {
trustProxy = Number(TRUST_PROXY);
} else if (TRUST_PROXY === 'true') {
trustProxy = true;
}
app.set('trust proxy', trustProxy);
}
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;

1
apps/api/src/services/configuration/configuration.service.ts

@ -114,6 +114,7 @@ export class ConfigurationService {
default: environment.rootUrl default: environment.rootUrl
}), }),
STRIPE_SECRET_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }),
TRUST_PROXY: str({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }), TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),
TWITTER_API_KEY: str({ default: 'dummyApiKey' }), TWITTER_API_KEY: str({ default: 'dummyApiKey' }),

1
apps/api/src/services/interfaces/environment.interface.ts

@ -57,6 +57,7 @@ export interface Environment extends CleanedEnvAccessors {
REQUEST_TIMEOUT: number; REQUEST_TIMEOUT: number;
ROOT_URL: string; ROOT_URL: string;
STRIPE_SECRET_KEY: string; STRIPE_SECRET_KEY: string;
TRUST_PROXY: string;
TWITTER_ACCESS_TOKEN: string; TWITTER_ACCESS_TOKEN: string;
TWITTER_ACCESS_TOKEN_SECRET: string; TWITTER_ACCESS_TOKEN_SECRET: string;
TWITTER_API_KEY: string; TWITTER_API_KEY: string;

46
package-lock.json

@ -29,6 +29,7 @@
"@internationalized/number": "3.6.7", "@internationalized/number": "3.6.7",
"@ionic/angular": "8.8.12", "@ionic/angular": "8.8.12",
"@keyv/redis": "5.1.6", "@keyv/redis": "5.1.6",
"@nest-lab/throttler-storage-redis": "1.2.0",
"@nestjs/bull": "11.0.4", "@nestjs/bull": "11.0.4",
"@nestjs/cache-manager": "3.1.3", "@nestjs/cache-manager": "3.1.3",
"@nestjs/common": "11.1.27", "@nestjs/common": "11.1.27",
@ -40,6 +41,7 @@
"@nestjs/platform-express": "11.1.27", "@nestjs/platform-express": "11.1.27",
"@nestjs/schedule": "6.1.3", "@nestjs/schedule": "6.1.3",
"@nestjs/serve-static": "5.0.5", "@nestjs/serve-static": "5.0.5",
"@nestjs/throttler": "6.5.0",
"@openrouter/ai-sdk-provider": "2.9.1", "@openrouter/ai-sdk-provider": "2.9.1",
"@prisma/adapter-pg": "7.8.0", "@prisma/adapter-pg": "7.8.0",
"@prisma/client": "7.8.0", "@prisma/client": "7.8.0",
@ -6956,6 +6958,39 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@nest-lab/throttler-storage-redis": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@nest-lab/throttler-storage-redis/-/throttler-storage-redis-1.2.0.tgz",
"integrity": "sha512-tMkUyo68NCKTR+zILk+EC35SMYBtDPZY2mCj7ZaCietWGVTnuP4zwq9ERYfvU6kJv6h8teNZrC6MJCmY6/dljw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/throttler": ">=6.0.0",
"ioredis": ">=5.0.0",
"reflect-metadata": "^0.2.1"
},
"peerDependenciesMeta": {
"@nestjs/common": {
"optional": false
},
"@nestjs/core": {
"optional": false
},
"@nestjs/throttler": {
"optional": false
},
"ioredis": {
"optional": false
},
"reflect-metadata": {
"optional": false
}
}
},
"node_modules/@nestjs/bull": { "node_modules/@nestjs/bull": {
"version": "11.0.4", "version": "11.0.4",
"resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-11.0.4.tgz", "resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-11.0.4.tgz",
@ -7441,6 +7476,17 @@
} }
} }
}, },
"node_modules/@nestjs/throttler": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0"
}
},
"node_modules/@ngtools/webpack": { "node_modules/@ngtools/webpack": {
"version": "21.2.6", "version": "21.2.6",
"resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-21.2.6.tgz", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-21.2.6.tgz",

2
package.json

@ -73,6 +73,7 @@
"@internationalized/number": "3.6.7", "@internationalized/number": "3.6.7",
"@ionic/angular": "8.8.12", "@ionic/angular": "8.8.12",
"@keyv/redis": "5.1.6", "@keyv/redis": "5.1.6",
"@nest-lab/throttler-storage-redis": "1.2.0",
"@nestjs/bull": "11.0.4", "@nestjs/bull": "11.0.4",
"@nestjs/cache-manager": "3.1.3", "@nestjs/cache-manager": "3.1.3",
"@nestjs/common": "11.1.27", "@nestjs/common": "11.1.27",
@ -84,6 +85,7 @@
"@nestjs/platform-express": "11.1.27", "@nestjs/platform-express": "11.1.27",
"@nestjs/schedule": "6.1.3", "@nestjs/schedule": "6.1.3",
"@nestjs/serve-static": "5.0.5", "@nestjs/serve-static": "5.0.5",
"@nestjs/throttler": "6.5.0",
"@openrouter/ai-sdk-provider": "2.9.1", "@openrouter/ai-sdk-provider": "2.9.1",
"@prisma/adapter-pg": "7.8.0", "@prisma/adapter-pg": "7.8.0",
"@prisma/client": "7.8.0", "@prisma/client": "7.8.0",

Loading…
Cancel
Save