Skip to content

Commit

Permalink
Merge pull request #38 from chvarkov/develop
Browse files Browse the repository at this point in the history
Added support reCAPTCHA v3. Updated external interfaces. Updated vers…
  • Loading branch information
chvarkov authored Apr 2, 2021
2 parents 59f254a + af86571 commit 0c39946
Show file tree
Hide file tree
Showing 16 changed files with 178 additions and 34 deletions.
46 changes: 43 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@ export class AppModule {
}
```

**Configuration for reCAPTCHA V3**

```typescript
@Module({
imports: [
GoogleRecaptchaModule.forRoot({
secretKey: process.env.GOOGLE_RECAPTCHA_SECRET_KEY,
response: (req: IncomingMessage) => (req.headers.recaptcha || '').toString(),
skipIf: process.env.NODE_ENV !== 'production',
agent: null,
actions: ['SignUp', 'SignIn'],
score: 0.8,
})
],
})
export class AppModule {
}
```

**Configuration for GraphQL application**

```typescript
Expand Down Expand Up @@ -78,6 +97,8 @@ export class AppModule {
| `network` | Optional.<br> Type: `GoogleRecaptchaNetwork` \| `boolean`<br> Default: `GoogleRecaptchaNetwork.Google` <br> If your server has trouble connecting to https://google.com then you can set networks:<br> `GoogleRecaptchaNetwork.Google` = 'https://www.google.com/recaptcha/api/siteverify'<br>`GoogleRecaptchaNetwork.Recaptcha` = 'https://recaptcha.net/recaptcha/api/siteverify'<br> or set any api url |
| `applicationType` | Optional.<br> Type: `ApplicationType` <br> Default: `ApplicationType.Rest` <br> Application type affect on type of request argument on `response` provider function <br> Request types:<br> `ApplicationType.Rest` - `(req: express.Request \| fastify.Request) => string \| Promise<string>` <br> `ApplicationType.GraphQL` - `(req: http.IncommingMessage) => string \| Promise<string>` |
| `agent` | Optional.<br> Type: `https.Agent`<br> If you need to use an agent |
| `score` | Optional.<br> Type: `number` \| `(score: number) => boolean`<br> Score validator for reCAPTCHA v3. <br> `number` - minimum available score. <br> `(score: number) => boolean` - function with custom validation rules. |
| `actions` | Optional.<br> Type: `string[]`<br> Available action list for reCAPTCHA v3. <br> You can make this check stricter by passing the action property parameter to `@Recaptcha(...)` decorator. |

If you want import configs from your [ConfigService](https://docs.nestjs.com/techniques/configuration#getting-started) via [custom getter function](https://docs.nestjs.com/techniques/configuration#custom-getter-functions) that will return `GoogleRecaptchaModuleOptions` object.

Expand Down Expand Up @@ -106,7 +127,11 @@ export class SomeService {
}

async someAction(recaptchaToken: string): Promise<void> {
const result = await this.recaptchaValidator.validate(recaptchaToken);
const result = await this.recaptchaValidator.validate({
response: recaptchaToken,
score: 0.8,
action: 'SomeAction',
});

if (!result.success) {
throw new GoogleRecaptchaException(result.errors);
Expand Down Expand Up @@ -139,7 +164,22 @@ You can override default property that contain recaptcha for specific endpoint.

@Controller('feedback')
export class FeedbackController {
@Recaptcha(req => req.body.recaptha)
@Recaptcha({response: req => req.body.recaptha})
@Post('send')
async send(): Promise<any> {
// TODO: Your implementation.
}
}

```

Also you can override recaptcha v3 options.

```typescript

@Controller('feedback')
export class FeedbackController {
@Recaptcha({response: req => req.body.recaptha, action: 'Send', score: 0.8})
@Post('send')
async send(): Promise<any> {
// TODO: Your implementation.
Expand Down Expand Up @@ -190,7 +230,7 @@ export class RecipesResolver {
}

// Overridden default header. This query using X-Recaptcha header
@Recaptcha((req: IncomingMessage) => (req.headers['x-recaptcha'] || '').toString())
@Recaptcha({response: (req: IncomingMessage) => (req.headers['x-recaptcha'] || '').toString()})
@Query(returns => [Recipe])
recipes(@Args() recipesArgs: RecipesArgs): Promise<Recipe[]> {
// TODO: Your implementation.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nestlab/google-recaptcha",
"version": "1.2.4",
"version": "2.0.0",
"description": "Google recaptcha module for NestJS.",
"keywords": [
"nest",
Expand All @@ -19,7 +19,7 @@
"main": "index.js",
"scripts": {
"build": "rimraf dist && tsc && cp package.json dist && cp README.md dist && cp LICENSE dist && cp CONTRIBUTING.md dist",
"test": "jest",
"test": "jest --silent=false",
"test:cov": "jest --coverage"
},
"repository": {
Expand Down
8 changes: 4 additions & 4 deletions src/decorators/recaptcha.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common';
import { GoogleRecaptchaGuard } from '../guards/google-recaptcha.guard';
import { RecaptchaResponseProvider } from '../types';
import { RECAPTCHA_RESPONSE_PROVIDER } from '../provider.declarations';
import { VerifyResponseDecoratorOptions } from '../interfaces/verify-response-decorator-options';
import { RECAPTCHA_VALIDATION_OPTIONS } from '../provider.declarations';

export function Recaptcha(response?: RecaptchaResponseProvider): MethodDecorator & ClassDecorator {
export function Recaptcha(options?: VerifyResponseDecoratorOptions): MethodDecorator & ClassDecorator {
return applyDecorators(
SetMetadata(RECAPTCHA_RESPONSE_PROVIDER, response),
SetMetadata(RECAPTCHA_VALIDATION_OPTIONS, options),
UseGuards(GoogleRecaptchaGuard),
);
}
4 changes: 3 additions & 1 deletion src/enums/error-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ export enum ErrorCode {
InvalidInputResponse = 'invalid-input-response',
BadRequest = 'bad-request',
TimeoutOrDuplicate = 'timeout-or-duplicate',
UnknownError = 'unknown-error'
UnknownError = 'unknown-error',
ForbiddenAction = 'forbidden-action',
LowScore = 'low-score',
}
10 changes: 9 additions & 1 deletion src/exceptions/google-recaptcha.exception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export class GoogleRecaptchaException extends HttpException {
case ErrorCode.MissingInputSecret:
return 'Invalid module configuration. Please check public-secret keys.';

case ErrorCode.LowScore:
return 'Low recaptcha score.'

case ErrorCode.ForbiddenAction:
return 'Forbidden recaptcha action.'

case ErrorCode.UnknownError:
case ErrorCode.BadRequest:
default:
Expand All @@ -31,7 +37,9 @@ export class GoogleRecaptchaException extends HttpException {
private static getErrorStatus(errorCode: ErrorCode): number {
return errorCode === ErrorCode.InvalidInputResponse ||
errorCode === ErrorCode.MissingInputResponse ||
errorCode === ErrorCode.TimeoutOrDuplicate
errorCode === ErrorCode.TimeoutOrDuplicate ||
errorCode === ErrorCode.ForbiddenAction ||
errorCode === ErrorCode.LowScore
? HttpStatus.BAD_REQUEST
: HttpStatus.INTERNAL_SERVER_ERROR;
}
Expand Down
14 changes: 9 additions & 5 deletions src/guards/google-recaptcha.guard.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { CanActivate, ExecutionContext, Inject, Injectable } from '@nestjs/common';
import { GoogleRecaptchaValidator } from '../services/google-recaptcha.validator';
import { GoogleRecaptchaGuardOptions } from '../interfaces/google-recaptcha-guard-options';
import { RECAPTCHA_OPTIONS, RECAPTCHA_RESPONSE_PROVIDER } from '../provider.declarations';
import { RECAPTCHA_OPTIONS, RECAPTCHA_VALIDATION_OPTIONS } from '../provider.declarations';
import { GoogleRecaptchaException } from '../exceptions/google-recaptcha.exception';
import { Reflector } from '@nestjs/core';
import { RecaptchaRequestResolver } from '../services/recaptcha-request.resolver';
import { ApplicationType } from '../enums/application-type';
import { VerifyResponseDecoratorOptions } from '../interfaces/verify-response-decorator-options';

@Injectable()
export class GoogleRecaptchaGuard implements CanActivate {
Expand All @@ -26,13 +27,16 @@ export class GoogleRecaptchaGuard implements CanActivate {
return true;
}

const provider = this.reflector.get(RECAPTCHA_RESPONSE_PROVIDER, context.getHandler());
const options: VerifyResponseDecoratorOptions = this.reflector.get(RECAPTCHA_VALIDATION_OPTIONS, context.getHandler());

const response = provider
? await provider(request)
const response = options?.response
? await options?.response(request)
: await this.options.response(request);

const result = await this.validator.validate(response);
const score = options?.score || this.options.score;
const action = options?.action;

const result = await this.validator.validate({response, score, action});

if (result.success) {
return true;
Expand Down
3 changes: 2 additions & 1 deletion src/interfaces/google-recaptcha-guard-options.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { RecaptchaResponseProvider } from '../types';
import { RecaptchaResponseProvider, ScoreValidator } from '../types';
import { ApplicationType } from '../enums/application-type';

export interface GoogleRecaptchaGuardOptions {
response: RecaptchaResponseProvider;
applicationType?: ApplicationType;
skipIf?: boolean | ((request: any) => boolean | Promise<boolean>);
score?: ScoreValidator;
}
1 change: 1 addition & 0 deletions src/interfaces/google-recaptcha-validation-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ import { ErrorCode } from '../enums/error-code';

export interface GoogleRecaptchaValidationResult {
success: boolean;
score?: number;
errors: ErrorCode[];
}
4 changes: 4 additions & 0 deletions src/interfaces/google-recaptcha-validator-options.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import * as https from 'https';
import { GoogleRecaptchaNetwork } from '../enums/google-recaptcha-network';
import { ScoreValidator } from '../types';

export interface GoogleRecaptchaValidatorOptions {
secretKey: string;
actions?: string[];
score?: ScoreValidator;

/**
* If your server has trouble connecting to https://google.com then you can set networks:
* GoogleRecaptchaNetwork.Google = 'https://www.google.com/recaptcha/api/siteverify'
Expand Down
13 changes: 13 additions & 0 deletions src/interfaces/verify-response-decorator-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { RecaptchaResponseProvider, ScoreValidator } from '../types';

export interface VerifyResponseDecoratorOptions {
response?: RecaptchaResponseProvider;
score?: ScoreValidator;
action?: string;
}

export interface VerifyResponseOptions {
response: string;
score?: ScoreValidator;
action?: string;
}
13 changes: 13 additions & 0 deletions src/interfaces/verify-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ErrorCode } from '../enums/error-code';

export interface VerifyResponseV2 {
success: boolean;
challenge_ts: string;
hostname: string;
errors: ErrorCode[]
}

export interface VerifyResponseV3 extends VerifyResponseV2 {
score: number;
action: string;
}
2 changes: 1 addition & 1 deletion src/provider.declarations.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export const RECAPTCHA_OPTIONS = Symbol('RECAPTCHA_OPTIONS');

export const RECAPTCHA_RESPONSE_PROVIDER = Symbol('RECAPTCHA_RESPONSE_PROVIDER');
export const RECAPTCHA_VALIDATION_OPTIONS = Symbol('RECAPTCHA_VALIDATION_OPTIONS');
84 changes: 70 additions & 14 deletions src/services/google-recaptcha.validator.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { HttpService, Inject, Injectable } from '@nestjs/common';
import { GoogleRecaptchaValidatorOptions } from '../interfaces/google-recaptcha-validator-options';
import { RECAPTCHA_OPTIONS } from '../provider.declarations';
import {HttpService, Inject, Injectable} from '@nestjs/common';
import {GoogleRecaptchaValidatorOptions} from '../interfaces/google-recaptcha-validator-options';
import {RECAPTCHA_OPTIONS} from '../provider.declarations';
import * as qs from 'querystring';
import { GoogleRecaptchaValidationResult } from '../interfaces/google-recaptcha-validation-result';
import { ErrorCode } from '../enums/error-code';
import { GoogleRecaptchaNetwork } from '../enums/google-recaptcha-network';
import {GoogleRecaptchaValidationResult} from '../interfaces/google-recaptcha-validation-result';
import {GoogleRecaptchaNetwork} from '../enums/google-recaptcha-network';
import {ScoreValidator} from '../types';
import {VerifyResponseOptions} from '../interfaces/verify-response-decorator-options';
import {VerifyResponseV2, VerifyResponseV3} from '../interfaces/verify-response';
import {ErrorCode} from '../enums/error-code';

@Injectable()
export class GoogleRecaptchaValidator {
Expand All @@ -15,25 +18,78 @@ export class GoogleRecaptchaValidator {
@Inject(RECAPTCHA_OPTIONS) private readonly options: GoogleRecaptchaValidatorOptions) {
}

validate(response: string): Promise<GoogleRecaptchaValidationResult> {
const data = qs.stringify({secret: this.options.secretKey, response});
async validate(options: VerifyResponseOptions): Promise<GoogleRecaptchaValidationResult> {
const result = await this.verifyResponse<VerifyResponseV3>(options.response);

if (!this.isUseV3(result)) {
return result;
}

console.log(result)

if (!this.isValidAction(result.action, options)) {
result.success = false;
result.errors.push(ErrorCode.ForbiddenAction);
}

if (!this.isValidScore(result.score, options.score)) {
result.success = false;
result.errors.push(ErrorCode.LowScore);
}

return result;
}

private verifyResponse<T extends VerifyResponseV2>(response: string): Promise<T> {
const data = qs.stringify({secret: this.options.secretKey, response});
const url = this.options.network || this.defaultNetwork;

return this.http.post(url, data, {
headers: this.headers,
httpsAgent: this.options.agent
}
)
headers: this.headers,
httpsAgent: this.options.agent
})
.toPromise()
.then(res => res.data)
.then(result => ({
success: result.success,
...result,
errors: result['error-codes'] || [],
}))
.then(result => {
delete result['error-codes'];
return result;
})
.catch(() => ({
success: false,
errors: [ErrorCode.UnknownError],
}));
}))
}

private isValidAction(action: string, options?: VerifyResponseOptions): boolean {
if (options.action) {
return options.action === action;
}

return this.options.actions
? this.options.actions.includes(action)
: true;
}

private isValidScore(score: number, validator?: ScoreValidator): boolean {
const finalValidator = validator || this.options.score;

if (finalValidator) {
if (typeof finalValidator === 'function') {
return finalValidator(score);
}

return score >= finalValidator;
}

return true;
}

private isUseV3(v: VerifyResponseV2): v is VerifyResponseV3 {
return ('score' in v && typeof v['score'] === 'number') &&
('action' in v && typeof v['action'] === 'string');
}
}
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export type RecaptchaResponseProvider = (req) => string | Promise<string>;

export type ScoreValidator = number | ((score: number) => boolean);
2 changes: 1 addition & 1 deletion test/assets/test-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ export class TestController {
@Recaptcha()
submit(): void {}

@Recaptcha(req => req.body.customRecaptchaField)
@Recaptcha({response: req => req.body.customRecaptchaField})
submitOverridden(): void {}
}
2 changes: 1 addition & 1 deletion test/helpers/create-google-recaptcha-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ import { HttpService } from '@nestjs/common';
import { GoogleRecaptchaValidatorOptions } from '../../src/interfaces/google-recaptcha-validator-options';

export function createGoogleRecaptchaValidator(options: GoogleRecaptchaValidatorOptions): GoogleRecaptchaValidator {
return new GoogleRecaptchaValidator(new HttpService(), options);
return new GoogleRecaptchaValidator(new HttpService(), options);
}

0 comments on commit 0c39946

Please sign in to comment.