From 82d55a3edbee26a64ac2451fc4cd33482609af91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Thu, 3 Jul 2025 14:13:18 +0200 Subject: [PATCH 1/2] add throttling support and apply to forgot password endpoint --- .../apps/job-launcher/server/package.json | 1 + .../job-launcher/server/src/app.module.ts | 45 ++++++++++++------- .../src/modules/auth/auth.controller.ts | 9 +++- yarn.lock | 12 +++++ 4 files changed, 49 insertions(+), 18 deletions(-) diff --git a/packages/apps/job-launcher/server/package.json b/packages/apps/job-launcher/server/package.json index fc7451e68c..22a416668b 100644 --- a/packages/apps/job-launcher/server/package.json +++ b/packages/apps/job-launcher/server/package.json @@ -42,6 +42,7 @@ "@nestjs/serve-static": "^4.0.1", "@nestjs/swagger": "^7.4.2", "@nestjs/terminus": "^11.0.0", + "@nestjs/throttler": "^6.4.0", "@nestjs/typeorm": "^10.0.1", "@sendgrid/mail": "^8.1.3", "@types/passport-jwt": "^4.0.1", diff --git a/packages/apps/job-launcher/server/src/app.module.ts b/packages/apps/job-launcher/server/src/app.module.ts index 03fcb7b581..d7526156c4 100644 --- a/packages/apps/job-launcher/server/src/app.module.ts +++ b/packages/apps/job-launcher/server/src/app.module.ts @@ -1,29 +1,30 @@ import { Module } from '@nestjs/common'; -import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; +import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; +import { ScheduleModule } from '@nestjs/schedule'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; +import { join } from 'path'; import { AppController } from './app.controller'; -import { DatabaseModule } from './database/database.module'; +import { EnvConfigModule } from './common/config/config.module'; +import { envValidator } from './common/config/env-schema'; +import { ExceptionFilter } from './common/exceptions/exception.filter'; import { JwtAuthGuard } from './common/guards'; +import { SnakeCaseInterceptor } from './common/interceptors/snake-case'; +import { TransformEnumInterceptor } from './common/interceptors/transform-enum.interceptor'; import { HttpValidationPipe } from './common/pipes'; -import { HealthModule } from './modules/health/health.module'; +import { DatabaseModule } from './database/database.module'; import { AuthModule } from './modules/auth/auth.module'; -import { UserModule } from './modules/user/user.module'; +import { CronJobModule } from './modules/cron-job/cron-job.module'; +import { HealthModule } from './modules/health/health.module'; import { JobModule } from './modules/job/job.module'; import { PaymentModule } from './modules/payment/payment.module'; -import { Web3Module } from './modules/web3/web3.module'; -import { envValidator } from './common/config/env-schema'; -import { ServeStaticModule } from '@nestjs/serve-static'; -import { join } from 'path'; +import { QualificationModule } from './modules/qualification/qualification.module'; +import { StatisticModule } from './modules/statistic/statistic.module'; import { StorageModule } from './modules/storage/storage.module'; -import { CronJobModule } from './modules/cron-job/cron-job.module'; -import { SnakeCaseInterceptor } from './common/interceptors/snake-case'; +import { UserModule } from './modules/user/user.module'; +import { Web3Module } from './modules/web3/web3.module'; import { WebhookModule } from './modules/webhook/webhook.module'; -import { EnvConfigModule } from './common/config/config.module'; -import { ExceptionFilter } from './common/exceptions/exception.filter'; -import { ScheduleModule } from '@nestjs/schedule'; -import { StatisticModule } from './modules/statistic/statistic.module'; -import { QualificationModule } from './modules/qualification/qualification.module'; -import { TransformEnumInterceptor } from './common/interceptors/transform-enum.interceptor'; @Module({ providers: [ @@ -47,8 +48,20 @@ import { TransformEnumInterceptor } from './common/interceptors/transform-enum.i provide: APP_FILTER, useClass: ExceptionFilter, }, + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, ], imports: [ + ThrottlerModule.forRoot({ + throttlers: [ + { + ttl: 60000, + limit: 1000, + }, + ], + }), ScheduleModule.forRoot(), ConfigModule.forRoot({ /** diff --git a/packages/apps/job-launcher/server/src/modules/auth/auth.controller.ts b/packages/apps/job-launcher/server/src/modules/auth/auth.controller.ts index cdd9440f7f..a1c3d0c3e0 100644 --- a/packages/apps/job-launcher/server/src/modules/auth/auth.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/auth/auth.controller.ts @@ -11,7 +11,6 @@ import { UseGuards, UseInterceptors, } from '@nestjs/common'; - import { ApiBearerAuth, ApiBody, @@ -19,6 +18,7 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import { ErrorAuth } from '../../common/constants/errors'; import { Public } from '../../common/decorators'; import { ValidationError } from '../../common/errors'; @@ -143,6 +143,7 @@ export class AuthJwtController { @Public() @HttpCode(204) + @Throttle({ default: { limit: 3, ttl: 60000 } }) @Post('/forgot-password') @ApiOperation({ summary: 'Forgot Password', @@ -161,7 +162,11 @@ export class AuthJwtController { status: 404, description: 'Not Found. Could not find the requested content.', }) - public async forgotPassword(@Body() data: ForgotPasswordDto): Promise { + public async forgotPassword( + @Body() data: ForgotPasswordDto, + @Ip() ip: string, + ): Promise { + console.log('IP:', ip); await this.authService.forgotPassword(data); } diff --git a/yarn.lock b/yarn.lock index f31fed15c6..22c9146e26 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4401,6 +4401,7 @@ __metadata: "@nestjs/swagger": "npm:^7.4.2" "@nestjs/terminus": "npm:^11.0.0" "@nestjs/testing": "npm:^10.4.6" + "@nestjs/throttler": "npm:^6.4.0" "@nestjs/typeorm": "npm:^10.0.1" "@sendgrid/mail": "npm:^8.1.3" "@types/bcrypt": "npm:^5.0.2" @@ -6431,6 +6432,17 @@ __metadata: languageName: node linkType: hard +"@nestjs/throttler@npm:^6.4.0": + version: 6.4.0 + resolution: "@nestjs/throttler@npm:6.4.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 + reflect-metadata: ^0.1.13 || ^0.2.0 + checksum: 10c0/796134644e341aad4a403b7431524db97adc31ae8771fc1160a4694a24c295b7a3dd15abcb72b9ea3a0702247b929f501fc5dc74a3f30d915f2667a39ba5c5d7 + languageName: node + linkType: hard + "@nestjs/typeorm@npm:^10.0.1": version: 10.0.2 resolution: "@nestjs/typeorm@npm:10.0.2" From 24b9880a43548f37644911f41517bbfd64f69bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Thu, 10 Jul 2025 18:40:23 +0200 Subject: [PATCH 2/2] enhance forgot password flow with hCaptcha validation --- .../src/components/Auth/ForgotPasswordForm.jsx | 6 +++++- .../job-launcher/client/src/services/auth.ts | 5 +++-- .../job-launcher/client/src/types/index.ts | 5 +++++ .../server/src/modules/auth/auth.controller.ts | 3 +-- .../server/src/modules/auth/auth.dto.ts | 4 ++++ .../src/modules/auth/auth.service.spec.ts | 17 +++++++++++++---- .../server/src/modules/auth/auth.service.ts | 18 +++++++++++++++++- 7 files changed, 48 insertions(+), 10 deletions(-) diff --git a/packages/apps/job-launcher/client/src/components/Auth/ForgotPasswordForm.jsx b/packages/apps/job-launcher/client/src/components/Auth/ForgotPasswordForm.jsx index 6be17a87a7..85cf68bfee 100644 --- a/packages/apps/job-launcher/client/src/components/Auth/ForgotPasswordForm.jsx +++ b/packages/apps/job-launcher/client/src/components/Auth/ForgotPasswordForm.jsx @@ -26,7 +26,11 @@ export const ForgotPasswordForm = () => { const handleForgotPassword = async ({ email }) => { setIsLoading(true); try { - await authService.forgotPassword(email); + const hCaptchaToken = await captchaRef.current.getResponse(); + await authService.forgotPassword({ + email, + hCaptchaToken, + }); setIsSuccess(true); } catch (err) { showError(err); diff --git a/packages/apps/job-launcher/client/src/services/auth.ts b/packages/apps/job-launcher/client/src/services/auth.ts index 6681643371..17536d94c0 100644 --- a/packages/apps/job-launcher/client/src/services/auth.ts +++ b/packages/apps/job-launcher/client/src/services/auth.ts @@ -1,4 +1,5 @@ import { + ForgotPasswordRequest, ResetPasswordRequest, SignInRequest, SignUpRequest, @@ -24,8 +25,8 @@ export const signOut = async (refreshToken: string) => { return data; }; -export const forgotPassword = async (email: string) => { - await api.post('/auth/forgot-password', { email }); +export const forgotPassword = async (body: ForgotPasswordRequest) => { + await api.post('/auth/forgot-password', body); }; export const resetPassword = async (body: ResetPasswordRequest) => { diff --git a/packages/apps/job-launcher/client/src/types/index.ts b/packages/apps/job-launcher/client/src/types/index.ts index 58f1c01343..db19cc899d 100644 --- a/packages/apps/job-launcher/client/src/types/index.ts +++ b/packages/apps/job-launcher/client/src/types/index.ts @@ -17,6 +17,11 @@ export type SignUpResponse = { refreshToken: string; }; +export type ForgotPasswordRequest = { + email: string; + hCaptchaToken: string; +}; + export type ResetPasswordRequest = { password: string; token: string; diff --git a/packages/apps/job-launcher/server/src/modules/auth/auth.controller.ts b/packages/apps/job-launcher/server/src/modules/auth/auth.controller.ts index a1c3d0c3e0..bbd70ec541 100644 --- a/packages/apps/job-launcher/server/src/modules/auth/auth.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/auth/auth.controller.ts @@ -166,8 +166,7 @@ export class AuthJwtController { @Body() data: ForgotPasswordDto, @Ip() ip: string, ): Promise { - console.log('IP:', ip); - await this.authService.forgotPassword(data); + await this.authService.forgotPassword(data, ip); } @Public() diff --git a/packages/apps/job-launcher/server/src/modules/auth/auth.dto.ts b/packages/apps/job-launcher/server/src/modules/auth/auth.dto.ts index 56015c7c29..2105d23875 100644 --- a/packages/apps/job-launcher/server/src/modules/auth/auth.dto.ts +++ b/packages/apps/job-launcher/server/src/modules/auth/auth.dto.ts @@ -13,6 +13,10 @@ export class ForgotPasswordDto { @IsEmail() @Transform(({ value }: { value: string }) => value.toLowerCase()) public email: string; + + @ApiProperty({ name: 'h_captcha_token' }) + @IsString() + public hCaptchaToken: string; } export class SignInDto { diff --git a/packages/apps/job-launcher/server/src/modules/auth/auth.service.spec.ts b/packages/apps/job-launcher/server/src/modules/auth/auth.service.spec.ts index a525dffe4b..6a49560722 100644 --- a/packages/apps/job-launcher/server/src/modules/auth/auth.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/auth/auth.service.spec.ts @@ -330,7 +330,10 @@ describe('AuthService', () => { it('should throw NotFoundError if user is not found', () => { findByEmailMock.mockResolvedValue(null); expect( - authService.forgotPassword({ email: 'user@example.com' }), + authService.forgotPassword({ + email: 'user@example.com', + hCaptchaToken: 'token', + }), ).rejects.toThrow(new NotFoundError(ErrorUser.NotFound)); }); @@ -338,13 +341,19 @@ describe('AuthService', () => { userEntity.status = UserStatus.INACTIVE; findByEmailMock.mockResolvedValue(userEntity); expect( - authService.forgotPassword({ email: 'user@example.com' }), + authService.forgotPassword({ + email: 'user@example.com', + hCaptchaToken: 'token', + }), ).rejects.toThrow(new ForbiddenError(ErrorUser.UserNotActive)); }); it('should remove existing token if it exists', async () => { findTokenMock.mockResolvedValue(tokenEntity); - await authService.forgotPassword({ email: 'user@example.com' }); + await authService.forgotPassword({ + email: 'user@example.com', + hCaptchaToken: 'token', + }); expect(tokenRepository.deleteOne).toHaveBeenCalled(); }); @@ -353,7 +362,7 @@ describe('AuthService', () => { sendGridService.sendEmail = jest.fn(); const email = 'user@example.com'; - await authService.forgotPassword({ email }); + await authService.forgotPassword({ email, hCaptchaToken: 'token' }); expect(sendGridService.sendEmail).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/apps/job-launcher/server/src/modules/auth/auth.service.ts b/packages/apps/job-launcher/server/src/modules/auth/auth.service.ts index 278f12c0a2..babebfb57c 100644 --- a/packages/apps/job-launcher/server/src/modules/auth/auth.service.ts +++ b/packages/apps/job-launcher/server/src/modules/auth/auth.service.ts @@ -181,7 +181,23 @@ export class AuthService { return { accessToken, refreshToken: newRefreshTokenEntity.uuid }; } - public async forgotPassword(data: ForgotPasswordDto): Promise { + public async forgotPassword( + data: ForgotPasswordDto, + ip?: string, + ): Promise { + if ( + !( + await verifyToken( + this.authConfigService.hcaptchaProtectionUrl, + this.authConfigService.hCaptchaSiteKey, + this.authConfigService.hCaptchaSecret, + data.hCaptchaToken, + ip, + ) + ).success + ) { + throw new ForbiddenError(ErrorAuth.InvalidCaptchaToken); + } const userEntity = await this.userRepository.findByEmail(data.email); if (!userEntity) {