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/package.json b/packages/apps/job-launcher/server/package.json index 249528e9e8..8856697c76 100644 --- a/packages/apps/job-launcher/server/package.json +++ b/packages/apps/job-launcher/server/package.json @@ -43,6 +43,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 b351d7e082..c15f1acdbf 100644 --- a/packages/apps/job-launcher/server/src/app.module.ts +++ b/packages/apps/job-launcher/server/src/app.module.ts @@ -1,31 +1,31 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; -import { ServeStaticModule } from '@nestjs/serve-static'; import { ScheduleModule } from '@nestjs/schedule'; -import { ConfigModule } from '@nestjs/config'; +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 Environment from './common/utils/environment'; +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 { 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 { StatisticModule } from './modules/statistic/statistic.module'; -import { QualificationModule } from './modules/qualification/qualification.module'; -import { TransformEnumInterceptor } from './common/interceptors/transform-enum.interceptor'; -import Environment from './common/utils/environment'; @Module({ providers: [ @@ -49,8 +49,20 @@ import Environment from './common/utils/environment'; 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 73566fd130..42c4847b41 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 @@ -10,7 +10,6 @@ import { UseGuards, UseInterceptors, } from '@nestjs/common'; - import { ApiBearerAuth, ApiBody, @@ -18,6 +17,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'; @@ -144,6 +144,7 @@ export class AuthJwtController { @Public() @HttpCode(204) + @Throttle({ default: { limit: 3, ttl: 60000 } }) @Post('/forgot-password') @ApiOperation({ summary: 'Forgot Password', @@ -162,8 +163,11 @@ export class AuthJwtController { status: 404, description: 'Not Found. Could not find the requested content.', }) - public async forgotPassword(@Body() data: ForgotPasswordDto): Promise { - await this.authService.forgotPassword(data); + public async forgotPassword( + @Body() data: ForgotPasswordDto, + @Ip() ip: string, + ): Promise { + 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) { diff --git a/yarn.lock b/yarn.lock index a9334c71b9..ca12e958cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4426,6 +4426,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" @@ -6698,6 +6699,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"