Skip to content

Commit

Permalink
Recaptcha integration (#9)
Browse files Browse the repository at this point in the history
* Create recaptcha libraries

* Recaptcha implementation and verification for generation route

* Protect authentication actions with recaptcha

* Deactivate recaptcha security locally

* Fix lint and test issues

* Add unit tests for google recaptcha generator
  • Loading branch information
abarghoud authored Nov 1, 2024
1 parent 8787765 commit 4dba7f9
Show file tree
Hide file tree
Showing 67 changed files with 1,326 additions and 85 deletions.
3 changes: 0 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,3 @@ Thumbs.db

.nx/cache
.nx/workspace-data

# .env file
apps/client/.env
5 changes: 5 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
RECAPTCHA_SITE_KEY=
FIREBASE_PROJECT_ID=
GOOGLE_APPLICATION_CREDENTIALS=
FRONTEND_URL=
ENVIRONMENT=local
2 changes: 2 additions & 0 deletions apps/api/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.env
gcp-key.json
3 changes: 3 additions & 0 deletions apps/api/src/@types/recaptcha.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare let grecaptcha: ReCaptchaV2.ReCaptcha & {
enterprise: ReCaptchaV2.ReCaptcha;
};
27 changes: 21 additions & 6 deletions apps/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,23 @@ import { PPSApi } from './pps/third-party/pps-api';
import { IPPSApiResponseAuthenticationMetaDataExtractorSymbol } from './pps/domain/authentication-metadata-extractor/pps-api-response-authentication-metadata-extractor.interface';
import { PPSApiResponseAuthenticationMetaDataExtractor } from './pps/domain/authentication-metadata-extractor/pps-api-response-authentication-metadata-extractor';
import { KeepAliveController } from './keep-alive/keep-alive.controller';
import { IRecaptchaCheckerSymbol } from '@pps-easy/recaptcha/contracts';
import { GoogleRecaptchaChecker, LocalRecaptchaChecker } from '@pps-easy/recaptcha/google';
import * as process from 'node:process';
import { RecaptchaController } from './recpatcha/recaptcha.controller';
import { RecaptchaGuard } from './guards/recaptcha.guard';

const isLocalEnvironment = process.env.ENVIRONMENT;

@Module({
imports: [HttpModule.register({
httpsAgent: new Agent({
rejectUnauthorized: false
imports: [
HttpModule.register({
httpsAgent: new Agent({
rejectUnauthorized: false
}),
}),
}),],
controllers: [GenerateController, KeepAliveController],
],
controllers: [GenerateController, KeepAliveController, RecaptchaController],
providers: [
{ provide: IPPSGenerateUseCaseSymbol, useClass: PPSGeneratorUseCase },
{ provide: IPPSApiSymbol, useClass: PPSApi, scope: Scope.REQUEST },
Expand All @@ -34,7 +42,14 @@ import { KeepAliveController } from './keep-alive/keep-alive.controller';
{
provide: IHtmlParserSymbol,
useClass: HtmlParser,
}
},
{
provide: IRecaptchaCheckerSymbol,
useValue: isLocalEnvironment ?
new LocalRecaptchaChecker() :
new GoogleRecaptchaChecker(process.env.RECAPTCHA_SITE_KEY || '', process.env.FIREBASE_PROJECT_ID || '')
},
RecaptchaGuard
],
})
export class AppModule implements OnModuleInit {
Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/app/generate.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { mock } from 'jest-mock-extended';
import { GenerateController } from './generate.controller';
import { PPSProfileDto } from './pps/domain/pps-profile-dto.model';
import { IPPSGenerateUseCase, IPPSGenerateUseCaseSymbol } from './pps/usecase/pps-generate-usecase.interface';
import { IRecaptchaCheckerSymbol } from '@pps-easy/recaptcha/contracts';
import { LocalRecaptchaChecker } from '@pps-easy/recaptcha/google';

describe('The GenerateController class', () => {
let app: TestingModule;
Expand All @@ -12,7 +14,10 @@ describe('The GenerateController class', () => {
beforeAll(async () => {
app = await Test.createTestingModule({
controllers: [GenerateController],
providers: [{ provide: IPPSGenerateUseCaseSymbol, useValue: ppsGeneratorUseCase }],
providers: [
{ provide: IPPSGenerateUseCaseSymbol, useValue: ppsGeneratorUseCase },
{ provide: IRecaptchaCheckerSymbol, useClass: LocalRecaptchaChecker },
],
}).compile();
});

Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/app/generate.controller.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { Body, Controller, Inject, Post } from '@nestjs/common';
import { Body, Controller, Inject, Post, UseGuards } from '@nestjs/common';

import { PPSGeneratorUseCase } from './pps/usecase/pps-generator-usecase.service';
import { PPSProfileDto } from './pps/domain/pps-profile-dto.model';
import { IPPSGenerateUseCaseSymbol } from './pps/usecase/pps-generate-usecase.interface';
import { RecaptchaGuard } from './guards/recaptcha.guard';

@Controller('/pps')
export class GenerateController {
constructor(@Inject(IPPSGenerateUseCaseSymbol) private readonly ppsGeneratorUseCase: PPSGeneratorUseCase) {}

@Post('/generate')
@UseGuards(RecaptchaGuard)
public generate(@Body() ppsProfileDto: PPSProfileDto): Promise<string> {
try {
return this.ppsGeneratorUseCase.generate(ppsProfileDto);
Expand Down
113 changes: 113 additions & 0 deletions apps/api/src/app/guards/recaptcha.guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Test, TestingModule } from '@nestjs/testing';

import { mock } from 'jest-mock-extended';

import { ChallengeResultData, IRecaptchaChecker, IRecaptchaCheckerSymbol } from '@pps-easy/recaptcha/contracts';

import { RecaptchaGuard } from './recaptcha.guard';
import { BadRequestException, ExecutionContext, ForbiddenException } from '@nestjs/common';

describe('The RecaptchaGuard', () => {
let app: TestingModule;
const recaptchaCheckerMock = mock<IRecaptchaChecker>()
const executionContextMock = mock<ExecutionContext>();
const getRequest = jest.fn();

beforeAll(() => {
executionContextMock.switchToHttp.mockReturnValue({ getRequest, getNext: jest.fn(), getResponse: jest.fn() });
});

beforeAll(async () => {
app = await Test.createTestingModule({
providers: [
RecaptchaGuard,
{ provide: IRecaptchaCheckerSymbol, useValue: recaptchaCheckerMock }
],
})
.compile()
});

it('should be defined', () => {
expect(app.get(RecaptchaGuard)).toBeDefined();
});

describe('The canActivate method', () => {
describe('When context http request does not contain body', () => {
beforeEach(() => {
getRequest.mockReturnValueOnce({ body: undefined });
});

it('should return false', () => {
const guard = app.get(RecaptchaGuard);

expect(guard.canActivate(executionContextMock)).toBe(false);
});
});

describe('When context http request body does not contain recaptchaToken', () => {
beforeEach(() => {
getRequest.mockReturnValueOnce({ body: {} });
});

it('should throw BadRequestException', () => {
const guard = app.get(RecaptchaGuard);

expect(() => guard.canActivate(executionContextMock)).toThrow(expect.any(BadRequestException));
});
});

describe('When context http request body contains a not valid recaptchaToken', () => {
beforeEach(() => {
const challengeResult: ChallengeResultData = {
reasons: [],
score: 0,
isValid: false,
}
recaptchaCheckerMock.check.mockResolvedValue(challengeResult);
getRequest.mockReturnValueOnce({ body: { recaptchaToken: 'notValidRecaptchaToken' } });
});

it('should throw forbidden exception', () => {
const guard = app.get(RecaptchaGuard);

expect(guard.canActivate(executionContextMock)).rejects.toEqual(expect.any(ForbiddenException));
});
});

describe('When context http request body contains a bad score check result', () => {
beforeEach(() => {
const challengeResult: ChallengeResultData = {
reasons: [],
score: 0.4,
isValid: true,
}
recaptchaCheckerMock.check.mockResolvedValue(challengeResult);
getRequest.mockReturnValueOnce({ body: { recaptchaToken: 'badScoreRecaptchaToken' } });
});

it('should throw forbidden exception', () => {
const guard = app.get(RecaptchaGuard);

expect(guard.canActivate(executionContextMock)).rejects.toEqual(expect.any(ForbiddenException));
});
});

describe('When context http request body contains a good score check result', () => {
beforeEach(() => {
const challengeResult: ChallengeResultData = {
reasons: [],
score: 0.6,
isValid: true,
}
recaptchaCheckerMock.check.mockResolvedValue(challengeResult);
getRequest.mockReturnValueOnce({ body: { recaptchaToken: 'goodScoreRecaptchaToken' } });
});

it('should resolve true', () => {
const guard = app.get(RecaptchaGuard);

expect(guard.canActivate(executionContextMock)).resolves.toBe(true);
});
});
});
});
49 changes: 49 additions & 0 deletions apps/api/src/app/guards/recaptcha.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
BadRequestException,
CanActivate,
ExecutionContext,
ForbiddenException,
Inject,
Injectable
} from '@nestjs/common';
import { Request } from 'express';

import { Observable } from 'rxjs';

import { IRecaptchaChecker, IRecaptchaCheckerSymbol } from '@pps-easy/recaptcha/contracts';
import { ChallengeResult } from '@pps-easy/recaptcha/domain';

@Injectable()
export class RecaptchaGuard implements CanActivate {
public constructor(@Inject(IRecaptchaCheckerSymbol) private readonly recaptchaChecker: IRecaptchaChecker) {
}

public canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request: Request = context.switchToHttp().getRequest();

if (!request.body) {
return false;
}

if (!request.body.recaptchaToken) {
throw new BadRequestException('Body must contain recaptchaToken');
}

return new Promise<boolean>((resolve, reject) => {
this.recaptchaChecker.check(request.body.recaptchaToken).then((result) => {
const challengeResult = new ChallengeResult(result);
const isValidChallenge = challengeResult.checkIsValid();

if (!isValidChallenge) {
reject(new ForbiddenException());

return;
}

resolve(isValidChallenge);
});
});
}
}
15 changes: 15 additions & 0 deletions apps/api/src/app/recpatcha/recaptcha.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Body, Controller, Inject, Post } from '@nestjs/common';

import { ChallengeResultData, IRecaptchaChecker, IRecaptchaCheckerSymbol } from '@pps-easy/recaptcha/contracts';

import { RecaptchaDto } from './recaptcha.dto';

@Controller('/recaptcha')
export class RecaptchaController {
constructor(@Inject(IRecaptchaCheckerSymbol) private readonly recaptchaChecker: IRecaptchaChecker) {}

@Post('/check')
public check(@Body() recaptchaDto: RecaptchaDto): Promise<ChallengeResultData> {
return this.recaptchaChecker.check(recaptchaDto.token);
}
}
7 changes: 7 additions & 0 deletions apps/api/src/app/recpatcha/recaptcha.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';

export class RecaptchaDto {
@IsString()
@IsNotEmpty()
token: string;
}
1 change: 1 addition & 0 deletions apps/client/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ REACT_APP_FIREBASE_PROJECT_ID=your-project-id
REACT_APP_FIREBASE_STORAGE_BUCKET=your-storage-bucket
REACT_APP_FIREBASE_MESSAGING_SENDER_ID=your-messaging-sender-id
REACT_APP_FIREBASE_APP_ID=your-app-id
ENVIRONMENT=local
1 change: 1 addition & 0 deletions apps/client/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.env
3 changes: 2 additions & 1 deletion apps/client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<meta charset="utf-8" />
<title>PPS Easy</title>
<base href="/" />
<script src="https://www.google.com/recaptcha/enterprise.js?render=%VITE_REACT_APP_RECAPTCHA_SITE_KEY%"></script>

<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
Expand All @@ -16,4 +17,4 @@
<script type="module" src="/src/main.tsx"></script>
</body>

</html>
</html>
3 changes: 3 additions & 0 deletions apps/client/src/app/@types/recaptcha.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare let grecaptcha: ReCaptchaV2.ReCaptcha & {
enterprise: ReCaptchaV2.ReCaptcha;
};
31 changes: 31 additions & 0 deletions apps/client/src/app/api/pps-certificate-api-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { AxiosInstance } from 'axios';

import { IRecaptchaGenerator } from '@pps-easy/recaptcha/contracts';

import { GeneratePPSPayload, IPPSCertificateApiService } from './pps-certificate-service.requirements';

export class PPSCertificateApiService implements IPPSCertificateApiService {
public constructor(private readonly httpService: AxiosInstance, private readonly recaptchaGenerator: IRecaptchaGenerator) {}

public async generate(payload: GeneratePPSPayload) {
try {
const recaptchaToken = await this.recaptchaGenerator.generate();
const response = await this.httpService.post('/api/pps/generate', { ...payload, recaptchaToken });

return response.data;
} catch (error) {
PPSCertificateApiService.handleApiError(error);
throw error;
}
}

private static handleApiError(error: unknown): void {
console.error('Error generating PPS:', error);

if (error instanceof Error && error.message.includes('CORS')) {
throw new Error('CORS error: Communication problem with the server.');
} else {
throw new Error('An error has occurred during certificate generation.');
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ export interface GeneratePPSPayload {
lastname: string;
}

export interface IPPSCertificateService {
generate: (payload: GeneratePPSPayload) => Promise<string>;
export interface IPPSCertificateApiService {
generate(payload: GeneratePPSPayload): Promise<string>;
}
27 changes: 0 additions & 27 deletions apps/client/src/app/api/pps-certificate-service.ts

This file was deleted.

Loading

0 comments on commit 4dba7f9

Please sign in to comment.