├── .prettierignore ├── .eslintignore ├── src ├── helpers │ ├── xor.ts │ ├── load-module.ts │ └── get-error-info.ts ├── interfaces │ ├── literal-object.ts │ ├── google-recaptcha-enterprise-options.ts │ ├── google-recaptcha-guard-options.ts │ ├── verify-response.ts │ ├── verify-response-decorator-options.ts │ ├── google-recaptcha-validator-options.ts │ ├── verify-response-enterprise.ts │ └── google-recaptcha-module-options.ts ├── enums │ ├── google-recaptcha-context.ts │ ├── google-recaptcha-network.ts │ ├── classification-reason.ts │ ├── google-recaptcha-enterprise-reason.ts │ └── error-code.ts ├── provider.declarations.ts ├── decorators │ ├── set-recaptcha-options.ts │ ├── recaptcha.ts │ └── recaptcha-result.ts ├── exceptions │ ├── google-recaptcha-network.exception.ts │ └── google-recaptcha.exception.ts ├── types.ts ├── services │ ├── recaptcha-request.resolver.ts │ ├── recaptcha-validator.resolver.ts │ ├── enterprise-reason.transformer.ts │ └── validators │ │ ├── abstract-google-recaptcha-validator.ts │ │ ├── google-recaptcha.validator.ts │ │ └── google-recaptcha-enterprise.validator.ts ├── models │ ├── recaptcha-config-ref.ts │ └── recaptcha-verification-result.ts ├── index.ts ├── guards │ └── google-recaptcha.guard.ts └── google-recaptcha.module.ts ├── .prettierrc ├── test ├── jest.json ├── xor.spec.ts ├── integrations │ ├── graphql │ │ ├── schema.gql │ │ └── graphql-recaptcha-v2-v3.spec.ts │ ├── http-recaptcha-v2-v3.spec.ts │ └── http-recaptcha-enterprice.spec.ts ├── assets │ ├── test-config-module.ts │ ├── test-recaptcha-options-factory.ts │ ├── test-config-service.ts │ ├── test-controller.ts │ └── test-error-filter.ts ├── recaptcha-request-resolver.spec.ts ├── load-module.spec.ts ├── helpers │ ├── create-google-recaptcha-validator.ts │ ├── create-google-recaptcha-enterprise-validator.ts │ └── create-execution-context.ts ├── google-recaptcha-network-exception.spec.ts ├── set-recaptcha-options.spec.ts ├── network │ └── test-recaptcha-network.ts ├── enterprise-reason.transformer.spec.ts ├── recaptcha-validator-resolver.spec.ts ├── google-recaptcha-module.spec.ts ├── google-recaptcha-validator.spec.ts ├── get-error-info.spec.ts ├── utils │ ├── test-http.ts │ └── mocked-recaptcha-api.ts ├── recaptcha-verification-result.spec.ts ├── recaptcha-config-ref.spec.ts ├── recaptcha-verification-result-enterprise.spec.ts ├── google-recaptcha-exception.spec.ts ├── google-recaptcha-async-module.spec.ts └── google-recaptcha-guard.spec.ts ├── tsconfig.json ├── .editorconfig ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── workflows │ └── test.yml └── PULL_REQUEST_TEMPLATE.md ├── .npmignore ├── .eslintrc.json ├── LICENSE ├── CONTRIBUTING.md ├── package.json ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | coverage/* 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | test 4 | -------------------------------------------------------------------------------- /src/helpers/xor.ts: -------------------------------------------------------------------------------- 1 | export function xor(a: boolean, b: boolean): boolean { 2 | return !!a !== !!b; 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "semi": true, 5 | "singleQuote": true, 6 | "printWidth": 140, 7 | "useTabs": true 8 | } 9 | -------------------------------------------------------------------------------- /src/interfaces/literal-object.ts: -------------------------------------------------------------------------------- 1 | export interface LiteralObject { 2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 3 | [key: string]: any; 4 | } 5 | -------------------------------------------------------------------------------- /src/interfaces/google-recaptcha-enterprise-options.ts: -------------------------------------------------------------------------------- 1 | export interface GoogleRecaptchaEnterpriseOptions { 2 | projectId: string; 3 | siteKey: string; 4 | apiKey: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/enums/google-recaptcha-context.ts: -------------------------------------------------------------------------------- 1 | export enum GoogleRecaptchaContext { 2 | GoogleRecaptcha = 'GoogleRecaptcha', 3 | GoogleRecaptchaEnterprise = 'GoogleRecaptchaEnterprise', 4 | } 5 | -------------------------------------------------------------------------------- /src/enums/google-recaptcha-network.ts: -------------------------------------------------------------------------------- 1 | export enum GoogleRecaptchaNetwork { 2 | Google = 'https://www.google.com/recaptcha/api/siteverify', 3 | Recaptcha = 'https://recaptcha.net/recaptcha/api/siteverify', 4 | } 5 | -------------------------------------------------------------------------------- /test/jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": "..", 4 | "testEnvironment": "node", 5 | "testRegex": ".spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/provider.declarations.ts: -------------------------------------------------------------------------------- 1 | export const RECAPTCHA_OPTIONS = Symbol('RECAPTCHA_OPTIONS'); 2 | 3 | export const RECAPTCHA_VALIDATION_OPTIONS = Symbol('RECAPTCHA_VALIDATION_OPTIONS'); 4 | 5 | export const RECAPTCHA_AXIOS_INSTANCE = 'RECAPTCHA_AXIOS_INSTANCE'; 6 | 7 | export const RECAPTCHA_LOGGER = 'RECAPTCHA_LOGGER'; 8 | -------------------------------------------------------------------------------- /test/xor.spec.ts: -------------------------------------------------------------------------------- 1 | import { xor } from '../src/helpers/xor'; 2 | 3 | describe('xor', () => { 4 | test('Test', () => { 5 | expect(xor(true, false)).toBeTruthy(); 6 | expect(xor(false, true)).toBeTruthy(); 7 | 8 | expect(xor(true, true)).toBeFalsy(); 9 | expect(xor(false, false)).toBeFalsy(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/interfaces/google-recaptcha-guard-options.ts: -------------------------------------------------------------------------------- 1 | import { RecaptchaRemoteIpProvider, RecaptchaResponseProvider, ScoreValidator, SkipIfValue } from '../types'; 2 | 3 | export interface GoogleRecaptchaGuardOptions { 4 | response: RecaptchaResponseProvider; 5 | remoteIp?: RecaptchaRemoteIpProvider; 6 | skipIf?: SkipIfValue; 7 | score?: ScoreValidator; 8 | } 9 | -------------------------------------------------------------------------------- /src/interfaces/verify-response.ts: -------------------------------------------------------------------------------- 1 | import { ErrorCode } from '../enums/error-code'; 2 | 3 | export interface VerifyResponseV2 { 4 | success: boolean; 5 | challenge_ts: string; 6 | hostname: string; 7 | errors: ErrorCode[]; 8 | } 9 | 10 | export interface VerifyResponseV3 extends VerifyResponseV2 { 11 | score: number; 12 | action: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/enums/classification-reason.ts: -------------------------------------------------------------------------------- 1 | export enum ClassificationReason { 2 | CLASSIFICATION_REASON_UNSPECIFIED = 'CLASSIFICATION_REASON_UNSPECIFIED', 3 | AUTOMATION = 'AUTOMATION', 4 | UNEXPECTED_ENVIRONMENT = 'UNEXPECTED_ENVIRONMENT', 5 | TOO_MUCH_TRAFFIC = 'TOO_MUCH_TRAFFIC', 6 | UNEXPECTED_USAGE_PATTERNS = 'UNEXPECTED_USAGE_PATTERNS', 7 | LOW_CONFIDENCE_SCORE = 'LOW_CONFIDENCE_SCORE', 8 | } 9 | -------------------------------------------------------------------------------- /src/enums/google-recaptcha-enterprise-reason.ts: -------------------------------------------------------------------------------- 1 | export enum GoogleRecaptchaEnterpriseReason { 2 | InvalidReasonUnspecified = 'INVALID_REASON_UNSPECIFIED', 3 | UnknownInvalidReason = 'UNKNOWN_INVALID_REASON', 4 | Malformed = 'MALFORMED', 5 | Expired = 'EXPIRED', 6 | Dupe = 'DUPE', 7 | SiteMismatch = 'SITE_MISMATCH', 8 | Missing = 'MISSING', 9 | BrowserError = 'BROWSER_ERROR', 10 | } 11 | -------------------------------------------------------------------------------- /test/integrations/graphql/schema.gql: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------ 2 | # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) 3 | # ------------------------------------------------------ 4 | 5 | type Feedback { 6 | title: String! 7 | } 8 | 9 | type Query { 10 | sayHello(name: String!): String! 11 | } 12 | 13 | type Mutation { 14 | submitFeedback(title: String!): Feedback! 15 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": false, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "declarationMap": false, 9 | "target": "es2017", 10 | "sourceMap": false, 11 | "outDir": "./dist", 12 | "baseUrl": "./src", 13 | "rootDir": "./src" 14 | }, 15 | "exclude": ["node_modules", "dist", "test"] 16 | } 17 | -------------------------------------------------------------------------------- /test/assets/test-config-module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TestConfigService } from './test-config-service'; 3 | import { GoogleRecaptchaModuleOptionsFactory } from './test-recaptcha-options-factory'; 4 | 5 | @Module({ 6 | providers: [TestConfigService, GoogleRecaptchaModuleOptionsFactory], 7 | exports: [TestConfigService, GoogleRecaptchaModuleOptionsFactory], 8 | }) 9 | export class TestConfigModule {} 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 4 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.ts] 11 | quote_type = single 12 | ij_typescript_use_double_quotes = false 13 | ij_typescript_use_semicolon_after_statement = true 14 | max_line_length = 140 15 | ij_smart_tabs = true 16 | 17 | [*.md] 18 | max_line_length = off 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /src/decorators/set-recaptcha-options.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | import { VerifyResponseDecoratorOptions } from '../interfaces/verify-response-decorator-options'; 3 | import { RECAPTCHA_VALIDATION_OPTIONS } from '../provider.declarations'; 4 | 5 | export function SetRecaptchaOptions(options?: VerifyResponseDecoratorOptions): MethodDecorator & ClassDecorator { 6 | return SetMetadata(RECAPTCHA_VALIDATION_OPTIONS, options); 7 | } 8 | -------------------------------------------------------------------------------- /src/helpers/load-module.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { LiteralObject } from '../interfaces/literal-object'; 3 | 4 | export function loadModule(moduleName: string, logError = false): LiteralObject { 5 | try { 6 | return require(moduleName); 7 | } catch (e) { 8 | if (logError) { 9 | Logger.error(`Module '${moduleName}' not found. \nPotential solution npm i ${moduleName}`); 10 | } 11 | throw e; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/exceptions/google-recaptcha-network.exception.ts: -------------------------------------------------------------------------------- 1 | import { GoogleRecaptchaException } from './google-recaptcha.exception'; 2 | import { ErrorCode } from '../enums/error-code'; 3 | 4 | export class GoogleRecaptchaNetworkException extends GoogleRecaptchaException { 5 | constructor(public readonly networkErrorCode?: string) { 6 | super([ErrorCode.NetworkError], networkErrorCode ? `Network error '${networkErrorCode}'.` : 'Unknown network error.'); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ContextType } from '@nestjs/common'; 2 | 3 | export type RecaptchaResponseProvider = (req) => string | Promise; 4 | 5 | export type RecaptchaRemoteIpProvider = (req) => string | Promise; 6 | 7 | export type ScoreValidator = number | ((score: number) => boolean); 8 | 9 | export type SkipIfValue = boolean | ((request: Req) => boolean | Promise); 10 | 11 | export type RecaptchaContextType = ContextType | 'graphql'; 12 | -------------------------------------------------------------------------------- /test/assets/test-recaptcha-options-factory.ts: -------------------------------------------------------------------------------- 1 | import { GoogleRecaptchaModuleOptions, GoogleRecaptchaOptionsFactory } from '../../src/interfaces/google-recaptcha-module-options'; 2 | import { TestConfigService } from './test-config-service'; 3 | 4 | export class GoogleRecaptchaModuleOptionsFactory implements GoogleRecaptchaOptionsFactory { 5 | createGoogleRecaptchaOptions(): Promise { 6 | return Promise.resolve(new TestConfigService().getGoogleRecaptchaOptions()); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/recaptcha-request-resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { RecaptchaRequestResolver } from '../src/services/recaptcha-request.resolver'; 2 | import { createExecutionContext } from './helpers/create-execution-context'; 3 | 4 | describe('RecaptchaRequestResolver', () => { 5 | const resolver = new RecaptchaRequestResolver(); 6 | 7 | test('Negative', () => { 8 | expect(() => resolver.resolve(createExecutionContext(() => null, null, 'unsupported'))).toThrowError('Unsupported request type'); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/interfaces/verify-response-decorator-options.ts: -------------------------------------------------------------------------------- 1 | import { RecaptchaRemoteIpProvider, RecaptchaResponseProvider, ScoreValidator } from '../types'; 2 | 3 | export interface VerifyResponseDecoratorOptions { 4 | response?: RecaptchaResponseProvider; 5 | remoteIp?: RecaptchaRemoteIpProvider; 6 | score?: ScoreValidator; 7 | action?: string; 8 | } 9 | 10 | export interface VerifyResponseOptions { 11 | response: string; 12 | remoteIp?: string; 13 | score?: ScoreValidator; 14 | action?: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/decorators/recaptcha.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, UseGuards } from '@nestjs/common'; 2 | import { GoogleRecaptchaGuard } from '../guards/google-recaptcha.guard'; 3 | import { VerifyResponseDecoratorOptions } from '../interfaces/verify-response-decorator-options'; 4 | import { SetRecaptchaOptions } from './set-recaptcha-options'; 5 | 6 | export function Recaptcha(options?: VerifyResponseDecoratorOptions): MethodDecorator & ClassDecorator { 7 | return applyDecorators(SetRecaptchaOptions(options), UseGuards(GoogleRecaptchaGuard)); 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | .env 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | -------------------------------------------------------------------------------- /test/assets/test-config-service.ts: -------------------------------------------------------------------------------- 1 | import { GoogleRecaptchaModuleOptions, GoogleRecaptchaNetwork } from '../../src'; 2 | import * as https from 'https'; 3 | 4 | export class TestConfigService { 5 | getGoogleRecaptchaOptions(): GoogleRecaptchaModuleOptions { 6 | return { 7 | secretKey: 'secret', 8 | response: (req) => req.body.recaptcha, 9 | skipIf: () => true, 10 | network: GoogleRecaptchaNetwork.Google, 11 | axiosConfig: { 12 | httpsAgent: new https.Agent({ 13 | timeout: 15_000, 14 | }), 15 | }, 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/helpers/get-error-info.ts: -------------------------------------------------------------------------------- 1 | import * as axios from 'axios'; 2 | import { LiteralObject } from '../interfaces/literal-object'; 3 | 4 | export function isAxiosError(error: Error | axios.AxiosError): error is axios.AxiosError { 5 | return (error).isAxiosError; 6 | } 7 | 8 | export function getErrorInfo(error: Error): string | LiteralObject { 9 | if (isAxiosError(error)) { 10 | return error.response?.data || error.code || 'Unknown axios error'; 11 | } 12 | 13 | return { error: error.name, message: error.message, stack: error.stack }; 14 | } 15 | -------------------------------------------------------------------------------- /test/load-module.spec.ts: -------------------------------------------------------------------------------- 1 | import { loadModule } from '../src/helpers/load-module'; 2 | import { Reflector } from '@nestjs/core'; 3 | 4 | describe('loadModule', () => { 5 | test('load', () => { 6 | const module = loadModule('@nestjs/core'); 7 | expect(module).toBeDefined(); 8 | expect(module.Reflector).toEqual(Reflector); 9 | }); 10 | 11 | test('failed load', () => { 12 | expect(() => loadModule('@unknown/unknown-package', true)).toThrowError('Cannot find module'); 13 | expect(() => loadModule('@unknown/unknown-package', false)).toThrowError('Cannot find module'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/decorators/recaptcha-result.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | import { loadModule } from '../helpers/load-module'; 3 | 4 | export const RecaptchaResult = createParamDecorator((data, context: ExecutionContext) => { 5 | switch (context.getType<'http' | 'graphql'>()) { 6 | case 'http': 7 | return context.switchToHttp().getRequest().recaptchaValidationResult; 8 | case 'graphql': 9 | return loadModule('@nestjs/graphql', true).GqlExecutionContext.create(context).getContext().req?.connection?._httpMessage?.req 10 | ?.recaptchaValidationResult; 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: chvarkov 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Describe steps to reproduce the behavior. Code examples. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /test/helpers/create-google-recaptcha-validator.ts: -------------------------------------------------------------------------------- 1 | import { GoogleRecaptchaValidator } from '../../src/services/validators/google-recaptcha.validator'; 2 | import { Logger } from '@nestjs/common'; 3 | import { GoogleRecaptchaModuleOptions } from '../../src'; 4 | import axios from 'axios'; 5 | import { RecaptchaConfigRef } from '../../src/models/recaptcha-config-ref'; 6 | 7 | export function createGoogleRecaptchaValidator(options: GoogleRecaptchaModuleOptions): GoogleRecaptchaValidator { 8 | return new GoogleRecaptchaValidator( 9 | axios.create(options.axiosConfig), 10 | new Logger(), 11 | new RecaptchaConfigRef(options), 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/enums/error-code.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorCode { 2 | MissingInputSecret = 'missing-input-secret', 3 | InvalidInputSecret = 'invalid-input-secret', 4 | MissingInputResponse = 'missing-input-response', 5 | InvalidInputResponse = 'invalid-input-response', 6 | BadRequest = 'bad-request', 7 | TimeoutOrDuplicate = 'timeout-or-duplicate', 8 | UnknownError = 'unknown-error', 9 | ForbiddenAction = 'forbidden-action', 10 | LowScore = 'low-score', 11 | InvalidKeys = 'invalid-keys', 12 | IncorrectCaptchaSol = 'incorrect-captcha-sol', 13 | NetworkError = 'network-error', 14 | // enterprise 15 | SiteMismatch = 'site-mismatch', 16 | BrowserError = 'browser-error', 17 | } 18 | -------------------------------------------------------------------------------- /test/assets/test-controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, UseGuards } from '@nestjs/common'; 2 | import { GoogleRecaptchaGuard, Recaptcha } from '../../src'; 3 | import { SetRecaptchaOptions } from '../../src/decorators/set-recaptcha-options'; 4 | 5 | @Controller('test') 6 | export class TestController { 7 | @Recaptcha() 8 | submit(): void { 9 | return; 10 | } 11 | 12 | @Recaptcha({ response: (req) => req.body.customRecaptchaField }) 13 | submitOverridden(): void { 14 | return; 15 | } 16 | 17 | @SetRecaptchaOptions({ action: 'TestOptions', score: 0.5 }) 18 | @UseGuards(GoogleRecaptchaGuard) 19 | submitWithSetRecaptchaOptionsDecorator(): void { 20 | return; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # project 2 | coverage 3 | src/* 4 | test/* 5 | .circleci/* 6 | index.ts 7 | 8 | #tests 9 | dist/test/* 10 | 11 | # dependencies 12 | /node_modules 13 | .env 14 | 15 | # Logs 16 | logs 17 | *.log 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | lerna-debug.log* 22 | 23 | # OS 24 | .DS_Store 25 | 26 | # Tests 27 | /coverage 28 | /.nyc_output 29 | 30 | # IDEs and editors 31 | /.idea 32 | .project 33 | .classpath 34 | .c9/ 35 | *.launch 36 | .settings/ 37 | *.sublime-workspace 38 | 39 | # IDE - VSCode 40 | .vscode/* 41 | !.vscode/settings.json 42 | !.vscode/tasks.json 43 | !.vscode/launch.json 44 | !.vscode/extensions.json 45 | 46 | .gitlab-ci.yml 47 | .tsconfig.json 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature request 6 | assignees: chvarkov 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /test/helpers/create-google-recaptcha-enterprise-validator.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { GoogleRecaptchaEnterpriseValidator, GoogleRecaptchaModuleOptions } from '../../src'; 3 | import { EnterpriseReasonTransformer } from '../../src/services/enterprise-reason.transformer'; 4 | import axios from 'axios'; 5 | import { RecaptchaConfigRef } from '../../src/models/recaptcha-config-ref'; 6 | 7 | export function createGoogleRecaptchaEnterpriseValidator(options: GoogleRecaptchaModuleOptions): GoogleRecaptchaEnterpriseValidator { 8 | return new GoogleRecaptchaEnterpriseValidator( 9 | axios.create(), 10 | new Logger(), 11 | new RecaptchaConfigRef(options), 12 | new EnterpriseReasonTransformer(), 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /test/assets/test-error-filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; 2 | import { GoogleRecaptchaException } from '../../src'; 3 | import { Response } from 'express'; 4 | 5 | @Catch(Error) 6 | export class TestErrorFilter implements ExceptionFilter { 7 | catch(exception: Error, host: ArgumentsHost): void { 8 | const res: Response = host.switchToHttp().getResponse(); 9 | 10 | if (exception instanceof GoogleRecaptchaException) { 11 | res.status(exception.getStatus()).send({ 12 | errorCodes: exception.errorCodes, 13 | }); 14 | 15 | return; 16 | } 17 | 18 | res.status(500).send({ 19 | name: exception.name, 20 | message: exception.message, 21 | stack: exception.stack, 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/services/recaptcha-request.resolver.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { loadModule } from '../helpers/load-module'; 3 | import { RecaptchaContextType } from '../types'; 4 | 5 | @Injectable() 6 | export class RecaptchaRequestResolver { 7 | resolve(context: ExecutionContext): T { 8 | const contextType: RecaptchaContextType = context.getType(); 9 | 10 | switch (contextType) { 11 | case 'http': 12 | return context.switchToHttp().getRequest(); 13 | 14 | case 'graphql': 15 | return loadModule('@nestjs/graphql', true).GqlExecutionContext.create(context).getContext().req?.socket?._httpMessage?.req; 16 | default: 17 | throw new Error(`Unsupported request type '${contextType}'.`); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true 5 | }, 6 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { 9 | "project": ["./tsconfig.json"] 10 | }, 11 | "plugins": ["@typescript-eslint"], 12 | "rules": { 13 | "no-async-promise-executor": "off", // Get rid of this 14 | "@typescript-eslint/explicit-function-return-type": [ 15 | "error", 16 | { 17 | "allowTypedFunctionExpressions": true 18 | } 19 | ], 20 | "semi": "off", 21 | "@typescript-eslint/semi": ["error"], 22 | "@typescript-eslint/lines-between-class-members": ["error"], 23 | "comma-dangle": ["error", "always-multiline"], 24 | "no-unused-vars": "off", 25 | "@typescript-eslint/no-unused-vars": ["error"] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/google-recaptcha-network-exception.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { GoogleRecaptchaNetworkException } from '../src/exceptions/google-recaptcha-network.exception'; 3 | 4 | describe('Google recaptcha network exception', () => { 5 | test('Test without error code', () => { 6 | const exception = new GoogleRecaptchaNetworkException(); 7 | expect(exception.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); 8 | expect(exception.message).toBe('Unknown network error.'); 9 | }); 10 | 11 | test('Test with error code', () => { 12 | const errCode = 'ECONNRESET'; 13 | const exception = new GoogleRecaptchaNetworkException(errCode); 14 | expect(exception.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); 15 | expect(exception.message.toString().includes(errCode)).toBeTruthy(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ "develop" ] 6 | pull_request: 7 | branches: [ "master", "develop" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [18.x, 20.x, 22.x] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | cache: 'npm' 23 | - run: npm ci 24 | - run: npm run build 25 | - run: npm run test:cov 26 | - name: Update Coverage Badge 27 | if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && matrix.node-version == '22.x'}} 28 | uses: we-cli/coverage-badge-action@main -------------------------------------------------------------------------------- /src/interfaces/google-recaptcha-validator-options.ts: -------------------------------------------------------------------------------- 1 | import { GoogleRecaptchaNetwork } from '../enums/google-recaptcha-network'; 2 | import { ScoreValidator } from '../types'; 3 | import { AxiosRequestConfig } from 'axios'; 4 | import { GoogleRecaptchaEnterpriseOptions } from './google-recaptcha-enterprise-options'; 5 | 6 | export interface GoogleRecaptchaValidatorOptions { 7 | secretKey?: string; 8 | actions?: string[]; 9 | score?: ScoreValidator; 10 | 11 | /** 12 | * If your server has trouble connecting to https://google.com then you can set networks: 13 | * GoogleRecaptchaNetwork.Google = 'https://www.google.com/recaptcha/api/siteverify' 14 | * GoogleRecaptchaNetwork.Recaptcha = 'https://recaptcha.net/recaptcha/api/siteverify' 15 | * or set any api url 16 | */ 17 | network?: GoogleRecaptchaNetwork | string; 18 | 19 | axiosConfig?: AxiosRequestConfig; 20 | 21 | enterprise?: GoogleRecaptchaEnterpriseOptions; 22 | } 23 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 4 | 5 | Fixes # (issue) 6 | 7 | # How Has This Been Tested? 8 | 9 | Please describe the tests that you ran to verify your changes. Please also note any relevant details for your test configuration. 10 | 11 | - [ ] reCAPTCHA V2 HTTP 12 | - [ ] reCAPTCHA V2 GraphQL 13 | - [ ] reCAPTCHA V3 HTTP 14 | - [ ] reCAPTCHA V3 GraphQL 15 | - [ ] reCAPTCHA Enterprise HTTP 16 | - [ ] reCAPTCHA Enterprise GraphQL 17 | 18 | # Checklist: 19 | 20 | - [ ] My code follows the style guidelines of this project 21 | - [ ] I have performed a self-review of my own code 22 | - [ ] I have commented my code, particularly in hard-to-understand areas 23 | - [ ] I have made corresponding changes to the documentation 24 | - [ ] My changes generate no new warnings 25 | - [ ] Any dependent changes have been merged and published in downstream modules 26 | -------------------------------------------------------------------------------- /src/models/recaptcha-config-ref.ts: -------------------------------------------------------------------------------- 1 | import { GoogleRecaptchaModuleOptions } from '../interfaces/google-recaptcha-module-options'; 2 | import { GoogleRecaptchaEnterpriseOptions } from '../interfaces/google-recaptcha-enterprise-options'; 3 | import { ScoreValidator, SkipIfValue } from '../types'; 4 | 5 | export class RecaptchaConfigRef { 6 | get valueOf(): GoogleRecaptchaModuleOptions { 7 | return this.value; 8 | } 9 | 10 | constructor(private readonly value: GoogleRecaptchaModuleOptions) { 11 | } 12 | 13 | setSecretKey(secretKey: string): this { 14 | this.value.secretKey = secretKey; 15 | this.value.enterprise = undefined; 16 | 17 | return this; 18 | } 19 | 20 | setEnterpriseOptions(options: GoogleRecaptchaEnterpriseOptions): this { 21 | this.value.secretKey = undefined; 22 | this.value.enterprise = options; 23 | 24 | return this; 25 | } 26 | 27 | setScore(score: ScoreValidator): this { 28 | this.value.score = score; 29 | 30 | return this; 31 | } 32 | 33 | setSkipIf(skipIf: SkipIfValue): this { 34 | this.value.skipIf = skipIf; 35 | 36 | return this; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test/set-recaptcha-options.spec.ts: -------------------------------------------------------------------------------- 1 | import { createExecutionContext } from './helpers/create-execution-context'; 2 | import { TestController } from './assets/test-controller'; 3 | import { Reflector } from '@nestjs/core'; 4 | import { RECAPTCHA_VALIDATION_OPTIONS } from '../src/provider.declarations'; 5 | import { VerifyResponseDecoratorOptions } from '../src/interfaces/verify-response-decorator-options'; 6 | 7 | describe('Set recaptcha options decorator', () => { 8 | let controller: TestController; 9 | let reflector: Reflector; 10 | 11 | beforeAll(async () => { 12 | controller = new TestController(); 13 | reflector = new Reflector(); 14 | }); 15 | 16 | test('Test options', () => { 17 | const executionContext = createExecutionContext(controller.submitWithSetRecaptchaOptionsDecorator, {}); 18 | const handler = executionContext.getHandler(); 19 | 20 | const options: VerifyResponseDecoratorOptions = reflector.get(RECAPTCHA_VALIDATION_OPTIONS, handler); 21 | 22 | expect(options.response).toBeUndefined(); 23 | expect(options.action).toBe('TestOptions'); 24 | expect(options.score).toBe(0.5); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Alexey Chvarkov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/interfaces/verify-response-enterprise.ts: -------------------------------------------------------------------------------- 1 | import { GoogleRecaptchaEnterpriseReason } from '../enums/google-recaptcha-enterprise-reason'; 2 | import { ClassificationReason } from '../enums/classification-reason'; 3 | 4 | export interface VerifyResponseEnterprise { 5 | tokenProperties?: VerifyResponseEnterpriseTokenProperties; 6 | riskAnalysis?: VerifyResponseEnterpriseRiskAnalysis; 7 | event: VerifyTokenEnterpriseResponseEvent; 8 | name: string; 9 | } 10 | 11 | export interface VerifyTokenEnterpriseEvent { 12 | token: string; 13 | siteKey: string; 14 | expectedAction: string; 15 | userIpAddress?: string; 16 | } 17 | 18 | export interface VerifyTokenEnterpriseResponseEvent extends VerifyTokenEnterpriseEvent { 19 | userAgent: string; 20 | userIpAddress: string; 21 | hashedAccountId: string; 22 | } 23 | 24 | export interface VerifyResponseEnterpriseTokenProperties { 25 | valid: boolean; 26 | invalidReason?: GoogleRecaptchaEnterpriseReason; 27 | hostname: string; 28 | action: string; 29 | createTime: string; 30 | } 31 | 32 | export interface VerifyResponseEnterpriseRiskAnalysis { 33 | score: number; 34 | reasons: ClassificationReason[]; 35 | } 36 | -------------------------------------------------------------------------------- /src/services/recaptcha-validator.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AbstractGoogleRecaptchaValidator } from './validators/abstract-google-recaptcha-validator'; 3 | import { GoogleRecaptchaValidator } from './validators/google-recaptcha.validator'; 4 | import { GoogleRecaptchaEnterpriseValidator } from './validators/google-recaptcha-enterprise.validator'; 5 | import { RecaptchaConfigRef } from '../models/recaptcha-config-ref'; 6 | 7 | @Injectable() 8 | export class RecaptchaValidatorResolver { 9 | constructor( 10 | private readonly configRef: RecaptchaConfigRef, 11 | protected readonly googleRecaptchaValidator: GoogleRecaptchaValidator, 12 | protected readonly googleRecaptchaEnterpriseValidator: GoogleRecaptchaEnterpriseValidator, 13 | ) {} 14 | 15 | resolve(): AbstractGoogleRecaptchaValidator { 16 | const configValue = this.configRef.valueOf; 17 | if (configValue.secretKey) { 18 | return this.googleRecaptchaValidator; 19 | } 20 | 21 | if (Object.keys(configValue.enterprise || {}).length) { 22 | return this.googleRecaptchaEnterpriseValidator; 23 | } 24 | 25 | throw new Error('Cannot resolve google recaptcha validator'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Recaptcha } from './decorators/recaptcha'; 2 | export { SetRecaptchaOptions } from './decorators/set-recaptcha-options'; 3 | export { RecaptchaResult } from './decorators/recaptcha-result'; 4 | export { GoogleRecaptchaGuard } from './guards/google-recaptcha.guard'; 5 | export { GoogleRecaptchaModule } from './google-recaptcha.module'; 6 | export { GoogleRecaptchaModuleOptions } from './interfaces/google-recaptcha-module-options'; 7 | export { ErrorCode } from './enums/error-code'; 8 | export { GoogleRecaptchaNetwork } from './enums/google-recaptcha-network'; 9 | export { GoogleRecaptchaException } from './exceptions/google-recaptcha.exception'; 10 | export { GoogleRecaptchaNetworkException } from './exceptions/google-recaptcha-network.exception'; 11 | export { GoogleRecaptchaValidator } from './services/validators/google-recaptcha.validator'; 12 | export { GoogleRecaptchaEnterpriseValidator } from './services/validators/google-recaptcha-enterprise.validator'; 13 | export { RecaptchaVerificationResult } from './models/recaptcha-verification-result'; 14 | export { ClassificationReason } from './enums/classification-reason'; 15 | export { RecaptchaConfigRef } from './models/recaptcha-config-ref'; 16 | -------------------------------------------------------------------------------- /src/services/enterprise-reason.transformer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { GoogleRecaptchaEnterpriseReason } from '../enums/google-recaptcha-enterprise-reason'; 3 | import { ErrorCode } from '../enums/error-code'; 4 | 5 | @Injectable() 6 | export class EnterpriseReasonTransformer { 7 | transform(errCode: GoogleRecaptchaEnterpriseReason): ErrorCode | null { 8 | switch (errCode) { 9 | case GoogleRecaptchaEnterpriseReason.BrowserError: 10 | return ErrorCode.BrowserError; 11 | 12 | case GoogleRecaptchaEnterpriseReason.SiteMismatch: 13 | return ErrorCode.SiteMismatch; 14 | 15 | case GoogleRecaptchaEnterpriseReason.Expired: 16 | case GoogleRecaptchaEnterpriseReason.Dupe: 17 | return ErrorCode.TimeoutOrDuplicate; 18 | 19 | case GoogleRecaptchaEnterpriseReason.UnknownInvalidReason: 20 | case GoogleRecaptchaEnterpriseReason.Malformed: 21 | return ErrorCode.InvalidInputResponse; 22 | 23 | case GoogleRecaptchaEnterpriseReason.Missing: 24 | return ErrorCode.MissingInputResponse; 25 | 26 | case GoogleRecaptchaEnterpriseReason.InvalidReasonUnspecified: 27 | return null; 28 | 29 | default: 30 | return ErrorCode.UnknownError; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/services/validators/abstract-google-recaptcha-validator.ts: -------------------------------------------------------------------------------- 1 | import { VerifyResponseOptions } from '../../interfaces/verify-response-decorator-options'; 2 | import { ScoreValidator } from '../../types'; 3 | import { RecaptchaVerificationResult } from '../../models/recaptcha-verification-result'; 4 | import { RecaptchaConfigRef } from '../../models/recaptcha-config-ref'; 5 | 6 | export abstract class AbstractGoogleRecaptchaValidator { 7 | protected constructor(protected readonly options: RecaptchaConfigRef) {} 8 | 9 | abstract validate(options: VerifyResponseOptions): Promise>; 10 | 11 | protected isValidAction(action: string, options?: VerifyResponseOptions): boolean { 12 | if (options.action) { 13 | return options.action === action; 14 | } 15 | 16 | return this.options.valueOf.actions ? this.options.valueOf.actions.includes(action) : true; 17 | } 18 | 19 | protected isValidScore(score: number, validator?: ScoreValidator): boolean { 20 | const finalValidator = validator || this.options.valueOf.score; 21 | 22 | if (finalValidator) { 23 | if (typeof finalValidator === 'function') { 24 | return finalValidator(score); 25 | } 26 | 27 | return score >= finalValidator; 28 | } 29 | 30 | return true; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/network/test-recaptcha-network.ts: -------------------------------------------------------------------------------- 1 | import { Controller, INestApplication, Module, Post } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { LiteralObject } from '../../src/interfaces/literal-object'; 4 | 5 | @Controller('api') 6 | class TestSiteVerifyController { 7 | private value: LiteralObject; 8 | 9 | @Post('siteverify') 10 | verify(): LiteralObject { 11 | return this.value; 12 | } 13 | 14 | setResult(value: LiteralObject): void { 15 | this.value = value; 16 | } 17 | } 18 | 19 | @Module({ 20 | controllers: [TestSiteVerifyController], 21 | }) 22 | class TestRecaptchaModule {} 23 | 24 | export class TestRecaptchaNetwork { 25 | constructor(private readonly app: INestApplication, private readonly port: number) {} 26 | 27 | get url(): string { 28 | return `http://localhost:${this.port}/api/siteverify`; 29 | } 30 | 31 | setResult(value: LiteralObject): void { 32 | this.app.get(TestSiteVerifyController).setResult(value); 33 | } 34 | 35 | close(): Promise { 36 | return this.app.close(); 37 | } 38 | 39 | static async create(port: number): Promise { 40 | const app = await NestFactory.create(TestRecaptchaModule, { logger: false }); 41 | await app.listen(port); 42 | 43 | return new TestRecaptchaNetwork(app, port); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/interfaces/google-recaptcha-module-options.ts: -------------------------------------------------------------------------------- 1 | import { GoogleRecaptchaGuardOptions } from './google-recaptcha-guard-options'; 2 | import { GoogleRecaptchaValidatorOptions } from './google-recaptcha-validator-options'; 3 | import { ModuleMetadata, Type } from '@nestjs/common/interfaces'; 4 | import { Logger } from '@nestjs/common'; 5 | import { Abstract } from '@nestjs/common/interfaces/abstract.interface'; 6 | 7 | export interface GoogleRecaptchaModuleOptions extends GoogleRecaptchaValidatorOptions, GoogleRecaptchaGuardOptions { 8 | debug?: boolean; 9 | logger?: Logger; 10 | global?: boolean; 11 | } 12 | 13 | export interface GoogleRecaptchaOptionsFactory { 14 | createGoogleRecaptchaOptions(): Promise | GoogleRecaptchaModuleOptions; 15 | } 16 | 17 | export interface GoogleRecaptchaModuleAsyncOptions extends Pick { 18 | // eslint-disable-next-line 19 | inject?: Array | Function>; 20 | useClass?: Type; 21 | useExisting?: Type; 22 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 23 | useFactory?: (...args: any[]) => Promise> | Omit; 24 | global?: boolean; 25 | } 26 | -------------------------------------------------------------------------------- /test/enterprise-reason.transformer.spec.ts: -------------------------------------------------------------------------------- 1 | import { EnterpriseReasonTransformer } from '../src/services/enterprise-reason.transformer'; 2 | import { GoogleRecaptchaEnterpriseReason } from '../src/enums/google-recaptcha-enterprise-reason'; 3 | import { ErrorCode } from '../src'; 4 | 5 | describe('EnterpriseReasonTransformer', () => { 6 | const transformer = new EnterpriseReasonTransformer(); 7 | 8 | const expectedMap: Map = new Map([ 9 | [GoogleRecaptchaEnterpriseReason.InvalidReasonUnspecified, null], 10 | [GoogleRecaptchaEnterpriseReason.UnknownInvalidReason, ErrorCode.InvalidInputResponse], 11 | [GoogleRecaptchaEnterpriseReason.Malformed, ErrorCode.InvalidInputResponse], 12 | [GoogleRecaptchaEnterpriseReason.Expired, ErrorCode.TimeoutOrDuplicate], 13 | [GoogleRecaptchaEnterpriseReason.Dupe, ErrorCode.TimeoutOrDuplicate], 14 | [GoogleRecaptchaEnterpriseReason.SiteMismatch, ErrorCode.SiteMismatch], 15 | [GoogleRecaptchaEnterpriseReason.Missing, ErrorCode.MissingInputResponse], 16 | [GoogleRecaptchaEnterpriseReason.BrowserError, ErrorCode.BrowserError], 17 | ['UNKNOWN_ERROR_CODE_TEST' as GoogleRecaptchaEnterpriseReason, ErrorCode.UnknownError], 18 | ]); 19 | 20 | test('transform', () => { 21 | Array.from(expectedMap.keys()).forEach((enterpriseReason) => { 22 | const errorCode = transformer.transform(enterpriseReason); 23 | expect(errorCode).toBe(expectedMap.get(enterpriseReason)); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/models/recaptcha-verification-result.ts: -------------------------------------------------------------------------------- 1 | import { ErrorCode } from '../enums/error-code'; 2 | import { VerifyResponseEnterpriseRiskAnalysis } from '../interfaces/verify-response-enterprise'; 3 | import { LiteralObject } from '../interfaces/literal-object'; 4 | 5 | export interface RecaptchaVerificationResultOptions { 6 | success: boolean; 7 | nativeResponse: Res; 8 | hostname: string; 9 | action?: string; 10 | score?: number; 11 | remoteIp?: string; 12 | errors: ErrorCode[]; 13 | } 14 | 15 | export class RecaptchaVerificationResult { 16 | readonly success: boolean; 17 | 18 | readonly hostname: string; 19 | 20 | readonly remoteIp: string | undefined; 21 | 22 | readonly action: string | undefined; 23 | 24 | readonly score: number | undefined; 25 | 26 | readonly nativeResponse: Res; 27 | 28 | readonly errors: ErrorCode[]; 29 | 30 | constructor(private readonly options: RecaptchaVerificationResultOptions) { 31 | this.success = options.success; 32 | this.hostname = options.hostname; 33 | this.action = options.action; 34 | this.remoteIp = options.remoteIp; 35 | this.score = options.score; 36 | this.errors = options.errors; 37 | this.nativeResponse = options.nativeResponse; 38 | } 39 | 40 | toObject(): LiteralObject { 41 | return { 42 | success: this.success, 43 | hostname: this.hostname, 44 | action: this.action, 45 | score: this.score, 46 | remoteIp: this.remoteIp, 47 | errors: this.errors, 48 | nativeResponse: this.nativeResponse, 49 | }; 50 | } 51 | 52 | getResponse(): Res { 53 | return this.nativeResponse; 54 | } 55 | 56 | getEnterpriseRiskAnalytics(): VerifyResponseEnterpriseRiskAnalysis | null { 57 | const res = this.getResponse(); 58 | 59 | return res['riskAnalysis'] || null; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /test/helpers/create-execution-context.ts: -------------------------------------------------------------------------------- 1 | import { ContextType, ExecutionContext, Type } from '@nestjs/common'; 2 | import { HttpArgumentsHost, RpcArgumentsHost, WsArgumentsHost } from '@nestjs/common/interfaces'; 3 | import { Request } from 'express'; 4 | 5 | function createArgumentHost(req: Partial): HttpArgumentsHost { 6 | return new (class implements HttpArgumentsHost { 7 | getRequest(): T { 8 | return req as T; 9 | } 10 | 11 | getNext(): T { 12 | console.error("Method 'getNext' doesn't implemented"); 13 | return undefined; 14 | } 15 | 16 | getResponse(): T { 17 | console.error("Method 'getResponse' doesn't implemented"); 18 | return undefined; 19 | } 20 | })(); 21 | } 22 | 23 | export function createExecutionContext(handler: () => void, req: Partial, type: string = 'http'): ExecutionContext { 24 | return new (class implements ExecutionContext { 25 | getHandler(): () => void { 26 | return handler; 27 | } 28 | 29 | switchToHttp(): HttpArgumentsHost { 30 | return createArgumentHost(req); 31 | } 32 | 33 | getArgByIndex(index: number): T { 34 | console.error(`Method 'getArgByIndex(${index})' doesn't implemented`); 35 | return undefined; 36 | } 37 | 38 | getArgs(): T { 39 | console.error("Method 'getArgs' doesn't implemented"); 40 | return undefined; 41 | } 42 | 43 | getClass(): Type { 44 | console.error("Method 'getClass' doesn't implemented"); 45 | return undefined; 46 | } 47 | 48 | getType(): TContext { 49 | return type as unknown as TContext; 50 | } 51 | 52 | switchToRpc(): RpcArgumentsHost { 53 | console.error("Method 'switchToRpc' doesn't implemented"); 54 | return undefined; 55 | } 56 | 57 | switchToWs(): WsArgumentsHost { 58 | console.error("Method 'switchToWs' doesn't implemented"); 59 | return undefined; 60 | } 61 | })(); 62 | } 63 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for considering contributing to GoogleRecaptchaModule! 4 | 5 | ## Opening issues 6 | 7 | If you find a bug, please feel free to [open an issue](https://github.com/chvarkov/google-recaptcha/issues). 8 | 9 | If you taking the time to mention a problem, even a seemingly minor one, it is greatly appreciated, and a totally valid contribution to this project. Thank you! 10 | 11 | ## Fixing bugs 12 | 13 | We love pull requests. Here’s a quick guide: 14 | 15 | 1. [Fork this repository](https://github.com/chvarkov/google-recaptcha/fork) and then clone it locally: 16 | 17 | ```bash 18 | git clone https://github.com/chvarkov/google-recaptcha.git 19 | ``` 20 | 21 | 2. Create a topic branch for your changes: 22 | 23 | ```bash 24 | git checkout -b bugfix/for-that-thing 25 | ``` 26 | 3. Commit a failing test for the bug: 27 | 28 | ```bash 29 | git commit -am "Adds a failing test to demonstrate that thing" 30 | ``` 31 | 32 | 4. Commit a fix that makes the test pass: 33 | 34 | ```bash 35 | git commit -am "Adds a fix for that thing!" 36 | ``` 37 | 38 | 5. Run the tests: 39 | 40 | ```bash 41 | npm test 42 | ``` 43 | 44 | 6. Fix your changes by eslint rules: 45 | 46 | ```bash 47 | npm run lint:fix 48 | ``` 49 | 50 | 7. If everything looks good, push to your fork: 51 | 52 | ```bash 53 | git push origin fix-for-that-thing 54 | ``` 55 | 56 | 8. [Submit a pull request.](https://help.github.com/articles/creating-a-pull-request) 57 | 58 | 9. Enjoy being the wonderful person you are 59 | 60 | After you’ve opened your pull request, [you should email me](mailto:chvarkov.alexey@gmail.com) your mailing address so I can mail you a personal thank you note. Seriously! 61 | 62 | ## Adding new features 63 | 64 | Thinking of adding a new feature? Cool! [Open an issue](https://github.com/chvarkov/google-recaptcha/issues) and let’s design it together. 65 | -------------------------------------------------------------------------------- /test/recaptcha-validator-resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { GoogleRecaptchaEnterpriseValidator, GoogleRecaptchaModuleOptions, GoogleRecaptchaValidator } from '../src'; 2 | import { RecaptchaValidatorResolver } from '../src/services/recaptcha-validator.resolver'; 3 | import { RecaptchaConfigRef } from '../src/models/recaptcha-config-ref'; 4 | 5 | describe('RecaptchaValidatorResolver', () => { 6 | const validator = new GoogleRecaptchaValidator(null, null, null); 7 | const enterpriseValidator = new GoogleRecaptchaEnterpriseValidator(null, null, null, null); 8 | 9 | const createResolver = (options: GoogleRecaptchaModuleOptions) => 10 | new RecaptchaValidatorResolver(new RecaptchaConfigRef(options), validator, enterpriseValidator); 11 | 12 | test('resolve', () => { 13 | const moduleOptions: GoogleRecaptchaModuleOptions = { 14 | response: (): string => 'token', 15 | secretKey: 'Secret', 16 | }; 17 | 18 | const resolver = createResolver(moduleOptions); 19 | 20 | const resolvedValidator = resolver.resolve(); 21 | 22 | expect(resolvedValidator).toBeInstanceOf(GoogleRecaptchaValidator); 23 | }); 24 | 25 | test('resolve enterprise', () => { 26 | const moduleOptions: GoogleRecaptchaModuleOptions = { 27 | response: (): string => 'token', 28 | enterprise: { 29 | apiKey: 'enterprise_apiKey', 30 | siteKey: 'enterprise_siteKey', 31 | projectId: 'enterprise_projectId', 32 | }, 33 | }; 34 | 35 | const resolver = createResolver(moduleOptions); 36 | const resolvedValidator = resolver.resolve(); 37 | 38 | expect(resolvedValidator).toBeInstanceOf(GoogleRecaptchaEnterpriseValidator); 39 | }); 40 | 41 | test('resolve error', () => { 42 | const moduleOptions: GoogleRecaptchaModuleOptions = { 43 | response: (): string => 'token', 44 | }; 45 | 46 | const resolver = createResolver(moduleOptions); 47 | expect(() => resolver.resolve()).toThrowError('Cannot resolve'); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/google-recaptcha-module.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import { GoogleRecaptchaValidator } from '../src/services/validators/google-recaptcha.validator'; 4 | import { GoogleRecaptchaGuard, GoogleRecaptchaModuleOptions, GoogleRecaptchaModule } from '../src'; 5 | import { RECAPTCHA_OPTIONS } from '../src/provider.declarations'; 6 | 7 | describe('Google recaptcha module', () => { 8 | const customNetwork = 'CUSTOM_URL'; 9 | 10 | const createApp = async (options: GoogleRecaptchaModuleOptions): Promise => { 11 | const testingModule = await Test.createTestingModule({ 12 | imports: [GoogleRecaptchaModule.forRoot(options)], 13 | }).compile(); 14 | 15 | return testingModule.createNestApplication(); 16 | }; 17 | 18 | let app: INestApplication; 19 | 20 | beforeAll(async () => { 21 | app = await createApp({ 22 | secretKey: 'secret key', 23 | response: (req) => req.headers.authorization, 24 | skipIf: () => process.env.NODE_ENV !== 'production', 25 | network: customNetwork, 26 | global: false, 27 | }); 28 | }); 29 | 30 | test('Test validator provider', () => { 31 | const guard = app.get(GoogleRecaptchaValidator); 32 | 33 | expect(guard).toBeInstanceOf(GoogleRecaptchaValidator); 34 | }); 35 | 36 | test('Test guard provider', () => { 37 | const guard = app.get(GoogleRecaptchaGuard); 38 | 39 | expect(guard).toBeInstanceOf(GoogleRecaptchaGuard); 40 | }); 41 | 42 | test('Test use recaptcha net options', async () => { 43 | const options: GoogleRecaptchaModuleOptions = app.get(RECAPTCHA_OPTIONS); 44 | 45 | expect(options).toBeDefined(); 46 | expect(options.network).toBe(customNetwork); 47 | }); 48 | 49 | test('Test invalid config', async () => { 50 | await expect(createApp({ response: () => '' })).rejects.toThrowError('must be contains "secretKey" xor "enterprise"'); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /test/google-recaptcha-validator.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { ErrorCode, GoogleRecaptchaModule } from '../src'; 3 | import { GoogleRecaptchaValidator } from '../src/services/validators/google-recaptcha.validator'; 4 | import { GoogleRecaptchaNetworkException } from '../src/exceptions/google-recaptcha-network.exception'; 5 | 6 | describe('Google recaptcha validator', () => { 7 | test('Invalid secret', async () => { 8 | const module = await Test.createTestingModule({ 9 | imports: [ 10 | GoogleRecaptchaModule.forRoot({ 11 | response: () => 'TEST_TOKEN', 12 | secretKey: 'TEST_SECRET', 13 | }), 14 | ], 15 | }).compile(); 16 | 17 | const app = module.createNestApplication(); 18 | 19 | const validator = app.get(GoogleRecaptchaValidator); 20 | expect(validator).toBeDefined(); 21 | expect(validator).toBeInstanceOf(GoogleRecaptchaValidator); 22 | 23 | const result = await validator.validate({ response: 'TEST_TOKEN' }); 24 | 25 | expect(result).toBeDefined(); 26 | expect(result.success).toBeFalsy(); 27 | expect(result.errors).toBeInstanceOf(Array); 28 | expect(result.errors.length).toBe(1); 29 | expect(result.errors[0]).toBe(ErrorCode.InvalidInputResponse); 30 | }); 31 | 32 | test('Network error', async () => { 33 | const module = await Test.createTestingModule({ 34 | imports: [ 35 | GoogleRecaptchaModule.forRoot({ 36 | response: () => 'TEST_TOKEN', 37 | secretKey: 'TEST_SECRET', 38 | axiosConfig: { 39 | proxy: { 40 | port: 5555, 41 | host: 'invalidhost', 42 | }, 43 | }, 44 | }), 45 | ], 46 | }).compile(); 47 | 48 | const app = module.createNestApplication(); 49 | 50 | const validator = app.get(GoogleRecaptchaValidator); 51 | expect(validator).toBeDefined(); 52 | expect(validator).toBeInstanceOf(GoogleRecaptchaValidator); 53 | 54 | await expect(validator.validate({ response: 'TEST_TOKEN' })).rejects.toThrowError(GoogleRecaptchaNetworkException); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/get-error-info.spec.ts: -------------------------------------------------------------------------------- 1 | import { getErrorInfo } from '../src/helpers/get-error-info'; 2 | import { LiteralObject } from '../src/interfaces/literal-object'; 3 | import { AxiosError, InternalAxiosRequestConfig } from 'axios'; 4 | 5 | describe('getErrorInfo', () => { 6 | test('Error', () => { 7 | const err = new Error('Test error'); 8 | 9 | const info = getErrorInfo(err) as LiteralObject; 10 | 11 | expect(typeof info).toBe('object'); 12 | expect(info.error).toBe(err.name); 13 | expect(info.message).toBe(err.message); 14 | expect(info.stack).toBe(err.stack); 15 | }); 16 | 17 | test('Axios error data', () => { 18 | const err: Partial> = { 19 | isAxiosError: true, 20 | name: 'AxiosError', 21 | stack: new Error().stack, 22 | message: 'Request was failed', 23 | response: { 24 | headers: {}, 25 | status: 400, 26 | config: {} as InternalAxiosRequestConfig, 27 | request: {}, 28 | statusText: 'Bad request', 29 | data: { 30 | success: false, 31 | message: 'Invalid credentials', 32 | }, 33 | }, 34 | }; 35 | 36 | const info = getErrorInfo(err as AxiosError) as LiteralObject; 37 | 38 | expect(typeof info).toBe('object'); 39 | expect(info.success).toBeFalsy(); 40 | expect(info.message).toBe(err.response.data.message); 41 | }); 42 | 43 | test('Axios error code', () => { 44 | const err: Partial = { 45 | code: 'ECONNRESET', 46 | isAxiosError: true, 47 | name: 'AxiosError', 48 | stack: new Error().stack, 49 | message: 'Request was failed', 50 | }; 51 | 52 | const info = getErrorInfo(err as AxiosError) as string; 53 | 54 | expect(typeof info).toBe('string'); 55 | expect(info).toBe(err.code); 56 | }); 57 | 58 | test('Axios unknown error', () => { 59 | const err: Partial = { 60 | isAxiosError: true, 61 | }; 62 | 63 | const info = getErrorInfo(err as AxiosError) as string; 64 | 65 | expect(typeof info).toBe('string'); 66 | expect(info).toBe('Unknown axios error'); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /test/utils/test-http.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'supertest'; 2 | import { LiteralObject } from '../../src/interfaces/literal-object'; 3 | 4 | export interface ITestHttpRequestOptions { 5 | responseType?: 'stream' | 'json' | 'blob'; 6 | query?: LiteralObject; 7 | headers?: LiteralObject; 8 | } 9 | 10 | export class TestHttp { 11 | constructor(private readonly httpServer: unknown) {} 12 | 13 | get(url: string, options?: ITestHttpRequestOptions): Promise { 14 | return new Promise((resolve, reject) => 15 | this.addRequestOptions(request(this.httpServer).get(url), options).end((err, res) => (err ? reject(err) : resolve(res))) 16 | ); 17 | } 18 | 19 | post(url: string, body?: string | object, options?: ITestHttpRequestOptions): Promise { 20 | return new Promise((resolve, reject) => 21 | this.addRequestOptions(request(this.httpServer).post(url), options) 22 | .send(body) 23 | .end((err, res) => (err ? reject(err) : resolve(res))) 24 | ); 25 | } 26 | 27 | patch(url: string, body?: string | object, options?: ITestHttpRequestOptions): Promise { 28 | return new Promise((resolve, reject) => 29 | this.addRequestOptions(request(this.httpServer).patch(url), options) 30 | .send(body) 31 | .end((err, res) => (err ? reject(err) : resolve(res))) 32 | ); 33 | } 34 | 35 | delete(url: string, options?: ITestHttpRequestOptions): Promise { 36 | return new Promise((resolve, reject) => 37 | this.addRequestOptions(request(this.httpServer).delete(url), options).end((err, res) => (err ? reject(err) : resolve(res))) 38 | ); 39 | } 40 | 41 | private addRequestOptions(req: request.Test, options?: ITestHttpRequestOptions): request.Test { 42 | if (options?.query) { 43 | req.query(options?.query); 44 | } 45 | 46 | if (options?.headers) { 47 | for (const header of Object.keys(options.headers)) { 48 | req.set(header, options.headers[header]); 49 | } 50 | } 51 | 52 | if (options?.responseType) { 53 | req.responseType(options?.responseType); 54 | } 55 | 56 | return req; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/recaptcha-verification-result.spec.ts: -------------------------------------------------------------------------------- 1 | import { Controller, INestApplication, Post } from '@nestjs/common'; 2 | import { GoogleRecaptchaModule, Recaptcha, RecaptchaResult, RecaptchaVerificationResult } from '../src'; 3 | import { Test, TestingModule } from '@nestjs/testing'; 4 | import { Request } from 'express'; 5 | import { VerifyResponseV3 } from '../src/interfaces/verify-response'; 6 | import * as request from 'supertest'; 7 | import { RECAPTCHA_AXIOS_INSTANCE } from '../src/provider.declarations'; 8 | import axios from 'axios'; 9 | 10 | @Controller('test') 11 | class TestController { 12 | @Recaptcha() 13 | @Post('submit') 14 | testAction(@RecaptchaResult() result: RecaptchaVerificationResult): string { 15 | expect(result).toBeInstanceOf(RecaptchaVerificationResult); 16 | expect(result.success).toBeTruthy(); 17 | 18 | expect(result.getEnterpriseRiskAnalytics()).toBeNull(); 19 | expect(result.getResponse()).toBeDefined(); 20 | 21 | return 'OK'; 22 | } 23 | } 24 | 25 | describe('Recaptcha verification result decorator', () => { 26 | let module: TestingModule; 27 | let app: INestApplication; 28 | 29 | beforeAll(async () => { 30 | module = await Test.createTestingModule({ 31 | imports: [ 32 | GoogleRecaptchaModule.forRoot({ 33 | response: (req: Request): string => req.headers.recaptcha?.toString(), 34 | secretKey: 'secret', 35 | }), 36 | ], 37 | controllers: [TestController], 38 | }) 39 | .overrideProvider(RECAPTCHA_AXIOS_INSTANCE) 40 | .useFactory({ 41 | factory: () => { 42 | const responseV3: VerifyResponseV3 = { 43 | success: true, 44 | action: 'Submit', 45 | errors: [], 46 | score: 0.9, 47 | hostname: 'localhost', 48 | challenge_ts: new Date().toISOString(), 49 | }; 50 | return Object.assign(axios.create({}), { 51 | post: () => 52 | Promise.resolve({ 53 | data: responseV3, 54 | }), 55 | }); 56 | }, 57 | }) 58 | .compile(); 59 | 60 | app = module.createNestApplication(); 61 | 62 | await app.init(); 63 | }); 64 | 65 | test('Test', () => { 66 | return request(app.getHttpServer()).post('/test/submit').expect(201).expect('OK'); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /test/recaptcha-config-ref.spec.ts: -------------------------------------------------------------------------------- 1 | import { GoogleRecaptchaModuleOptions } from '../src'; 2 | import { RecaptchaConfigRef } from '../src/models/recaptcha-config-ref'; 3 | import { GoogleRecaptchaEnterpriseOptions } from '../src/interfaces/google-recaptcha-enterprise-options'; 4 | 5 | describe('RecaptchaConfigRef', () => { 6 | const options: GoogleRecaptchaModuleOptions = { 7 | secretKey: 'SECRET', 8 | response: () => 'RESPONSE', 9 | }; 10 | 11 | test('setSecretKey', () => { 12 | const ref = new RecaptchaConfigRef(options); 13 | expect(ref.valueOf.secretKey).toBe(options.secretKey); 14 | 15 | ref.setSecretKey('NEW_ONE'); 16 | expect(ref.valueOf.secretKey).toBe('NEW_ONE'); 17 | expect(options.secretKey).toBe('NEW_ONE'); 18 | 19 | expect(ref.valueOf.enterprise).toBeUndefined(); 20 | }); 21 | 22 | test('setSecretKey', () => { 23 | const ref = new RecaptchaConfigRef(options); 24 | expect(ref.valueOf.secretKey).toBe(options.secretKey); 25 | 26 | const eOpts: GoogleRecaptchaEnterpriseOptions = { 27 | apiKey: 'e_api_key', 28 | projectId: 'e_project_id', 29 | siteKey: 'e_site_key', 30 | }; 31 | 32 | ref.setEnterpriseOptions(eOpts); 33 | expect(ref.valueOf.enterprise.apiKey).toBe(eOpts.apiKey); 34 | expect(ref.valueOf.enterprise.projectId).toBe(eOpts.projectId); 35 | expect(ref.valueOf.enterprise.siteKey).toBe(eOpts.siteKey); 36 | expect(ref.valueOf.secretKey).toBeUndefined(); 37 | 38 | expect(options.enterprise.apiKey).toBe(eOpts.apiKey); 39 | expect(options.enterprise.projectId).toBe(eOpts.projectId); 40 | expect(options.enterprise.siteKey).toBe(eOpts.siteKey); 41 | expect(options.secretKey).toBeUndefined(); 42 | }); 43 | 44 | test('setScore', () => { 45 | const ref = new RecaptchaConfigRef(options); 46 | expect(ref.valueOf.secretKey).toBe(options.secretKey); 47 | 48 | ref.setScore(0.5); 49 | expect(ref.valueOf.score).toBe(0.5); 50 | expect(options.score).toBe(0.5); 51 | }); 52 | 53 | test('setSkipIf', () => { 54 | const ref = new RecaptchaConfigRef(options); 55 | expect(ref.valueOf.secretKey).toBe(options.secretKey); 56 | 57 | ref.setSkipIf(true); 58 | expect(ref.valueOf.skipIf).toBeTruthy(); 59 | expect(options.skipIf).toBeTruthy(); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/exceptions/google-recaptcha.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | import { ErrorCode } from '../enums/error-code'; 3 | 4 | export class GoogleRecaptchaException extends HttpException { 5 | constructor(public readonly errorCodes: ErrorCode[], errorMessage?: string) { 6 | super( 7 | errorMessage || GoogleRecaptchaException.getErrorMessage(errorCodes[0]), 8 | GoogleRecaptchaException.getErrorStatus(errorCodes[0]) 9 | ); 10 | } 11 | 12 | private static getErrorMessage(errorCode: ErrorCode): string { 13 | switch (errorCode) { 14 | case ErrorCode.InvalidInputResponse: 15 | return 'The response parameter is invalid or malformed.'; 16 | 17 | case ErrorCode.MissingInputResponse: 18 | return 'The response parameter is missing.'; 19 | case ErrorCode.TimeoutOrDuplicate: 20 | return 'The response is no longer valid: either is too old or has been used previously.'; 21 | 22 | case ErrorCode.InvalidInputSecret: 23 | case ErrorCode.MissingInputSecret: 24 | return 'Invalid module configuration. Please check public-secret keys.'; 25 | 26 | case ErrorCode.InvalidKeys: 27 | return 'Recaptcha token was signed by invalid api key.'; 28 | 29 | case ErrorCode.LowScore: 30 | return 'Low recaptcha score.'; 31 | 32 | case ErrorCode.ForbiddenAction: 33 | return 'Forbidden recaptcha action.'; 34 | 35 | case ErrorCode.SiteMismatch: 36 | return 'The user verification token did not match the provided site key.'; 37 | 38 | case ErrorCode.BrowserError: 39 | return 'Retriable error (such as network failure) occurred on the browser.'; 40 | 41 | case ErrorCode.IncorrectCaptchaSol: 42 | return 'incorrect-captcha-sol'; 43 | 44 | case ErrorCode.UnknownError: 45 | case ErrorCode.BadRequest: 46 | default: 47 | return 'Unexpected error. Please submit issue to @nestlab/google-recaptcha.'; 48 | } 49 | } 50 | 51 | private static getErrorStatus(errorCode: ErrorCode): number { 52 | return errorCode === ErrorCode.InvalidInputResponse || 53 | errorCode === ErrorCode.MissingInputResponse || 54 | errorCode === ErrorCode.TimeoutOrDuplicate || 55 | errorCode === ErrorCode.ForbiddenAction || 56 | errorCode === ErrorCode.SiteMismatch || 57 | errorCode === ErrorCode.BrowserError || 58 | errorCode === ErrorCode.IncorrectCaptchaSol || 59 | errorCode === ErrorCode.LowScore || 60 | errorCode === ErrorCode.InvalidKeys 61 | ? HttpStatus.BAD_REQUEST 62 | : HttpStatus.INTERNAL_SERVER_ERROR; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nestlab/google-recaptcha", 3 | "version": "3.10.0", 4 | "description": "Google recaptcha module for NestJS.", 5 | "keywords": [ 6 | "nestjs", 7 | "recaptcha", 8 | "google recaptcha", 9 | "nestjs recaptcha" 10 | ], 11 | "private": false, 12 | "main": "index.js", 13 | "scripts": { 14 | "build": "rimraf dist && tsc && cp package.json dist && cp README.md dist && cp LICENSE dist && cp CONTRIBUTING.md dist && cp CHANGELOG.md dist", 15 | "format": "prettier \"**/*.ts\" \"**/*.json\" --ignore-path ./.prettierignore --write", 16 | "lint:fix": "eslint . --fix", 17 | "lint:check": "eslint . --max-warnings=0", 18 | "test": "jest --silent=false", 19 | "test:cov": "jest --coverage --coverageReporters=\"json-summary\"", 20 | "publish-package": "cd dist && npm publish --access public" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/chvarkov/google-recaptcha.git" 25 | }, 26 | "author": "Alexey Chvarkov", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/chvarkov/google-recaptcha/issues" 30 | }, 31 | "homepage": "https://github.com/chvarkov/google-recaptcha", 32 | "dependencies": { 33 | "axios": "^1.8.4" 34 | }, 35 | "peerDependencies": { 36 | "@nestjs/common": ">=8.0.0 <12.0.0", 37 | "@nestjs/core": ">=8.0.0 <12.0.0" 38 | }, 39 | "peerDependenciesMeta": { 40 | "@nestjs/graphql": { 41 | "optional": true 42 | } 43 | }, 44 | "devDependencies": { 45 | "@nestjs/apollo": "^13.1.0", 46 | "@nestjs/axios": "^4.0.0", 47 | "@nestjs/common": "^11.1.3", 48 | "@nestjs/core": "^11.1.3", 49 | "@nestjs/graphql": "^13.1.0", 50 | "@nestjs/platform-express": "^11.1.3", 51 | "@nestjs/testing": "^11.1.3", 52 | "@types/express": "^4.17.13", 53 | "@types/jest": "^29.5.12", 54 | "@types/node": "^18.7.14", 55 | "@types/supertest": "^2.0.12", 56 | "@typescript-eslint/eslint-plugin": "^5.36.1", 57 | "@typescript-eslint/parser": "^5.36.1", 58 | "apollo-server-express": "^3.10.2", 59 | "eslint": "^8.23.0", 60 | "graphql": "^16.6.0", 61 | "jest": "^29.7.0", 62 | "prettier": "^2.7.1", 63 | "reflect-metadata": "^0.1.13", 64 | "rxjs": "^7.5.6", 65 | "supertest": "^6.3.3", 66 | "ts-jest": "^29.2.5", 67 | "ts-loader": "^9.3.1", 68 | "ts-node": "^10.9.1", 69 | "typescript": "^4.9.5" 70 | }, 71 | "jest": { 72 | "moduleFileExtensions": [ 73 | "js", 74 | "json", 75 | "ts" 76 | ], 77 | "rootDir": ".", 78 | "roots": [ 79 | "/test" 80 | ], 81 | "testRegex": ".spec.ts$", 82 | "transform": { 83 | "^.+\\.(t|j)s$": "ts-jest" 84 | }, 85 | "coverageDirectory": "./coverage", 86 | "testEnvironment": "node", 87 | "collectCoverageFrom": [ 88 | "src/**/*.ts" 89 | ] 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /test/recaptcha-verification-result-enterprise.spec.ts: -------------------------------------------------------------------------------- 1 | import { Controller, INestApplication, Post } from '@nestjs/common'; 2 | import { ClassificationReason, GoogleRecaptchaModule, Recaptcha, RecaptchaResult, RecaptchaVerificationResult } from '../src'; 3 | import { Test, TestingModule } from '@nestjs/testing'; 4 | import { Request } from 'express'; 5 | import * as request from 'supertest'; 6 | import { VerifyResponseEnterprise } from '../src/interfaces/verify-response-enterprise'; 7 | import { RECAPTCHA_AXIOS_INSTANCE } from '../src/provider.declarations'; 8 | import axios from 'axios'; 9 | 10 | @Controller('test') 11 | class TestController { 12 | @Recaptcha() 13 | @Post('submit') 14 | testAction(@RecaptchaResult() result: RecaptchaVerificationResult): string { 15 | expect(result).toBeInstanceOf(RecaptchaVerificationResult); 16 | expect(result.success).toBeTruthy(); 17 | 18 | expect(result.getResponse()).toBeDefined(); 19 | 20 | const riskAnalytics = result.getEnterpriseRiskAnalytics(); 21 | 22 | expect(riskAnalytics).toBeDefined(); 23 | expect(riskAnalytics.score).toBe(0.5); 24 | expect(riskAnalytics.reasons.length).toBe(1); 25 | expect(riskAnalytics.reasons[0]).toBe(ClassificationReason.AUTOMATION); 26 | 27 | return 'OK'; 28 | } 29 | } 30 | 31 | describe('Recaptcha verification result decorator (enterprise)', () => { 32 | let module: TestingModule; 33 | let app: INestApplication; 34 | 35 | beforeAll(async () => { 36 | module = await Test.createTestingModule({ 37 | imports: [ 38 | GoogleRecaptchaModule.forRoot({ 39 | response: (req: Request): string => req.headers.recaptcha?.toString(), 40 | enterprise: { 41 | siteKey: 'siteKey', 42 | apiKey: 'apiKey', 43 | projectId: 'projectId', 44 | }, 45 | }), 46 | ], 47 | controllers: [TestController], 48 | }) 49 | .overrideProvider(RECAPTCHA_AXIOS_INSTANCE) 50 | .useFactory({ 51 | factory: () => { 52 | const responseEnterprise: VerifyResponseEnterprise = { 53 | event: { 54 | expectedAction: 'Submit', 55 | siteKey: 'siteKey', 56 | token: 'token', 57 | hashedAccountId: 'id', 58 | userAgent: 'UA', 59 | userIpAddress: '0.0.0.0', 60 | }, 61 | riskAnalysis: { 62 | score: 0.5, 63 | reasons: [ClassificationReason.AUTOMATION], 64 | }, 65 | name: 'test/name', 66 | tokenProperties: { 67 | action: 'Submit', 68 | hostname: 'localhost', 69 | valid: true, 70 | createTime: new Date().toISOString(), 71 | }, 72 | }; 73 | return Object.assign(axios.create(), { 74 | post: () => 75 | Promise.resolve({ 76 | data: responseEnterprise, 77 | }), 78 | }); 79 | }, 80 | }) 81 | .compile(); 82 | 83 | app = module.createNestApplication(); 84 | 85 | await app.init(); 86 | }); 87 | 88 | test('Test', () => { 89 | return request(app.getHttpServer()).post('/test/submit').expect(201).expect('OK'); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/guards/google-recaptcha.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Inject, Injectable, Logger } from '@nestjs/common'; 2 | import { RECAPTCHA_LOGGER, RECAPTCHA_VALIDATION_OPTIONS } from '../provider.declarations'; 3 | import { GoogleRecaptchaException } from '../exceptions/google-recaptcha.exception'; 4 | import { Reflector } from '@nestjs/core'; 5 | import { RecaptchaRequestResolver } from '../services/recaptcha-request.resolver'; 6 | import { VerifyResponseDecoratorOptions } from '../interfaces/verify-response-decorator-options'; 7 | import { RecaptchaValidatorResolver } from '../services/recaptcha-validator.resolver'; 8 | import { GoogleRecaptchaContext } from '../enums/google-recaptcha-context'; 9 | import { AbstractGoogleRecaptchaValidator } from '../services/validators/abstract-google-recaptcha-validator'; 10 | import { GoogleRecaptchaEnterpriseValidator } from '../services/validators/google-recaptcha-enterprise.validator'; 11 | import { LiteralObject } from '../interfaces/literal-object'; 12 | import { RecaptchaConfigRef } from '../models/recaptcha-config-ref'; 13 | 14 | @Injectable() 15 | export class GoogleRecaptchaGuard implements CanActivate { 16 | constructor( 17 | private readonly reflector: Reflector, 18 | private readonly requestResolver: RecaptchaRequestResolver, 19 | private readonly validatorResolver: RecaptchaValidatorResolver, 20 | @Inject(RECAPTCHA_LOGGER) private readonly logger: Logger, 21 | private readonly configRef: RecaptchaConfigRef, 22 | ) {} 23 | 24 | async canActivate(context: ExecutionContext): Promise { 25 | const request: LiteralObject = this.requestResolver.resolve(context); 26 | 27 | const skipIfValue = this.configRef.valueOf.skipIf; 28 | const skip = typeof skipIfValue === 'function' ? await skipIfValue(request) : !!skipIfValue; 29 | 30 | if (skip) { 31 | return true; 32 | } 33 | 34 | const options: VerifyResponseDecoratorOptions = this.reflector.get(RECAPTCHA_VALIDATION_OPTIONS, context.getHandler()); 35 | 36 | const [response, remoteIp] = await Promise.all([ 37 | options?.response ? await options.response(request) : await this.configRef.valueOf.response(request), 38 | options?.remoteIp ? await options.remoteIp(request) : await this.configRef.valueOf.remoteIp && this.configRef.valueOf.remoteIp(request), 39 | ]); 40 | 41 | const score = options?.score || this.configRef.valueOf.score; 42 | const action = options?.action; 43 | 44 | const validator = this.validatorResolver.resolve(); 45 | 46 | request.recaptchaValidationResult = await validator.validate({ response, remoteIp, score, action }); 47 | 48 | if (this.configRef.valueOf.debug) { 49 | const loggerCtx = this.resolveLogContext(validator); 50 | this.logger.debug(request.recaptchaValidationResult.toObject(), `${loggerCtx}.result`); 51 | } 52 | 53 | if (request.recaptchaValidationResult.success) { 54 | return true; 55 | } 56 | 57 | throw new GoogleRecaptchaException(request.recaptchaValidationResult.errors); 58 | } 59 | 60 | private resolveLogContext(validator: AbstractGoogleRecaptchaValidator): GoogleRecaptchaContext { 61 | return validator instanceof GoogleRecaptchaEnterpriseValidator 62 | ? GoogleRecaptchaContext.GoogleRecaptchaEnterprise 63 | : GoogleRecaptchaContext.GoogleRecaptcha; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/utils/mocked-recaptcha-api.ts: -------------------------------------------------------------------------------- 1 | import { LiteralObject } from '../../src/interfaces/literal-object'; 2 | import axios from 'axios'; 3 | import { AxiosRequestConfig, AxiosResponse, AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios"; 4 | import * as qs from 'qs'; 5 | 6 | export class MockedRecaptchaApi { 7 | private readonly responseMap: Map = new Map(); 8 | private readonly networkErrorMap: Map = new Map(); 9 | private readonly errorMap: Map = new Map< 10 | string, 11 | { statusCode?: number; code?: string; payload?: LiteralObject } 12 | >(); 13 | 14 | getAxios(): AxiosInstance { 15 | const responseMap = this.responseMap; 16 | const networkErrorMap = this.networkErrorMap; 17 | const errorMap = this.errorMap; 18 | const instance = axios.create({}); 19 | return Object.assign(instance, { 20 | post(url: string, data?: any, config?: AxiosRequestConfig): Promise> { 21 | const resolveFn = (d: any) => d?.['response'] || d?.['event']?.token || null; 22 | 23 | const token = typeof data === 'string' ? resolveFn(qs.parse(data)) : resolveFn(data); 24 | 25 | const response = responseMap.get(token); 26 | 27 | if (response) { 28 | const res: AxiosResponse = { 29 | data: response, 30 | status: 200, 31 | config: {} as InternalAxiosRequestConfig, 32 | request: {}, 33 | headers: {}, 34 | statusText: 'OK', 35 | }; 36 | return Promise.resolve(res); 37 | } 38 | 39 | const networkError = networkErrorMap.get(token); 40 | 41 | if (networkError) { 42 | const err: AxiosError = { 43 | request: data, 44 | message: 'Request was failed', 45 | isAxiosError: true, 46 | stack: new Error().stack, 47 | name: 'AxiosError', 48 | code: networkError, 49 | toJSON: () => ({}), 50 | }; 51 | 52 | return Promise.reject(err); 53 | } 54 | 55 | const errData = errorMap.get(token); 56 | 57 | if (errData) { 58 | const err: AxiosError = { 59 | response: { 60 | data: errData.payload, 61 | headers: {}, 62 | request: {}, 63 | config: {} as InternalAxiosRequestConfig, 64 | status: errData.statusCode, 65 | statusText: 'Request was failed', 66 | }, 67 | status: errData.statusCode, 68 | request: data, 69 | message: 'Request was failed', 70 | isAxiosError: true, 71 | stack: new Error().stack, 72 | name: 'AxiosError', 73 | code: errData.code, 74 | toJSON: () => ({}), 75 | }; 76 | 77 | return Promise.reject(err); 78 | } 79 | 80 | expect(errData).toBeDefined(); 81 | }, 82 | }); 83 | } 84 | 85 | addResponse(token: string, payload: T): this { 86 | this.responseMap.set(token, payload); 87 | 88 | return this; 89 | } 90 | 91 | addError(token: string, options: { statusCode?: number; payload?: T; code?: string }): this { 92 | this.errorMap.set(token, options); 93 | 94 | return this; 95 | } 96 | 97 | addNetworkError(token: string, errorCode: string): this { 98 | this.networkErrorMap.set(token, errorCode); 99 | 100 | return this; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /test/google-recaptcha-exception.spec.ts: -------------------------------------------------------------------------------- 1 | import { ErrorCode, GoogleRecaptchaException } from '../src'; 2 | import { HttpStatus } from '@nestjs/common'; 3 | 4 | describe('Google recaptcha exception', () => { 5 | test('Test error code InvalidInputResponse', () => { 6 | const exception = new GoogleRecaptchaException([ErrorCode.InvalidInputResponse]); 7 | expect(exception.getStatus()).toBe(HttpStatus.BAD_REQUEST); 8 | }); 9 | 10 | test('Test error code MissingInputResponse', () => { 11 | const exception = new GoogleRecaptchaException([ErrorCode.MissingInputResponse]); 12 | expect(exception.getStatus()).toBe(HttpStatus.BAD_REQUEST); 13 | }); 14 | 15 | test('Test error code TimeoutOrDuplicate', () => { 16 | const exception = new GoogleRecaptchaException([ErrorCode.TimeoutOrDuplicate]); 17 | expect(exception.getStatus()).toBe(HttpStatus.BAD_REQUEST); 18 | }); 19 | 20 | test('Test error code InvalidKeys', () => { 21 | const exception = new GoogleRecaptchaException([ErrorCode.InvalidKeys]); 22 | expect(exception.getStatus()).toBe(HttpStatus.BAD_REQUEST); 23 | }); 24 | 25 | test('Test error code InvalidInputSecret', () => { 26 | const exception = new GoogleRecaptchaException([ErrorCode.InvalidInputSecret]); 27 | expect(exception.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); 28 | }); 29 | 30 | test('Test error code MissingInputSecret', () => { 31 | const exception = new GoogleRecaptchaException([ErrorCode.MissingInputSecret]); 32 | expect(exception.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); 33 | }); 34 | 35 | test('Test error code BadRequest', () => { 36 | const exception = new GoogleRecaptchaException([ErrorCode.BadRequest]); 37 | expect(exception.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); 38 | }); 39 | 40 | test('Test error code SiteMismatch', () => { 41 | const exception = new GoogleRecaptchaException([ErrorCode.SiteMismatch]); 42 | expect(exception.getStatus()).toBe(HttpStatus.BAD_REQUEST); 43 | }); 44 | 45 | test('Test error code BrowserError', () => { 46 | const exception = new GoogleRecaptchaException([ErrorCode.BrowserError]); 47 | expect(exception.getStatus()).toBe(HttpStatus.BAD_REQUEST); 48 | }); 49 | 50 | test('Test error code IncorrectCaptchaSol', () => { 51 | const exception = new GoogleRecaptchaException([ErrorCode.IncorrectCaptchaSol]); 52 | expect(exception.getStatus()).toBe(HttpStatus.BAD_REQUEST); 53 | }); 54 | 55 | test('Test error code UnknownError', () => { 56 | const exception = new GoogleRecaptchaException([ErrorCode.UnknownError]); 57 | expect(exception.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); 58 | }); 59 | 60 | test('Test unexpected error code', () => { 61 | const exception = new GoogleRecaptchaException(['UnexpectedErrorCode' as ErrorCode]); 62 | expect(exception.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); 63 | }); 64 | 65 | test('Test network error code', () => { 66 | const exception = new GoogleRecaptchaException([ErrorCode.NetworkError]); 67 | expect(exception.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); 68 | }); 69 | 70 | test('Test network error with custom message', () => { 71 | const message = 'TEST_MSG'; 72 | const exception = new GoogleRecaptchaException([ErrorCode.NetworkError], message); 73 | expect(exception.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); 74 | expect(exception.message).toBe(message); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /test/integrations/graphql/graphql-recaptcha-v2-v3.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, Module } from '@nestjs/common'; 2 | import { GoogleRecaptchaModule, Recaptcha, RecaptchaResult, RecaptchaVerificationResult } from '../../../src'; 3 | import { Test, TestingModule } from '@nestjs/testing'; 4 | import * as request from 'supertest'; 5 | import { MockedRecaptchaApi } from '../../utils/mocked-recaptcha-api'; 6 | import { VerifyResponseV3 } from '../../../src/interfaces/verify-response'; 7 | import { TestHttp } from '../../utils/test-http'; 8 | import { IncomingMessage } from 'http'; 9 | import { Args, Field, GraphQLModule, InputType, Mutation, ObjectType, Query, Resolver } from '@nestjs/graphql'; 10 | import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; 11 | import * as path from 'path'; 12 | import { RECAPTCHA_AXIOS_INSTANCE } from '../../../src/provider.declarations'; 13 | 14 | @InputType() 15 | export class FeedbackInput { 16 | @Field({ nullable: false }) 17 | title: string; 18 | } 19 | 20 | @ObjectType() 21 | export class Feedback { 22 | @Field({ nullable: false }) 23 | title: string; 24 | } 25 | 26 | @Resolver(() => Feedback) 27 | export class FeedbackResolver { 28 | @Query(() => String) 29 | sayHello(@Args('name') name: string): string { 30 | return `Hi, ${name}`; 31 | } 32 | 33 | @Recaptcha() 34 | @Mutation(() => Feedback) 35 | submitFeedback(@Args('title') title: string, @RecaptchaResult() result: RecaptchaVerificationResult): Feedback { 36 | expect(result).toBeInstanceOf(RecaptchaVerificationResult); 37 | expect(result.success).toBeTruthy(); 38 | 39 | expect(result.getResponse()).toBeDefined(); 40 | 41 | const riskAnalytics = result.getEnterpriseRiskAnalytics(); 42 | 43 | expect(riskAnalytics).toBeNull(); 44 | 45 | return { 46 | title, 47 | }; 48 | } 49 | } 50 | 51 | @Module({ 52 | providers: [FeedbackResolver], 53 | }) 54 | class TestModule {} 55 | 56 | describe('HTTP Recaptcha V2 V3', () => { 57 | const mockedRecaptchaApi = new MockedRecaptchaApi(); 58 | 59 | let http: TestHttp; 60 | 61 | let module: TestingModule; 62 | let app: INestApplication; 63 | 64 | beforeAll(async () => { 65 | module = await Test.createTestingModule({ 66 | imports: [ 67 | TestModule, 68 | GraphQLModule.forRoot({ 69 | include: [TestModule], 70 | playground: false, 71 | driver: ApolloDriver, 72 | autoSchemaFile: path.join(__dirname, 'schema.gql'), 73 | }), 74 | GoogleRecaptchaModule.forRoot({ 75 | debug: true, 76 | response: (req: IncomingMessage): string => req.headers.recaptcha?.toString(), 77 | secretKey: 'secret_key', 78 | score: (score: number) => score >= 0.6, 79 | actions: ['Submit'], 80 | }), 81 | ], 82 | }) 83 | .overrideProvider(RECAPTCHA_AXIOS_INSTANCE) 84 | .useFactory({ 85 | factory: () => mockedRecaptchaApi.getAxios(), 86 | }) 87 | .compile(); 88 | 89 | app = module.createNestApplication(); 90 | 91 | await app.init(); 92 | 93 | http = new TestHttp(app.getHttpServer()); 94 | }); 95 | 96 | afterAll(() => app.close()); 97 | 98 | test('V3 OK', async () => { 99 | const mutation = () => ` 100 | mutation submitFeedback($title: String!) { 101 | submitFeedback(title: $title) { 102 | title 103 | } 104 | }`; 105 | 106 | mockedRecaptchaApi.addResponse('test_graphql_v3_ok', { 107 | success: true, 108 | hostname: 'hostname', 109 | challenge_ts: new Date().toISOString(), 110 | action: 'Submit', 111 | score: 0.9, 112 | errors: [], 113 | }); 114 | 115 | const title = 'TEST'; 116 | 117 | const res: request.Response = await http.post( 118 | '/graphql', 119 | { 120 | query: mutation(), 121 | variables: { 122 | title, 123 | }, 124 | }, 125 | { 126 | headers: { 127 | Recaptcha: 'test_graphql_v3_ok', 128 | }, 129 | } 130 | ); 131 | 132 | expect(res.statusCode).toBe(200); 133 | 134 | expect(res.body.data.submitFeedback.title).toBe(title); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /test/google-recaptcha-async-module.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { GoogleRecaptchaValidator } from '../src/services/validators/google-recaptcha.validator'; 3 | import { GoogleRecaptchaModule, GoogleRecaptchaModuleOptions } from '../src'; 4 | import { TestConfigModule } from './assets/test-config-module'; 5 | import { TestConfigService } from './assets/test-config-service'; 6 | import { GoogleRecaptchaModuleOptionsFactory } from './assets/test-recaptcha-options-factory'; 7 | import { HttpModule, HttpService } from '@nestjs/axios'; 8 | import { RECAPTCHA_AXIOS_INSTANCE, RECAPTCHA_OPTIONS } from '../src/provider.declarations'; 9 | import { AxiosInstance, AxiosProxyConfig, AxiosRequestConfig } from 'axios'; 10 | import * as https from 'https'; 11 | import { Type } from '@nestjs/common'; 12 | import { GoogleRecaptchaOptionsFactory } from '../src/interfaces/google-recaptcha-module-options'; 13 | 14 | describe('Google recaptcha async module', () => { 15 | const checkDefaultConfigs = (defaults: AxiosRequestConfig): void => { 16 | expect(defaults).toBeDefined(); 17 | expect(defaults.proxy).toBeDefined(); 18 | 19 | const proxy: AxiosProxyConfig = defaults.proxy as AxiosProxyConfig; 20 | 21 | expect(proxy).toBeDefined(); 22 | expect(typeof proxy).toBe('object'); 23 | expect(proxy.host).toBe('TEST_PROXY_HOST'); 24 | expect(proxy.port).toBe(7777); 25 | }; 26 | 27 | test('Test via import module and use default axios config', async () => { 28 | const testingModule = await Test.createTestingModule({ 29 | imports: [ 30 | GoogleRecaptchaModule.forRootAsync({ 31 | imports: [ 32 | HttpModule.register({ 33 | proxy: { 34 | host: 'TEST_PROXY_HOST', 35 | port: 7777, 36 | }, 37 | data: 'TEST', 38 | timeout: 1000000000, 39 | httpsAgent: new https.Agent({ 40 | timeout: 17_000, 41 | }), 42 | }), 43 | TestConfigModule, 44 | ], 45 | useFactory: (config: TestConfigService, http: HttpService) => ({ 46 | ...config.getGoogleRecaptchaOptions(), 47 | axiosConfig: { ...http.axiosRef.defaults, headers: {} }, 48 | }), 49 | inject: [TestConfigService, HttpService], 50 | global: false, 51 | }), 52 | ], 53 | }).compile(); 54 | 55 | const app = testingModule.createNestApplication(); 56 | 57 | await app.init(); 58 | 59 | const validator = app.get(GoogleRecaptchaValidator); 60 | expect(validator).toBeInstanceOf(GoogleRecaptchaValidator); 61 | 62 | const axiosInstance: AxiosInstance = app.get(RECAPTCHA_AXIOS_INSTANCE); 63 | 64 | checkDefaultConfigs({ ...axiosInstance.defaults, headers: {} }); 65 | 66 | expect(axiosInstance.defaults.data).toBeUndefined(); 67 | 68 | const options: GoogleRecaptchaModuleOptions = app.get(RECAPTCHA_OPTIONS); 69 | 70 | expect(options).toBeDefined(); 71 | 72 | checkDefaultConfigs(options.axiosConfig); 73 | 74 | expect(options.axiosConfig.data).toBe('TEST'); 75 | 76 | const httpsAgent: https.Agent = axiosInstance.defaults.httpsAgent; 77 | 78 | expect(httpsAgent).toBeInstanceOf(https.Agent); 79 | expect(httpsAgent.options.timeout).toBe(17_000); 80 | }); 81 | 82 | test('Test via useClass', async () => { 83 | const testingModule = await Test.createTestingModule({ 84 | imports: [ 85 | GoogleRecaptchaModule.forRootAsync({ 86 | useClass: GoogleRecaptchaModuleOptionsFactory, 87 | }), 88 | ], 89 | }).compile(); 90 | 91 | const app = testingModule.createNestApplication(); 92 | 93 | const validator = app.get(GoogleRecaptchaValidator); 94 | expect(validator).toBeInstanceOf(GoogleRecaptchaValidator); 95 | }); 96 | 97 | test('Test via useExisting', async () => { 98 | const testingModule = await Test.createTestingModule({ 99 | imports: [ 100 | GoogleRecaptchaModule.forRootAsync({ 101 | imports: [TestConfigModule], 102 | useExisting: GoogleRecaptchaModuleOptionsFactory, 103 | }), 104 | ], 105 | }).compile(); 106 | 107 | const app = testingModule.createNestApplication(); 108 | 109 | const validator = app.get(GoogleRecaptchaValidator); 110 | expect(validator).toBeInstanceOf(GoogleRecaptchaValidator); 111 | }); 112 | 113 | test('Test via useClass that not implement GoogleRecaptchaOptionsFactory', async () => { 114 | await Test.createTestingModule({ 115 | imports: [ 116 | GoogleRecaptchaModule.forRootAsync({ 117 | useClass: TestConfigModule as Type, 118 | }), 119 | ], 120 | }) 121 | .compile() 122 | .then(() => expect(true).toBeFalsy()) 123 | .catch((e) => expect(e.message).toBe("Factory must be implement 'GoogleRecaptchaOptionsFactory' interface.")); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /src/services/validators/google-recaptcha.validator.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Logger } from '@nestjs/common'; 2 | import { RECAPTCHA_AXIOS_INSTANCE, RECAPTCHA_LOGGER } from '../../provider.declarations'; 3 | import * as qs from 'querystring'; 4 | import * as axios from 'axios'; 5 | import { GoogleRecaptchaNetwork } from '../../enums/google-recaptcha-network'; 6 | import { VerifyResponseOptions } from '../../interfaces/verify-response-decorator-options'; 7 | import { VerifyResponseV2, VerifyResponseV3 } from '../../interfaces/verify-response'; 8 | import { ErrorCode } from '../../enums/error-code'; 9 | import { GoogleRecaptchaNetworkException } from '../../exceptions/google-recaptcha-network.exception'; 10 | import { AbstractGoogleRecaptchaValidator } from './abstract-google-recaptcha-validator'; 11 | import { RecaptchaVerificationResult } from '../../models/recaptcha-verification-result'; 12 | import { GoogleRecaptchaContext } from '../../enums/google-recaptcha-context'; 13 | import { getErrorInfo } from '../../helpers/get-error-info'; 14 | import { AxiosInstance } from 'axios'; 15 | import { RecaptchaConfigRef } from '../../models/recaptcha-config-ref'; 16 | 17 | @Injectable() 18 | export class GoogleRecaptchaValidator extends AbstractGoogleRecaptchaValidator { 19 | private readonly defaultNetwork = GoogleRecaptchaNetwork.Google; 20 | 21 | private readonly headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; 22 | 23 | constructor( 24 | @Inject(RECAPTCHA_AXIOS_INSTANCE) private readonly axios: AxiosInstance, 25 | @Inject(RECAPTCHA_LOGGER) private readonly logger: Logger, 26 | configRef: RecaptchaConfigRef, 27 | ) { 28 | super(configRef); 29 | } 30 | 31 | /** 32 | * @throws GoogleRecaptchaNetworkException 33 | * @param {VerifyResponseOptions} options 34 | */ 35 | async validate(options: VerifyResponseOptions): Promise> { 36 | const result = await this.verifyResponse(options.response, options.remoteIp); 37 | 38 | if (!this.isUseV3(result)) { 39 | const resV2: VerifyResponseV2 = result; 40 | return new RecaptchaVerificationResult({ 41 | nativeResponse: resV2 as VerifyResponseV3, 42 | remoteIp: options.remoteIp, 43 | score: undefined, 44 | action: undefined, 45 | errors: resV2.errors, 46 | success: resV2.success, 47 | hostname: resV2.hostname, 48 | }); 49 | } 50 | 51 | if (!this.isValidAction(result.action, options)) { 52 | result.success = false; 53 | result.errors.push(ErrorCode.ForbiddenAction); 54 | } 55 | 56 | if (!this.isValidScore(result.score, options.score)) { 57 | result.success = false; 58 | result.errors.push(ErrorCode.LowScore); 59 | } 60 | 61 | const nativeResponse = { ...result }; 62 | 63 | return new RecaptchaVerificationResult({ 64 | nativeResponse: nativeResponse, 65 | remoteIp: options.remoteIp, 66 | score: result.score, 67 | errors: result.errors, 68 | success: result.success, 69 | action: result.action, 70 | hostname: result.hostname, 71 | }); 72 | } 73 | 74 | private verifyResponse(response: string, remoteIp?: string): Promise { 75 | const body = qs.stringify({ secret: this.options.valueOf.secretKey, response, remoteip: remoteIp }); 76 | const url = this.options.valueOf.network || this.defaultNetwork; 77 | 78 | const config: axios.AxiosRequestConfig = { 79 | headers: this.headers, 80 | }; 81 | 82 | if (this.options.valueOf.debug) { 83 | this.logger.debug({ body }, `${GoogleRecaptchaContext.GoogleRecaptcha}.request`); 84 | } 85 | 86 | return this.axios.post(url, body, config) 87 | .then((res) => res.data) 88 | .then((data) => { 89 | if (this.options.valueOf.debug) { 90 | this.logger.debug(data, `${GoogleRecaptchaContext.GoogleRecaptcha}.response`); 91 | } 92 | 93 | return data; 94 | }) 95 | .then((result) => ({ 96 | ...result, 97 | errors: result['error-codes'] || [], 98 | })) 99 | .then((result) => { 100 | delete result['error-codes']; 101 | return result; 102 | }) 103 | .catch((err: axios.AxiosError) => { 104 | if (this.options.valueOf.debug) { 105 | this.logger.debug(getErrorInfo(err), `${GoogleRecaptchaContext.GoogleRecaptcha}.error`); 106 | } 107 | 108 | const networkErrorCode = err.isAxiosError && !err.response && err.code; 109 | 110 | if (networkErrorCode) { 111 | throw new GoogleRecaptchaNetworkException(networkErrorCode); 112 | } 113 | 114 | return { 115 | success: false, 116 | errors: [ErrorCode.UnknownError], 117 | }; 118 | }); 119 | } 120 | 121 | private isUseV3(v: VerifyResponseV2): v is VerifyResponseV3 { 122 | return 'score' in v && typeof v['score'] === 'number' && 'action' in v && typeof v['action'] === 'string'; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/services/validators/google-recaptcha-enterprise.validator.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Logger } from '@nestjs/common'; 2 | import { RECAPTCHA_AXIOS_INSTANCE, RECAPTCHA_LOGGER } from '../../provider.declarations'; 3 | import { VerifyResponseOptions } from '../../interfaces/verify-response-decorator-options'; 4 | import { AbstractGoogleRecaptchaValidator } from './abstract-google-recaptcha-validator'; 5 | import { RecaptchaVerificationResult } from '../../models/recaptcha-verification-result'; 6 | import { ErrorCode } from '../../enums/error-code'; 7 | import * as axios from 'axios'; 8 | import { GoogleRecaptchaNetworkException } from '../../exceptions/google-recaptcha-network.exception'; 9 | import { GoogleRecaptchaContext } from '../../enums/google-recaptcha-context'; 10 | import { VerifyResponseEnterprise, VerifyTokenEnterpriseEvent } from '../../interfaces/verify-response-enterprise'; 11 | import { EnterpriseReasonTransformer } from '../enterprise-reason.transformer'; 12 | import { getErrorInfo } from '../../helpers/get-error-info'; 13 | import { AxiosInstance } from 'axios'; 14 | import { LiteralObject } from '../../interfaces/literal-object'; 15 | import { RecaptchaConfigRef } from '../../models/recaptcha-config-ref'; 16 | 17 | type VerifyResponse = [VerifyResponseEnterprise, LiteralObject]; 18 | 19 | @Injectable() 20 | export class GoogleRecaptchaEnterpriseValidator extends AbstractGoogleRecaptchaValidator { 21 | private readonly headers = { 'Content-Type': 'application/json' }; 22 | 23 | constructor( 24 | @Inject(RECAPTCHA_AXIOS_INSTANCE) private readonly axios: AxiosInstance, 25 | @Inject(RECAPTCHA_LOGGER) private readonly logger: Logger, 26 | configRef: RecaptchaConfigRef, 27 | private readonly enterpriseReasonTransformer: EnterpriseReasonTransformer 28 | ) { 29 | super(configRef); 30 | } 31 | 32 | async validate(options: VerifyResponseOptions): Promise> { 33 | const [result, errorDetails] = await this.verifyResponse(options.response, options.action, options.remoteIp); 34 | 35 | const errors: ErrorCode[] = []; 36 | let success = result?.tokenProperties?.valid || false; 37 | 38 | if (!errorDetails) { 39 | if (result.tokenProperties) { 40 | if (result.tokenProperties.invalidReason) { 41 | const invalidReasonCode = this.enterpriseReasonTransformer.transform(result.tokenProperties.invalidReason); 42 | 43 | if (invalidReasonCode) { 44 | errors.push(invalidReasonCode); 45 | } 46 | } 47 | 48 | if (success && !this.isValidAction(result.tokenProperties.action, options)) { 49 | success = false; 50 | errors.push(ErrorCode.ForbiddenAction); 51 | } 52 | } 53 | 54 | if (result.riskAnalysis && !this.isValidScore(result.riskAnalysis.score, options.score)) { 55 | success = false; 56 | errors.push(ErrorCode.LowScore); 57 | } 58 | } 59 | 60 | if (!success && !errors.length) { 61 | errorDetails ? errors.push(ErrorCode.UnknownError) : errors.push(ErrorCode.InvalidInputResponse); 62 | } 63 | 64 | return new RecaptchaVerificationResult({ 65 | success, 66 | errors, 67 | nativeResponse: result, 68 | remoteIp: options.remoteIp, 69 | score: result?.riskAnalysis?.score, 70 | action: result?.tokenProperties?.action, 71 | hostname: result?.tokenProperties?.hostname || '', 72 | }); 73 | } 74 | 75 | private verifyResponse(response: string, expectedAction: string, remoteIp: string): Promise { 76 | const projectId = this.options.valueOf.enterprise.projectId; 77 | const body: { event: VerifyTokenEnterpriseEvent } = { 78 | event: { 79 | expectedAction, 80 | siteKey: this.options.valueOf.enterprise.siteKey, 81 | token: response, 82 | userIpAddress: remoteIp, 83 | }, 84 | }; 85 | 86 | const url = `https://recaptchaenterprise.googleapis.com/v1/projects/${projectId}/assessments`; 87 | 88 | const config: axios.AxiosRequestConfig = { 89 | headers: this.headers, 90 | params: { 91 | key: this.options.valueOf.enterprise.apiKey, 92 | }, 93 | }; 94 | 95 | if (this.options.valueOf.debug) { 96 | this.logger.debug({ body }, `${GoogleRecaptchaContext.GoogleRecaptchaEnterprise}.request`); 97 | } 98 | 99 | return this.axios.post(url, body, config) 100 | .then((res) => res.data) 101 | .then((data: VerifyResponseEnterprise): VerifyResponse => { 102 | if (this.options.valueOf.debug) { 103 | this.logger.debug(data, `${GoogleRecaptchaContext.GoogleRecaptchaEnterprise}.response`); 104 | } 105 | 106 | return [data, null]; 107 | }) 108 | .catch((err: axios.AxiosError): VerifyResponse => { 109 | if (this.options.valueOf.debug) { 110 | this.logger.debug(getErrorInfo(err), `${GoogleRecaptchaContext.GoogleRecaptchaEnterprise}.error`); 111 | } 112 | 113 | const networkErrorCode = err.isAxiosError && !err.response && err.code; 114 | 115 | if (networkErrorCode) { 116 | throw new GoogleRecaptchaNetworkException(networkErrorCode); 117 | } 118 | 119 | const errData: LiteralObject = { 120 | status: err.response.status, 121 | data: err.response.data, 122 | }; 123 | 124 | return [null, errData]; 125 | }); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | chvarkov.alexey@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /test/integrations/http-recaptcha-v2-v3.spec.ts: -------------------------------------------------------------------------------- 1 | import { Controller, INestApplication, Post } from '@nestjs/common'; 2 | import { ErrorCode, GoogleRecaptchaModule, Recaptcha, RecaptchaResult, RecaptchaVerificationResult } from '../../src'; 3 | import { Test, TestingModule } from '@nestjs/testing'; 4 | import { Request } from 'express'; 5 | import * as request from 'supertest'; 6 | import { MockedRecaptchaApi } from '../utils/mocked-recaptcha-api'; 7 | import { VerifyResponseV2, VerifyResponseV3 } from '../../src/interfaces/verify-response'; 8 | import { TestHttp } from '../utils/test-http'; 9 | import { TestErrorFilter } from '../assets/test-error-filter'; 10 | import { RECAPTCHA_AXIOS_INSTANCE } from '../../src/provider.declarations'; 11 | import { LiteralObject } from '../../src/interfaces/literal-object'; 12 | 13 | @Controller('test') 14 | class TestController { 15 | @Recaptcha() 16 | @Post('submit') 17 | testAction(@RecaptchaResult() result: RecaptchaVerificationResult): LiteralObject { 18 | expect(result).toBeInstanceOf(RecaptchaVerificationResult); 19 | expect(result.success).toBeTruthy(); 20 | expect(result.remoteIp).toBe('IP_ADDR') 21 | 22 | expect(result.getResponse()).toBeDefined(); 23 | 24 | const riskAnalytics = result.getEnterpriseRiskAnalytics(); 25 | 26 | expect(riskAnalytics).toBeNull(); 27 | 28 | return { success: true }; 29 | } 30 | } 31 | 32 | describe('HTTP Recaptcha V2 V3', () => { 33 | const mockedRecaptchaApi = new MockedRecaptchaApi(); 34 | 35 | let http: TestHttp; 36 | 37 | let module: TestingModule; 38 | let app: INestApplication; 39 | 40 | beforeAll(async () => { 41 | module = await Test.createTestingModule({ 42 | imports: [ 43 | GoogleRecaptchaModule.forRoot({ 44 | debug: true, 45 | response: (req: Request): string => req.headers.recaptcha?.toString(), 46 | secretKey: 'secret_key', 47 | score: 0.6, 48 | actions: ['Submit'], 49 | remoteIp: () => 'IP_ADDR', 50 | }), 51 | ], 52 | controllers: [TestController], 53 | }) 54 | .overrideProvider(RECAPTCHA_AXIOS_INSTANCE) 55 | .useFactory({ 56 | factory: () => mockedRecaptchaApi.getAxios(), 57 | }) 58 | .compile(); 59 | 60 | app = module.createNestApplication(); 61 | 62 | app.useGlobalFilters(new TestErrorFilter()); 63 | 64 | await app.init(); 65 | 66 | http = new TestHttp(app.getHttpServer()); 67 | }); 68 | 69 | afterAll(() => app.close()); 70 | 71 | test('V2 OK', async () => { 72 | mockedRecaptchaApi.addResponse('test_v2_ok', { 73 | success: true, 74 | hostname: 'hostname', 75 | challenge_ts: new Date().toISOString(), 76 | errors: [], 77 | }); 78 | 79 | const res: request.Response = await http.post( 80 | '/test/submit', 81 | {}, 82 | { 83 | headers: { 84 | Recaptcha: 'test_v2_ok', 85 | }, 86 | } 87 | ); 88 | 89 | expect(res.statusCode).toBe(201); 90 | expect(res.body.success).toBe(true); 91 | }); 92 | 93 | test('V2 API error', async () => { 94 | mockedRecaptchaApi.addError('test_v2_api_err', { 95 | statusCode: 400, 96 | }); 97 | 98 | const res: request.Response = await http.post( 99 | '/test/submit', 100 | {}, 101 | { 102 | headers: { 103 | Recaptcha: 'test_v2_api_err', 104 | }, 105 | } 106 | ); 107 | 108 | expect(res.statusCode).toBe(500); 109 | expect(res.body.errorCodes).toBeDefined(); 110 | expect(res.body.errorCodes.length).toBe(1); 111 | expect(res.body.errorCodes[0]).toBe(ErrorCode.UnknownError); 112 | }); 113 | 114 | test('V2 Network error', async () => { 115 | mockedRecaptchaApi.addError('test_v2_network_err', { 116 | code: 'ECONNRESET', 117 | }); 118 | 119 | const res: request.Response = await http.post( 120 | '/test/submit', 121 | {}, 122 | { 123 | headers: { 124 | Recaptcha: 'test_v2_network_err', 125 | }, 126 | } 127 | ); 128 | 129 | expect(res.statusCode).toBe(500); 130 | }); 131 | 132 | test('V3 OK', async () => { 133 | mockedRecaptchaApi.addResponse('test_v3_ok', { 134 | success: true, 135 | hostname: 'hostname', 136 | challenge_ts: new Date().toISOString(), 137 | action: 'Submit', 138 | score: 0.9, 139 | errors: [], 140 | }); 141 | 142 | const res: request.Response = await http.post( 143 | '/test/submit', 144 | {}, 145 | { 146 | headers: { 147 | Recaptcha: 'test_v3_ok', 148 | }, 149 | } 150 | ); 151 | 152 | expect(res.statusCode).toBe(201); 153 | expect(res.body.success).toBe(true); 154 | }); 155 | 156 | test('V3 Invalid action', async () => { 157 | mockedRecaptchaApi.addResponse('test_v3_invalid_action', { 158 | success: true, 159 | hostname: 'hostname', 160 | challenge_ts: new Date().toISOString(), 161 | errors: [], 162 | action: 'InvalidAction', 163 | score: 0.9, 164 | }); 165 | 166 | const res: request.Response = await http.post( 167 | '/test/submit', 168 | {}, 169 | { 170 | headers: { 171 | Recaptcha: 'test_v3_invalid_action', 172 | }, 173 | } 174 | ); 175 | 176 | expect(res.statusCode).toBe(400); 177 | expect(res.body.errorCodes).toBeDefined(); 178 | expect(res.body.errorCodes.length).toBe(1); 179 | expect(res.body.errorCodes[0]).toBe(ErrorCode.ForbiddenAction); 180 | }); 181 | 182 | test('V3 Low score', async () => { 183 | mockedRecaptchaApi.addResponse('test_v3_low_score', { 184 | success: true, 185 | hostname: 'hostname', 186 | challenge_ts: new Date().toISOString(), 187 | errors: [], 188 | action: 'Submit', 189 | score: 0.3, 190 | }); 191 | 192 | const res: request.Response = await http.post( 193 | '/test/submit', 194 | {}, 195 | { 196 | headers: { 197 | Recaptcha: 'test_v3_low_score', 198 | }, 199 | } 200 | ); 201 | 202 | expect(res.statusCode).toBe(400); 203 | expect(res.body.errorCodes).toBeDefined(); 204 | expect(res.body.errorCodes.length).toBe(1); 205 | expect(res.body.errorCodes[0]).toBe(ErrorCode.LowScore); 206 | }); 207 | }); 208 | -------------------------------------------------------------------------------- /src/google-recaptcha.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Logger, Provider } from '@nestjs/common'; 2 | import { GoogleRecaptchaGuard } from './guards/google-recaptcha.guard'; 3 | import { GoogleRecaptchaValidator } from './services/validators/google-recaptcha.validator'; 4 | import { GoogleRecaptchaEnterpriseValidator } from './services/validators/google-recaptcha-enterprise.validator'; 5 | import { 6 | GoogleRecaptchaModuleAsyncOptions, 7 | GoogleRecaptchaModuleOptions, 8 | GoogleRecaptchaOptionsFactory, 9 | } from './interfaces/google-recaptcha-module-options'; 10 | import { RECAPTCHA_AXIOS_INSTANCE, RECAPTCHA_LOGGER, RECAPTCHA_OPTIONS } from './provider.declarations'; 11 | import { RecaptchaRequestResolver } from './services/recaptcha-request.resolver'; 12 | import { Reflector } from '@nestjs/core'; 13 | import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; 14 | import { Agent } from 'https'; 15 | import { RecaptchaValidatorResolver } from './services/recaptcha-validator.resolver'; 16 | import { EnterpriseReasonTransformer } from './services/enterprise-reason.transformer'; 17 | import { xor } from './helpers/xor'; 18 | import { RecaptchaConfigRef } from './models/recaptcha-config-ref'; 19 | 20 | export class GoogleRecaptchaModule { 21 | private static axiosDefaultConfig: AxiosRequestConfig = { 22 | timeout: 60_000, 23 | httpsAgent: new Agent({ keepAlive: true }), 24 | }; 25 | 26 | static forRoot(options: GoogleRecaptchaModuleOptions): DynamicModule { 27 | const providers: Provider[] = [ 28 | Reflector, 29 | GoogleRecaptchaGuard, 30 | GoogleRecaptchaValidator, 31 | GoogleRecaptchaEnterpriseValidator, 32 | RecaptchaRequestResolver, 33 | RecaptchaValidatorResolver, 34 | EnterpriseReasonTransformer, 35 | { 36 | provide: RECAPTCHA_OPTIONS, 37 | useValue: options, 38 | }, 39 | { 40 | provide: RECAPTCHA_LOGGER, 41 | useFactory: () => options.logger || new Logger(), 42 | }, 43 | { 44 | provide: RecaptchaConfigRef, 45 | useFactory: () => new RecaptchaConfigRef(options), 46 | }, 47 | ]; 48 | 49 | this.validateOptions(options); 50 | 51 | const internalProviders: Provider[] = [ 52 | { 53 | provide: RECAPTCHA_AXIOS_INSTANCE, 54 | useFactory: (): AxiosInstance => axios.create( 55 | this.transformAxiosConfig({ 56 | ...this.axiosDefaultConfig, 57 | ...options.axiosConfig, 58 | headers: null, 59 | }), 60 | ), 61 | }, 62 | ]; 63 | 64 | return { 65 | global: options.global != null ? options.global : true, 66 | module: GoogleRecaptchaModule, 67 | providers: providers.concat(internalProviders), 68 | exports: providers, 69 | }; 70 | } 71 | 72 | static forRootAsync(options: GoogleRecaptchaModuleAsyncOptions): DynamicModule { 73 | const providers: Provider[] = [ 74 | Reflector, 75 | { 76 | provide: RECAPTCHA_LOGGER, 77 | useFactory: (options: GoogleRecaptchaModuleOptions) => options.logger || new Logger(), 78 | inject: [RECAPTCHA_OPTIONS], 79 | }, 80 | { 81 | provide: RecaptchaConfigRef, 82 | useFactory: (opts: GoogleRecaptchaModuleOptions) => new RecaptchaConfigRef(opts), 83 | inject: [RECAPTCHA_OPTIONS], 84 | }, 85 | GoogleRecaptchaGuard, 86 | GoogleRecaptchaValidator, 87 | GoogleRecaptchaEnterpriseValidator, 88 | RecaptchaRequestResolver, 89 | RecaptchaValidatorResolver, 90 | EnterpriseReasonTransformer, 91 | ...this.createAsyncProviders(options), 92 | ]; 93 | 94 | const internalProviders: Provider[] = [ 95 | { 96 | provide: RECAPTCHA_AXIOS_INSTANCE, 97 | useFactory: (options: GoogleRecaptchaModuleOptions): AxiosInstance => { 98 | this.validateOptions(options); 99 | 100 | const transformedAxiosConfig = this.transformAxiosConfig({ 101 | ...this.axiosDefaultConfig, 102 | ...options.axiosConfig, 103 | headers: null, 104 | }); 105 | return axios.create(transformedAxiosConfig); 106 | }, 107 | inject: [RECAPTCHA_OPTIONS], 108 | }, 109 | ]; 110 | 111 | return { 112 | global: options.global != null ? options.global : true, 113 | module: GoogleRecaptchaModule, 114 | imports: options.imports, 115 | providers: providers.concat(internalProviders), 116 | exports: providers, 117 | }; 118 | } 119 | 120 | private static transformAxiosConfig(axiosConfig: AxiosRequestConfig): AxiosRequestConfig { 121 | const config = { ...axiosConfig }; 122 | 123 | delete config.baseURL; 124 | delete config.url; 125 | delete config.responseType; 126 | delete config.method; 127 | delete config.transformRequest; 128 | delete config.transformResponse; 129 | delete config.paramsSerializer; 130 | delete config.validateStatus; 131 | delete config.data; 132 | delete config.adapter; 133 | 134 | return config; 135 | } 136 | 137 | private static createAsyncProviders(options: GoogleRecaptchaModuleAsyncOptions): Provider[] { 138 | const providers: Provider[] = [this.createAsyncOptionsProvider(options)]; 139 | 140 | if (options.useClass) { 141 | providers.push({ 142 | provide: options.useClass, 143 | useClass: options.useClass, 144 | }); 145 | } 146 | 147 | return providers; 148 | } 149 | 150 | private static createAsyncOptionsProvider(options: GoogleRecaptchaModuleAsyncOptions): Provider { 151 | if (options.useFactory) { 152 | return { 153 | provide: RECAPTCHA_OPTIONS, 154 | useFactory: options.useFactory, 155 | inject: options.inject, 156 | }; 157 | } 158 | 159 | return { 160 | provide: RECAPTCHA_OPTIONS, 161 | useFactory: async (optionsFactory: GoogleRecaptchaOptionsFactory): Promise => { 162 | if (!this.isGoogleRecaptchaFactory(optionsFactory)) { 163 | throw new Error("Factory must be implement 'GoogleRecaptchaOptionsFactory' interface."); 164 | } 165 | return optionsFactory.createGoogleRecaptchaOptions(); 166 | }, 167 | inject: [options.useExisting || options.useClass], 168 | }; 169 | } 170 | 171 | private static validateOptions(options: GoogleRecaptchaModuleOptions): void | never { 172 | const hasEnterpriseOptions = !!Object.keys(options.enterprise || {}).length; 173 | if (!xor(!!options.secretKey, hasEnterpriseOptions)) { 174 | throw new Error('Google recaptcha options must be contains "secretKey" xor "enterprise".'); 175 | } 176 | } 177 | 178 | private static isGoogleRecaptchaFactory(object?: GoogleRecaptchaOptionsFactory): object is GoogleRecaptchaOptionsFactory { 179 | return !!object && typeof object.createGoogleRecaptchaOptions === 'function'; 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /test/google-recaptcha-guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { GoogleRecaptchaException, GoogleRecaptchaGuard, GoogleRecaptchaModuleOptions } from '../src'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { GoogleRecaptchaGuardOptions } from '../src/interfaces/google-recaptcha-guard-options'; 4 | import { createGoogleRecaptchaValidator } from './helpers/create-google-recaptcha-validator'; 5 | import { GoogleRecaptchaValidatorOptions } from '../src/interfaces/google-recaptcha-validator-options'; 6 | import { createExecutionContext } from './helpers/create-execution-context'; 7 | import { TestController } from './assets/test-controller'; 8 | import { TestRecaptchaNetwork } from './network/test-recaptcha-network'; 9 | import { RecaptchaRequestResolver } from '../src/services/recaptcha-request.resolver'; 10 | import { Logger } from '@nestjs/common'; 11 | import { createGoogleRecaptchaEnterpriseValidator } from './helpers/create-google-recaptcha-enterprise-validator'; 12 | import { RecaptchaValidatorResolver } from '../src/services/recaptcha-validator.resolver'; 13 | import { RecaptchaConfigRef } from '../src/models/recaptcha-config-ref'; 14 | 15 | describe('Google recaptcha guard', () => { 16 | let network: TestRecaptchaNetwork; 17 | const networkPort = 6048; 18 | const validatorOptions: GoogleRecaptchaValidatorOptions = { 19 | secretKey: 'Secret', 20 | }; 21 | const guardOptions: GoogleRecaptchaGuardOptions = { 22 | response: (req) => req.body.recaptcha, 23 | }; 24 | 25 | const controller = new TestController(); 26 | 27 | beforeAll(async () => { 28 | network = await TestRecaptchaNetwork.create(networkPort); 29 | }); 30 | 31 | afterAll(async () => { 32 | await network.close(); 33 | }); 34 | 35 | test('SkipIf = true + default response provider', async () => { 36 | const options = { ...validatorOptions, ...guardOptions }; 37 | const validator = createGoogleRecaptchaValidator(options); 38 | const enterpriseValidator = createGoogleRecaptchaEnterpriseValidator(options); 39 | const configRef = new RecaptchaConfigRef(options); 40 | const validatorResolver = new RecaptchaValidatorResolver(configRef, validator, enterpriseValidator); 41 | 42 | const guard = new GoogleRecaptchaGuard( 43 | new Reflector(), 44 | new RecaptchaRequestResolver(), 45 | validatorResolver, 46 | new Logger(), 47 | new RecaptchaConfigRef({ ...options, skipIf: true }), 48 | ); 49 | 50 | const context = createExecutionContext(controller.submit, { body: { recaptcha: 'RECAPTCHA_TOKEN' } }); 51 | 52 | const canActivate = await guard.canActivate(context); 53 | 54 | expect(canActivate).toBeTruthy(); 55 | }); 56 | 57 | test('SkipIf = (req) => true + overridden response provider', async () => { 58 | const options = { ...validatorOptions, ...guardOptions }; 59 | const validator = createGoogleRecaptchaValidator(options); 60 | const enterpriseValidator = createGoogleRecaptchaEnterpriseValidator(options); 61 | const validatorResolver = new RecaptchaValidatorResolver( 62 | new RecaptchaConfigRef(options), 63 | validator, 64 | enterpriseValidator, 65 | ); 66 | 67 | const guard = new GoogleRecaptchaGuard( 68 | new Reflector(), 69 | new RecaptchaRequestResolver(), 70 | validatorResolver, 71 | new Logger(), 72 | new RecaptchaConfigRef({ ...options, skipIf: (): boolean => true }), 73 | ); 74 | 75 | const context = createExecutionContext(controller.submitOverridden.prototype, { body: { recaptcha: 'RECAPTCHA_TOKEN' } }); 76 | 77 | const canActivate = await guard.canActivate(context); 78 | 79 | expect(canActivate).toBeTruthy(); 80 | }); 81 | 82 | test('Invalid secret', async () => { 83 | const options = { ...validatorOptions, ...guardOptions }; 84 | const validator = createGoogleRecaptchaValidator(options); 85 | const enterpriseValidator = createGoogleRecaptchaEnterpriseValidator(options); 86 | const validatorResolver = new RecaptchaValidatorResolver( 87 | new RecaptchaConfigRef(options), 88 | validator, 89 | enterpriseValidator, 90 | ); 91 | 92 | const guard = new GoogleRecaptchaGuard( 93 | new Reflector(), 94 | new RecaptchaRequestResolver(), 95 | validatorResolver, 96 | new Logger(), 97 | new RecaptchaConfigRef(options), 98 | ); 99 | 100 | const context = createExecutionContext(controller.submit, { body: { recaptcha: 'RECAPTCHA_TOKEN' } }); 101 | 102 | await guard 103 | .canActivate(context) 104 | .then(() => expect(true).toBeFalsy()) 105 | .catch((e) => expect(e).toBeInstanceOf(GoogleRecaptchaException)); 106 | }); 107 | 108 | test('Invalid network', async () => { 109 | const options = { 110 | ...validatorOptions, 111 | ...guardOptions, 112 | network: 'https://localhost/some-invalid-path', 113 | }; 114 | 115 | const validator = createGoogleRecaptchaValidator(options); 116 | const enterpriseValidator = createGoogleRecaptchaEnterpriseValidator(options); 117 | const validatorResolver = new RecaptchaValidatorResolver( 118 | new RecaptchaConfigRef(options), 119 | validator, 120 | enterpriseValidator, 121 | ); 122 | 123 | const guard = new GoogleRecaptchaGuard( 124 | new Reflector(), 125 | new RecaptchaRequestResolver(), 126 | validatorResolver, 127 | new Logger(), 128 | new RecaptchaConfigRef({ ...guardOptions, ...validatorOptions }), 129 | ); 130 | 131 | const context = createExecutionContext(controller.submit, { body: { recaptcha: 'RECAPTCHA_TOKEN' } }); 132 | 133 | await guard 134 | .canActivate(context) 135 | .then(() => expect(true).toBeFalsy()) 136 | .catch((e) => expect(e).toBeInstanceOf(GoogleRecaptchaException)); 137 | }); 138 | 139 | test('Valid', async () => { 140 | network.setResult({ 141 | success: true, 142 | }); 143 | const options = { 144 | ...validatorOptions, 145 | ...guardOptions, 146 | network: network.url, 147 | }; 148 | 149 | const validator = createGoogleRecaptchaValidator(options); 150 | const enterpriseValidator = createGoogleRecaptchaEnterpriseValidator(options); 151 | const validatorResolver = new RecaptchaValidatorResolver(new RecaptchaConfigRef(options), validator, enterpriseValidator); 152 | 153 | const guard = new GoogleRecaptchaGuard(new Reflector(), new RecaptchaRequestResolver(), validatorResolver, new Logger(), new RecaptchaConfigRef(options)); 154 | 155 | const context = createExecutionContext(controller.submit, { body: { recaptcha: 'RECAPTCHA_TOKEN' } }); 156 | 157 | const canActivate = await guard.canActivate(context); 158 | 159 | expect(canActivate).toBeTruthy(); 160 | }); 161 | 162 | test('Unsupported request type', async () => { 163 | const options = {} as GoogleRecaptchaModuleOptions; 164 | 165 | const validator = createGoogleRecaptchaValidator(options); 166 | const enterpriseValidator = createGoogleRecaptchaEnterpriseValidator(options); 167 | const validatorResolver = new RecaptchaValidatorResolver( 168 | new RecaptchaConfigRef(options), 169 | validator, 170 | enterpriseValidator, 171 | ); 172 | 173 | const guard = new GoogleRecaptchaGuard(new Reflector(), new RecaptchaRequestResolver(), validatorResolver, new Logger(), new RecaptchaConfigRef(options)); 174 | 175 | const context = createExecutionContext(controller.submit, { body: { recaptcha: 'RECAPTCHA_TOKEN' } }); 176 | 177 | Object.assign(context, { getType: () => 'unknown' }); 178 | 179 | await expect(guard.canActivate(context)).rejects.toThrowError('Unsupported request type'); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v3.10.0 4 | - Upgraded axios 1.7.7 => 1.8.4 5 | 6 | ## v3.9.0 7 | - Support NestJS 11. Upgraded peer dependencies versions: 8 | - `@nestjs/common`: >=8.0.0 <12.0.0 9 | - `@nestjs/core`: >=8.0.0 <12.0.0 10 | 11 | - Fixed getting `networkErrorCode` from `AxiosError` 12 | - Upgraded axios 1.7.4 => 1.7.7 13 | 14 | ## v3.8.0 15 | - Updated `GoogleRecaptchaModuleAsyncOptions` interface 16 | 17 | ## v3.7.0 18 | - Added `RecaptchaConfigRef` for dynamic configuration 19 | 20 | ## v3.6.0 21 | - Added `remoteIp?: (req) => string` option into: 22 | - Module options 23 | - Decorator options to override default 24 | - Validator options 25 | 26 | ## v3.5.0 27 | - Added `global?: boolean` module option 28 | 29 | ## v3.4.1 30 | - Fixed import LiteralObject for NestJS 10 31 | 32 | ## v3.4.0 33 | - Upgraded peer dependencies versions: 34 | - `@nestjs/common`: >=8.0.0 <11.0.0 35 | - `@nestjs/core`: >=8.0.0 <11.0.0 36 | 37 | ## v3.3.1 38 | - Reworked readme docs. 39 | - Added changelog file. 40 | 41 | ## v3.3.0 42 | - Reworked to use axios instead of @nestjs/axios. 43 | - Removed peer dependencies: 44 | - `@nestjs/axios` 45 | - `rxjs` 46 | 47 | ## v3.2.0 48 | 49 | - Upgraded peer dependencies versions: 50 | - `@nestjs/axios`: >=1.0.0 <2.0.0 51 | - `axios`: >=1.0.0 <2.0.0 52 | 53 | ## v3.1.9 54 | 55 | - Declared used axios package as peerDependency. 56 | 57 | ## v3.1.8 58 | 59 | - Fixed async module options type in ts strict mode. 60 | - Declared used `rxjs` package as peerDependency. 61 | 62 | ## v3.1.7 63 | 64 | - Smallfix with logging recaptcha results. 65 | - Fixed resolving error codes for enterprise validator. 66 | 67 | ## v3.1.6 68 | 69 | - Fixed handling enterprise response without token properties info. 70 | 71 | ## v3.1.5 72 | 73 | - Fixed recaptcha enterprise error handling. 74 | 75 | ## v3.1.4 76 | 77 | - Fixed instance of response for recaptcha v2. 78 | - Fixed error handling for recaptcha enterprise. 79 | - Internal fixes. 80 | - Test coverage. 81 | 82 | ## v3.1.3 83 | 84 | - Fixed response type for `RecaptchaVerificationResult.getEnterpriseRiskAnalytics()`. 85 | 86 | ## v3.1.2 87 | 88 | - Fixed http exception statuses for error codes: `site-mismatch`, `browser-error` (HTTP status - 400). 89 | - Added error code: `incorrect-captcha-sol`. 90 | 91 | ## v3.1.1 92 | 93 | - Minor type fixes by eslint rules. 94 | - Fixes in: README.md, package.json. 95 | 96 | ## v3.1.0 97 | 98 | - Added support reCAPTCHA Enterprise API. 99 | - Updated module options: 100 | - Updated `secretKey` as optional (shouldn't use for enterprise configuration). 101 | - Added `enterprise` option 102 | 103 | | Property | Description | 104 | |---------------------------------|-------------| 105 | | `enterprise.projectId` | **Required.**
Type: `string`
Google Cloud project ID | 106 | | `enterprise.siteKey` | **Required.**
Type: `string`
[reCAPTCHA key](https://cloud.google.com/recaptcha-enterprise/docs/keys) associated with the site/app. | 107 | | `enterprise.apiKey` | **Required.**
Type: `string`
API key associated with the current project.
Must have permission `reCAPTCHA Enterprise API`.
You can manage credentials [here](https://console.cloud.google.com/apis/credentials). | 108 | 109 | **Updated GoogleRecaptchaValidator interface** 110 | 111 | ```typescript 112 | class GoogleRecaptchaValidator { 113 | validate(options: VerifyResponseOptions): Promise>; 114 | } 115 | ``` 116 | 117 | **Addded recaptcha validator for enterprise** 118 | 119 | ```typescript 120 | @Injectable() 121 | export class SomeService { 122 | constructor(private readonly recaptchaEnterpriseValidator: GoogleRecaptchaEnterpriseValidator) { 123 | } 124 | 125 | async someAction(recaptchaToken: string): Promise { 126 | const result = await this.recaptchaEnterpriseValidator.validate({ 127 | response: recaptchaToken, 128 | score: 0.8, 129 | action: 'SomeAction', 130 | }); 131 | 132 | if (!result.success) { 133 | throw new GoogleRecaptchaException(result.errors); 134 | } 135 | 136 | const riskAnalytics = result.getEnterpriseRiskAnalytics(); 137 | 138 | console.log('score', riskAnalysis.score); 139 | console.log('score', riskAnalysis.reasons); 140 | 141 | // TODO: Your implemetation 142 | } 143 | } 144 | ``` 145 | 146 | 147 | ## v3.0.3 148 | 149 | - Updated README.md. 150 | 151 | ## v3.0.2 152 | 153 | - Added debug mode and logging 154 | - Added module options 155 | - `debug?: boolean` enables debug mode 156 | - `logger?: Logger` instance of Logger from @nestjs/common or extended from this 157 | 158 | ## v3.0.1 159 | 160 | - Fixed published root dir 161 | 162 | ## v3.0.0 163 | 164 | - Compat with NestJS 9 165 | - Removed deprecated options: 166 | - `applicationType?: ApplicationType` (now detect it automatically) 167 | - `agent?: https.Agent` (use option axiosConfig.httpsAgent) 168 | 169 | ## v2.1.2 170 | 171 | - Fixed decorators reexports 172 | 173 | ## v2.1.1 174 | 175 | - Removed source maps. Little fixes in readme file. 176 | 177 | ## v2.1.0 178 | 179 | - Added request type auto detection from execution context`applicationType` configuration option marked as deprecated. Will removed in next major release. 180 | 181 | ## v2.0.8 182 | 183 | - Fixed README.md. 184 | 185 | ## 2.0.7 186 | 187 | - Added axiosConfig: AxiosRequestConfig option. 188 | - Option agent?: https.Agent marked as deprecated. 189 | - Added GoogleRecaptchaNetworkException. 190 | 191 | ## v2.0.6 192 | 193 | - Added support NestJS 8. 194 | - Dynamic loading HttpModule from `@nestjs/axios` or `@nestjs/common`. 195 | 196 | ## v2.0.5 197 | 198 | - Fixed dynamic module loading. 199 | 200 | ## v2.0.4 201 | 202 | - Added `RecaptchaResult` decorator. 203 | 204 | ## v2.0.3 205 | 206 | - Added `SetRecaptchaOptions` decorator. 207 | 208 | ## v2.0.2 209 | 210 | - Added error handling for invalid-keys. 211 | 212 | ## v2.0.1 213 | 214 | - Removed console.log 215 | 216 | ## v2.0.0 217 | 218 | - Added validation by action and score for reCAPTCHA v3. 219 | - Updated external interfaces. Affected places: 220 | - service GoogleRecaptchaValidator 221 | - decorator Recaptcha 222 | - module options (added optional default parameters) 223 | 224 | ## v1.2.4 225 | 226 | - Fixed readme. 227 | 228 | ## v.1.2.3 229 | 230 | - Updated readme. Added example to use validation in service. 231 | 232 | ## v1.2.2 233 | 234 | - Added support GraphQL. 235 | 236 | ## v1.2.1 237 | 238 | - Added LICENSE, CONTRIBUTING.md to build. Fixed readme. 239 | 240 | ## v1.2.0 241 | 242 | - Updated google recaptcha module options. 243 | - Removed option useRecaptchaNet: boolean 244 | - Added option: network: GoogleRecaptchaNetwork | string
If your server has trouble connecting to 'https://google.com' then you can set networks: 245 |
GoogleRecaptchaNetwork.Google = 'https://www.google.com/recaptcha/api/siteverify' 246 |
GoogleRecaptchaNetwork.Recaptcha = 'https://recaptcha.net/recaptcha/api/siteverify' 247 | or set any api url 248 | 249 | ## v1.1.11 250 | 251 | Removed unused dev dependencies. Updated readme. 252 | 253 | ## v1.1.10 254 | 255 | - Extended peer dependencies versions: 256 | - @nestjs/core: >=6.0.0 <8.0.0 257 | - @nestjs/common: >=6.0.0 <8.0.0 258 | 259 | ## v1.1.9 260 | 261 | - Fixed global option for `forRootAsync` method. 262 | 263 | ## v1.1.8 264 | 265 | - Module declared as global. 266 | 267 | ## v1.1.7 268 | 269 | - Fixed readme.md file. 270 | 271 | ## v1.1.6 272 | 273 | - Updated `skipIf` option to `boolean | ((request: any) => boolean | Promise)` 274 | 275 | ## v1.1.5 276 | 277 | - Updated skipIf argument from `() => boolean` to `(request) => boolean | Promise`. 278 | 279 | ## v1.1.4 280 | 281 | - Added option to use recaptcha.net and agent support. 282 | 283 | ## v1.1.3 284 | 285 | - Async module initialization. 286 | 287 | ## v1.1.2 288 | 289 | - Added override ability default recaptcha property. 290 | 291 | ## v1.1.1 292 | 293 | - Updated `GoogleRecaptchaException`. 294 | 295 | 296 | ## v1.1.0 297 | 298 | - Added `GoogleRecaptchaException`. Error handling via exception filter. 299 | 300 | ## v1.0.13 301 | 302 | - Reexported types 303 | -------------------------------------------------------------------------------- /test/integrations/http-recaptcha-enterprice.spec.ts: -------------------------------------------------------------------------------- 1 | import { Controller, INestApplication, Post } from '@nestjs/common'; 2 | import { ClassificationReason, ErrorCode, GoogleRecaptchaModule, Recaptcha, RecaptchaResult, RecaptchaVerificationResult } from '../../src'; 3 | import { Test, TestingModule } from '@nestjs/testing'; 4 | import { Request } from 'express'; 5 | import * as request from 'supertest'; 6 | import { MockedRecaptchaApi } from '../utils/mocked-recaptcha-api'; 7 | import { VerifyResponseV2 } from '../../src/interfaces/verify-response'; 8 | import { TestHttp } from '../utils/test-http'; 9 | import { VerifyResponseEnterprise } from '../../src/interfaces/verify-response-enterprise'; 10 | import { GoogleRecaptchaEnterpriseReason } from '../../src/enums/google-recaptcha-enterprise-reason'; 11 | import { TestErrorFilter } from '../assets/test-error-filter'; 12 | import { RECAPTCHA_AXIOS_INSTANCE } from '../../src/provider.declarations'; 13 | import { LiteralObject } from '../../src/interfaces/literal-object'; 14 | 15 | @Controller('test') 16 | class TestController { 17 | @Recaptcha({ response: (req) => req.headers.recaptcha, action: 'Submit', score: 0.7, remoteIp: () => 'IP_ADDR' }) 18 | @Post('submit') 19 | testAction(@RecaptchaResult() result: RecaptchaVerificationResult): LiteralObject { 20 | expect(result).toBeInstanceOf(RecaptchaVerificationResult); 21 | expect(result.success).toBeTruthy(); 22 | 23 | expect(result.remoteIp).toBe('IP_ADDR'); 24 | 25 | expect(result.getResponse()).toBeDefined(); 26 | 27 | const riskAnalytics = result.getEnterpriseRiskAnalytics(); 28 | 29 | expect(riskAnalytics).toBeDefined(); 30 | 31 | expect(typeof riskAnalytics.score).toBe('number'); 32 | expect(riskAnalytics.reasons).toBeInstanceOf(Array); 33 | 34 | return { success: true }; 35 | } 36 | } 37 | 38 | describe('HTTP Recaptcha Enterprise', () => { 39 | const mockedRecaptchaApi = new MockedRecaptchaApi(); 40 | 41 | let http: TestHttp; 42 | 43 | let module: TestingModule; 44 | let app: INestApplication; 45 | 46 | beforeAll(async () => { 47 | module = await Test.createTestingModule({ 48 | imports: [ 49 | GoogleRecaptchaModule.forRoot({ 50 | debug: true, 51 | response: (req: Request): string => req.headers.recaptcha_should_be_overwritten?.toString(), 52 | enterprise: { 53 | projectId: 'enterprise_projectId', 54 | apiKey: 'enterprise_apiKey', 55 | siteKey: 'enterprise_siteKey', 56 | }, 57 | score: 0.6, 58 | actions: ['ShouldBeOverwritten'], 59 | remoteIp: () => 'SOME_IP', 60 | }), 61 | ], 62 | controllers: [TestController], 63 | }) 64 | .overrideProvider(RECAPTCHA_AXIOS_INSTANCE) 65 | .useFactory({ 66 | factory: () => mockedRecaptchaApi.getAxios(), 67 | }) 68 | .compile(); 69 | 70 | app = module.createNestApplication(); 71 | 72 | app.useGlobalFilters(new TestErrorFilter()); 73 | 74 | await app.init(); 75 | 76 | http = new TestHttp(app.getHttpServer()); 77 | }); 78 | 79 | afterAll(() => app.close()); 80 | 81 | test('Enterprise OK', async () => { 82 | mockedRecaptchaApi.addResponse('test_enterprise_ok', { 83 | name: 'name', 84 | event: { 85 | userIpAddress: '0.0.0.0', 86 | siteKey: 'siteKey', 87 | userAgent: 'UA', 88 | token: 'token', 89 | hashedAccountId: '', 90 | expectedAction: 'Submit', 91 | }, 92 | tokenProperties: { 93 | createTime: new Date().toISOString(), 94 | valid: true, 95 | action: 'Submit', 96 | hostname: 'localhost', 97 | }, 98 | riskAnalysis: { 99 | reasons: [ClassificationReason.LOW_CONFIDENCE_SCORE], 100 | score: 0.8, 101 | }, 102 | }); 103 | 104 | const res: request.Response = await http.post( 105 | '/test/submit', 106 | {}, 107 | { 108 | headers: { 109 | Recaptcha: 'test_enterprise_ok', 110 | }, 111 | } 112 | ); 113 | 114 | expect(res.statusCode).toBe(201); 115 | expect(res.body.success).toBe(true); 116 | }); 117 | 118 | test('Enterprise token malformed', async () => { 119 | mockedRecaptchaApi.addResponse('test_enterprise_token_malformed', { 120 | name: 'name', 121 | event: { 122 | userIpAddress: '0.0.0.0', 123 | siteKey: 'siteKey', 124 | userAgent: 'UA', 125 | token: '', 126 | hashedAccountId: '', 127 | expectedAction: 'Submit', 128 | }, 129 | tokenProperties: { 130 | valid: false, 131 | invalidReason: GoogleRecaptchaEnterpriseReason.Malformed, 132 | hostname: '', 133 | action: '', 134 | createTime: '1970-01-01T00:00:00Z', 135 | }, 136 | }); 137 | 138 | const res: request.Response = await http.post( 139 | '/test/submit', 140 | {}, 141 | { 142 | headers: { 143 | Recaptcha: 'test_enterprise_token_malformed', 144 | }, 145 | } 146 | ); 147 | 148 | expect(res.statusCode).toBe(400); 149 | expect(res.body.errorCodes).toBeDefined(); 150 | expect(res.body.errorCodes.length).toBe(1); 151 | expect(res.body.errorCodes[0]).toBe(ErrorCode.InvalidInputResponse); 152 | }); 153 | 154 | test('Enterprise without token properties', async () => { 155 | mockedRecaptchaApi.addResponse('test_enterprise_without_token_props', { 156 | name: 'name', 157 | event: { 158 | userIpAddress: '0.0.0.0', 159 | siteKey: 'siteKey', 160 | userAgent: 'UA', 161 | token: '', 162 | hashedAccountId: '', 163 | expectedAction: 'Submit', 164 | }, 165 | }); 166 | 167 | const res: request.Response = await http.post( 168 | '/test/submit', 169 | {}, 170 | { 171 | headers: { 172 | Recaptcha: 'test_enterprise_without_token_props', 173 | }, 174 | } 175 | ); 176 | 177 | expect(res.statusCode).toBe(400); 178 | 179 | expect(res.body.errorCodes).toBeDefined(); 180 | expect(res.body.errorCodes.length).toBe(1); 181 | expect(res.body.errorCodes[0]).toBe(ErrorCode.InvalidInputResponse); 182 | }); 183 | 184 | test('Enterprise API error', async () => { 185 | mockedRecaptchaApi.addError('test_enterprise_api_err', { 186 | statusCode: 400, 187 | }); 188 | 189 | const res: request.Response = await http.post( 190 | '/test/submit', 191 | {}, 192 | { 193 | headers: { 194 | Recaptcha: 'test_enterprise_api_err', 195 | }, 196 | } 197 | ); 198 | 199 | expect(res.statusCode).toBe(500); 200 | 201 | expect(res.body.errorCodes).toBeDefined(); 202 | expect(res.body.errorCodes.length).toBe(1); 203 | expect(res.body.errorCodes[0]).toBe(ErrorCode.UnknownError); 204 | }); 205 | 206 | test('Enterprise Network error', async () => { 207 | mockedRecaptchaApi.addNetworkError('test_enterprise_network_err', 'ECONNRESET'); 208 | 209 | const res: request.Response = await http.post( 210 | '/test/submit', 211 | {}, 212 | { 213 | headers: { 214 | Recaptcha: 'test_enterprise_network_err', 215 | }, 216 | } 217 | ); 218 | 219 | expect(res.statusCode).toBe(500); 220 | 221 | expect(res.body.errorCodes).toBeDefined(); 222 | expect(res.body.errorCodes.length).toBe(1); 223 | expect(res.body.errorCodes[0]).toBe(ErrorCode.NetworkError); 224 | }); 225 | 226 | test('Enterprise Expired token', async () => { 227 | mockedRecaptchaApi.addResponse('test_enterprise_expired_token', { 228 | name: 'name', 229 | event: { 230 | userIpAddress: '0.0.0.0', 231 | siteKey: 'siteKey', 232 | userAgent: 'UA', 233 | token: 'token', 234 | hashedAccountId: '', 235 | expectedAction: 'InvalidAction', 236 | }, 237 | tokenProperties: { 238 | createTime: new Date().toISOString(), 239 | valid: true, 240 | action: 'InvalidAction', 241 | hostname: 'localhost', 242 | invalidReason: GoogleRecaptchaEnterpriseReason.Expired, 243 | }, 244 | riskAnalysis: { 245 | reasons: [ClassificationReason.LOW_CONFIDENCE_SCORE], 246 | score: 0.8, 247 | }, 248 | }); 249 | 250 | const res: request.Response = await http.post( 251 | '/test/submit', 252 | {}, 253 | { 254 | headers: { 255 | Recaptcha: 'test_enterprise_expired_token', 256 | }, 257 | } 258 | ); 259 | 260 | expect(res.statusCode).toBe(400); 261 | expect(res.body.errorCodes).toBeDefined(); 262 | expect(res.body.errorCodes.length).toBe(2); 263 | expect(res.body.errorCodes[0]).toBe(ErrorCode.TimeoutOrDuplicate); 264 | expect(res.body.errorCodes[1]).toBe(ErrorCode.ForbiddenAction); 265 | }); 266 | 267 | test('Enterprise Invalid action', async () => { 268 | mockedRecaptchaApi.addResponse('test_enterprise_invalid_action', { 269 | name: 'name', 270 | event: { 271 | userIpAddress: '0.0.0.0', 272 | siteKey: 'siteKey', 273 | userAgent: 'UA', 274 | token: 'token', 275 | hashedAccountId: '', 276 | expectedAction: 'InvalidAction', 277 | }, 278 | tokenProperties: { 279 | createTime: new Date().toISOString(), 280 | valid: true, 281 | action: 'InvalidAction', 282 | hostname: 'localhost', 283 | }, 284 | riskAnalysis: { 285 | reasons: [ClassificationReason.LOW_CONFIDENCE_SCORE], 286 | score: 0.8, 287 | }, 288 | }); 289 | 290 | const res: request.Response = await http.post( 291 | '/test/submit', 292 | {}, 293 | { 294 | headers: { 295 | Recaptcha: 'test_enterprise_invalid_action', 296 | }, 297 | } 298 | ); 299 | 300 | expect(res.statusCode).toBe(400); 301 | }); 302 | 303 | test('Enterprise Low score', async () => { 304 | mockedRecaptchaApi.addResponse('test_enterprise_low_score', { 305 | name: 'name', 306 | event: { 307 | userIpAddress: '0.0.0.0', 308 | siteKey: 'siteKey', 309 | userAgent: 'UA', 310 | token: 'token', 311 | hashedAccountId: '', 312 | expectedAction: 'Submit', 313 | }, 314 | tokenProperties: { 315 | createTime: new Date().toISOString(), 316 | valid: true, 317 | action: 'Submit', 318 | hostname: 'localhost', 319 | }, 320 | riskAnalysis: { 321 | reasons: [ClassificationReason.LOW_CONFIDENCE_SCORE], 322 | score: 0.3, 323 | }, 324 | }); 325 | 326 | const res: request.Response = await http.post( 327 | '/test/submit', 328 | {}, 329 | { 330 | headers: { 331 | Recaptcha: 'test_enterprise_low_score', 332 | }, 333 | } 334 | ); 335 | 336 | expect(res.statusCode).toBe(400); 337 | expect(res.body.errorCodes).toBeDefined(); 338 | expect(res.body.errorCodes.length).toBe(1); 339 | expect(res.body.errorCodes[0]).toBe(ErrorCode.LowScore); 340 | }); 341 | 342 | test('Enterprise Invalid reason unspecified + low score', async () => { 343 | mockedRecaptchaApi.addResponse('test_enterprise_inv_reason_unspecified_low_score', { 344 | name: 'name', 345 | event: { 346 | token: 'token', 347 | siteKey: 'siteKey', 348 | userAgent: '', 349 | userIpAddress: '', 350 | expectedAction: 'Submit', 351 | hashedAccountId: '', 352 | }, 353 | riskAnalysis: { 354 | score: 0.6, 355 | reasons: [], 356 | }, 357 | tokenProperties: { 358 | valid: true, 359 | invalidReason: GoogleRecaptchaEnterpriseReason.InvalidReasonUnspecified, 360 | hostname: 'localhost', 361 | action: 'Submit', 362 | createTime: '2022-09-07T19:53:55.566Z', 363 | }, 364 | }); 365 | 366 | const res: request.Response = await http.post( 367 | '/test/submit', 368 | {}, 369 | { 370 | headers: { 371 | Recaptcha: 'test_enterprise_inv_reason_unspecified_low_score', 372 | }, 373 | } 374 | ); 375 | 376 | expect(res.statusCode).toBe(400); 377 | expect(res.body.errorCodes).toBeDefined(); 378 | expect(res.body.errorCodes.length).toBe(1); 379 | expect(res.body.errorCodes[0]).toBe(ErrorCode.LowScore); 380 | }); 381 | }); 382 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Google recaptcha module 2 | 3 | This package provides protection for endpoints using [reCAPTCHA](https://www.google.com/recaptcha/about/) for [NestJS](https://docs.nestjs.com/) REST and GraphQL applications. By integrating with reCAPTCHA, this package helps to prevent automated abuse such as spam and bots, improving the security and reliability of your application. 4 | 5 | [![NPM Version](https://img.shields.io/npm/v/@nestlab/google-recaptcha.svg)](https://www.npmjs.com/package/@nestlab/google-recaptcha) 6 | [![Licence](https://img.shields.io/npm/l/@nestlab/google-recaptcha.svg)](https://github.com/chvarkov/google-recaptcha/blob/master/LICENSE) 7 | [![NPM Downloads](https://img.shields.io/npm/dm/@nestlab/google-recaptcha.svg)](https://www.npmjs.com/package/@nestlab/google-recaptcha) 8 | [![Build status](https://github.com/chvarkov/google-recaptcha/actions/workflows/test.yml/badge.svg)](https://github.com/chvarkov/google-recaptcha/actions/workflows/test.yml) 9 | [![Coverage Status](https://chvarkov.github.io/google-recaptcha/badges/coverage.svg)](https://github.com/chvarkov/google-recaptcha/actions) 10 | 11 | 12 | ## Table of Contents 13 | 14 | * [Installation](#installation) 15 | * [Changes](#changes) 16 | * [Configuration](#configuration) 17 | * [Options](#options) 18 | * [REST application](#rest-application) 19 | * [reCAPTCHA v2](#rest-recaptcha-v2) 20 | * [reCAPTCHA v3](#rest-recaptcha-v3) 21 | * [reCAPTCHA Enterprise](#rest-recaptcha-enterprise) 22 | * [Graphql application](#graphql-application) 23 | * [reCAPTCHA v2](#graphql-recaptcha-v2) 24 | * [reCAPTCHA v3](#graphql-recaptcha-v3) 25 | * [reCAPTCHA Enterprise](#graphql-recaptcha-enterprise) 26 | * [Usage](#usage) 27 | * [REST application](#usage-in-rest-application) 28 | * [Graphql application](#usage-in-graphql-application) 29 | * [Validate in service](#validate-in-service) 30 | * [Validate in service (Enterprise)](#validate-in-service-enterprise) 31 | * [Dynamic Recaptcha configuration](#dynamic-recaptcha-configuration) 32 | * [Error handling](#error-handling) 33 | * [Contribution](#contribution) 34 | * [License](#license) 35 | 36 | Usage example [here](https://github.com/chvarkov/google-recaptcha-example) 37 | 38 | 39 | ## Installation 40 | 41 | ``` 42 | $ npm i @nestlab/google-recaptcha 43 | ``` 44 | 45 | ## Changes 46 | 47 | The list of changes made in the project can be found in the [CHANGELOG.md](./CHANGELOG.md) file. 48 | 49 | ## Configuration 50 | 51 | ### Options 52 | 53 | **GoogleRecaptchaModuleOptions** 54 | 55 | | Property | Description | 56 | |-------------------|-------------| 57 | | `response` | **Required.**
Type: `(request) => string`
Function that returns response (recaptcha token) by request | 58 | | `secretKey` | Optional.
Type: `string`
Google recaptcha secret key. Must be set if you don't use reCAPTCHA Enterprise | 59 | | `debug` | Optional.
Type: `boolean`
Default: `false`
Enables logging requests, responses, errors and transformed results | 60 | | `logger` | Optional.
Type: `Logger`
Default: `new Logger()`
Instance of custom logger that extended from Logger (@nestjs/common) | 61 | | `skipIf` | Optional.
Type: `boolean` \| `(request) => boolean \| Promise`
Function that returns true if you allow the request to skip the recaptcha verification. Useful for involing other check methods (e.g. custom privileged API key) or for development or testing | 62 | | `enterprise` | Optional.
Type: `GoogleRecaptchaEnterpriseOptions`
Options for using reCAPTCHA Enterprise API. Cannot be used with `secretKey` option. | 63 | | `network` | Optional.
Type: `GoogleRecaptchaNetwork` \| `string`
Default: `GoogleRecaptchaNetwork.Google`
If your server has trouble connecting to https://google.com then you can set networks:
`GoogleRecaptchaNetwork.Google` = 'https://www.google.com/recaptcha/api/siteverify'
`GoogleRecaptchaNetwork.Recaptcha` = 'https://recaptcha.net/recaptcha/api/siteverify'
or set any api url | 64 | | `score` | Optional.
Type: `number` \| `(score: number) => boolean`
Score validator for reCAPTCHA v3 or enterprise.
`number` - minimum available score.
`(score: number) => boolean` - function with custom validation rules. | 65 | | `actions` | Optional.
Type: `string[]`
Available action list for reCAPTCHA v3 or enterprise.
You can make this check stricter by passing the action property parameter to `@Recaptcha(...)` decorator. | 66 | | `remoteIp` | Optional.
Type: `(request) => string`
A function that returns a remote IP address from the request | 67 | | `axiosConfig` | Optional.
Type: `AxiosRequestConfig`
Allows to setup proxy, response timeout, https agent etc... | 68 | | `global` | Optional.
Type: `boolean`
Default: `false` Defines a module in the [global scope](https://docs.nestjs.com/modules#global-modules). | 69 | 70 | **GoogleRecaptchaEnterpriseOptions** 71 | 72 | | Property | Description | 73 | |-----------------|-------------| 74 | | `projectId` | **Required.**
Type: `string`
Google Cloud project ID | 75 | | `siteKey` | **Required.**
Type: `string`
[reCAPTCHA key](https://cloud.google.com/recaptcha-enterprise/docs/keys) associated with the site/app. | 76 | | `apiKey` | **Required.**
Type: `string`
API key associated with the current project.
Must have permission `reCAPTCHA Enterprise API`.
You can manage credentials [here](https://console.cloud.google.com/apis/credentials). | 77 | 78 | 79 | The module provides two static methods for configuration: `forRoot` and `forRootAsync`. 80 | 81 | **forRoot** 82 | 83 | > forRoot(options: GoogleRecaptchaModuleOptions): DynamicModule 84 | 85 | The `forRoot` method accepts a `GoogleRecaptchaModuleOptions` object that configures the module. This method should be used in the root `AppModule`.
Example usage: 86 | 87 | ```typescript 88 | @Module({ 89 | imports: [ 90 | GoogleRecaptchaModule.forRoot({ 91 | secretKey: process.env.GOOGLE_RECAPTCHA_SECRET_KEY, 92 | response: req => req.headers.recaptcha, 93 | }) 94 | ], 95 | }) 96 | export class AppModule { 97 | } 98 | ``` 99 | 100 | **forRootAsync** 101 | 102 | > forRootAsync(options: ModuleAsyncOptions): DynamicModule 103 | 104 | The `forRootAsync` method is similar to `forRoot`, but allows for asynchronous configuration.
105 | It accepts a `GoogleRecaptchaModuleAsyncOptions` object that returns a configuration object or a Promise that resolves to a configuration object.
106 | Read more about [ConfigService](https://docs.nestjs.com/techniques/configuration#getting-started) and [custom getter function](https://docs.nestjs.com/techniques/configuration#custom-getter-functions). 107 | 108 | Example usage: 109 | 110 | ```typescript 111 | @Module({ 112 | imports: [ 113 | GoogleRecaptchaModule.forRootAsync({ 114 | imports: [ConfigModule], 115 | useFactory: (configService: ConfigService) => configService.googleRecaptchaOptions, 116 | inject: [ConfigService], 117 | }) 118 | ], 119 | }) 120 | export class AppModule { 121 | } 122 | ``` 123 | 124 | ### REST application 125 | 126 | #### REST reCAPTCHA V2 127 | 128 | ```typescript 129 | @Module({ 130 | imports: [ 131 | GoogleRecaptchaModule.forRoot({ 132 | secretKey: process.env.GOOGLE_RECAPTCHA_SECRET_KEY, 133 | response: req => req.headers.recaptcha, 134 | skipIf: process.env.NODE_ENV !== 'production', 135 | }), 136 | ], 137 | }) 138 | export class AppModule { 139 | } 140 | ``` 141 | 142 | **Tip: header names transforming to lower case.** 143 | 144 | **For example:** If you send 'Recaptcha' header then use `(req) => req.headers.recaptcha` 145 | 146 |
147 | 148 | #### REST reCAPTCHA V3 149 | 150 | ```typescript 151 | @Module({ 152 | imports: [ 153 | GoogleRecaptchaModule.forRoot({ 154 | secretKey: process.env.GOOGLE_RECAPTCHA_SECRET_KEY, 155 | response: req => req.headers.recaptcha, 156 | skipIf: process.env.NODE_ENV !== 'production', 157 | actions: ['SignUp', 'SignIn'], 158 | score: 0.8, 159 | }), 160 | ], 161 | }) 162 | export class AppModule { 163 | } 164 | ``` 165 | 166 | **Tip: header names transforming to lower case.** 167 | 168 | **For example:** If you send 'Recaptcha' header then use `(req) => req.headers.recaptcha` 169 | 170 |
171 | 172 | #### REST reCAPTCHA Enterprise 173 | 174 | ```typescript 175 | @Module({ 176 | imports: [ 177 | GoogleRecaptchaModule.forRoot({ 178 | response: (req) => req.headers.recaptcha, 179 | skipIf: process.env.NODE_ENV !== 'production', 180 | actions: ['SignUp', 'SignIn'], 181 | score: 0.8, 182 | enterprise: { 183 | projectId: process.env.RECAPTCHA_ENTERPRISE_PROJECT_ID, 184 | siteKey: process.env.RECAPTCHA_ENTERPRISE_SITE_KEY, 185 | apiKey: process.env.RECAPTCHA_ENTERPRISE_API_KEY, 186 | }, 187 | }), 188 | ], 189 | }) 190 | export class AppModule { 191 | } 192 | ``` 193 | 194 | **Tip: header names transforming to lower case.** 195 | 196 | **For example:** If you send 'Recaptcha' header then use `(req) => req.headers.recaptcha` 197 | 198 | ### Graphql application 199 | 200 | #### Graphql reCAPTCHA V2 201 | 202 | ```typescript 203 | @Module({ 204 | imports: [ 205 | GoogleRecaptchaModule.forRoot({ 206 | secretKey: process.env.GOOGLE_RECAPTCHA_SECRET_KEY, 207 | response: (req: IncomingMessage) => (req.headers.recaptcha || '').toString(), 208 | skipIf: process.env.NODE_ENV !== 'production', 209 | }), 210 | ], 211 | }) 212 | export class AppModule { 213 | } 214 | ``` 215 | 216 | **Tip: header names transforming to lower case.** 217 | 218 | **For example:** If you send 'Recaptcha' header then use `(req: IncomingMessage) => (req.headers.recaptcha || '').toString()` 219 | 220 |
221 | 222 | #### Graphql reCAPTCHA V3 223 | 224 | ```typescript 225 | @Module({ 226 | imports: [ 227 | GoogleRecaptchaModule.forRoot({ 228 | secretKey: process.env.GOOGLE_RECAPTCHA_SECRET_KEY, 229 | response: (req: IncomingMessage) => (req.headers.recaptcha || '').toString(), 230 | skipIf: process.env.NODE_ENV !== 'production', 231 | actions: ['SignUp', 'SignIn'], 232 | score: 0.8, 233 | }), 234 | ], 235 | }) 236 | export class AppModule { 237 | } 238 | ``` 239 | 240 | **Tip: header names transforming to lower case.** 241 | 242 | **For example:** If you send 'Recaptcha' header then use `(req: IncomingMessage) => (req.headers.recaptcha || '').toString()` 243 | 244 |
245 | 246 | #### Graphql reCAPTCHA Enterprise 247 | 248 | ```typescript 249 | @Module({ 250 | imports: [ 251 | GoogleRecaptchaModule.forRoot({ 252 | response: (req: IncomingMessage) => (req.headers.recaptcha || '').toString(), 253 | skipIf: process.env.NODE_ENV !== 'production', 254 | actions: ['SignUp', 'SignIn'], 255 | score: 0.8, 256 | enterprise: { 257 | projectId: process.env.RECAPTCHA_ENTERPRISE_PROJECT_ID, 258 | siteKey: process.env.RECAPTCHA_ENTERPRISE_SITE_KEY, 259 | apiKey: process.env.RECAPTCHA_ENTERPRISE_API_KEY, 260 | }, 261 | }), 262 | ], 263 | }) 264 | export class AppModule { 265 | } 266 | ``` 267 | 268 | **Tip: header names transforming to lower case.** 269 | 270 | **For example:** If you send 'Recaptcha' header then use `(req) => req.headers.recaptcha` 271 | 272 | 273 | **Configuration for reCAPTCHA Enterprise** 274 | 275 | ```typescript 276 | @Module({ 277 | imports: [ 278 | GoogleRecaptchaModule.forRoot({ 279 | response: (req) => req.headers.recaptcha, 280 | skipIf: process.env.NODE_ENV !== 'production', 281 | actions: ['SignUp', 'SignIn'], 282 | score: 0.8, 283 | enterprise: { 284 | projectId: process.env.RECAPTCHA_ENTERPRISE_PROJECT_ID, 285 | siteKey: process.env.RECAPTCHA_ENTERPRISE_SITE_KEY, 286 | apiKey: process.env.RECAPTCHA_ENTERPRISE_API_KEY, 287 | }, 288 | }), 289 | ], 290 | }) 291 | export class AppModule { 292 | } 293 | ``` 294 | 295 | ## Usage 296 | 297 | ### Usage in REST application 298 | 299 | To protect your REST endpoints, you can use the `@Recaptcha` decorator.
Example: 300 | 301 | ```typescript 302 | 303 | @Controller('feedback') 304 | export class FeedbackController { 305 | @Recaptcha() 306 | @Post('send') 307 | async send(): Promise { 308 | // TODO: Your implementation. 309 | } 310 | } 311 | 312 | ``` 313 | 314 | You can also override the default property that contains reCAPTCHA for a specific endpoint.
315 | 316 | ```typescript 317 | 318 | @Controller('feedback') 319 | export class FeedbackController { 320 | @Recaptcha({response: req => req.body.recaptha}) 321 | @Post('send') 322 | async send(): Promise { 323 | // TODO: Your implementation. 324 | } 325 | } 326 | 327 | ``` 328 | 329 | Additionally, you can override reCAPTCHA v3 options.
330 | 331 | ```typescript 332 | 333 | @Controller('feedback') 334 | export class FeedbackController { 335 | @Recaptcha({response: req => req.body.recaptha, action: 'Send', score: 0.8}) 336 | @Post('send') 337 | async send(): Promise { 338 | // TODO: Your implementation. 339 | } 340 | } 341 | 342 | ``` 343 | 344 | To get the verification result, you can use the @RecaptchaResult() decorator.
345 | 346 | ```typescript 347 | 348 | @Controller('feedback') 349 | export class FeedbackController { 350 | @Recaptcha() 351 | @Post('send') 352 | async send(@RecaptchaResult() recaptchaResult: RecaptchaVerificationResult): Promise { 353 | console.log(`Action: ${recaptchaResult.action} Score: ${recaptchaResult.score}`); 354 | // TODO: Your implementation. 355 | } 356 | } 357 | 358 | ``` 359 | 360 | If you want to use the Google reCAPTCHA guard in combination with other guards, you can use the `@UseGuards` decorator.
361 | ```typescript 362 | 363 | @Controller('feedback') 364 | export class FeedbackController { 365 | @SetRecaptchaOptions({action: 'Send', score: 0.8}) 366 | @UseGuards(Guard1, GoogleRecaptchaGuard, Guard2) 367 | @Post('send') 368 | async send(): Promise { 369 | // TODO: Your implementation. 370 | } 371 | } 372 | 373 | ``` 374 | 375 | You can find a usage example in the following [link](https://github.com/chvarkov/google-recaptcha-example). 376 | 377 | ### Usage in Graphql application 378 | 379 | To protect your resolver, use the `@Recaptcha` decorator. 380 | 381 | ```typescript 382 | @Recaptcha() 383 | @Resolver(of => Recipe) 384 | export class RecipesResolver { 385 | @Query(returns => Recipe) 386 | async recipe(@Args('id') id: string): Promise { 387 | // TODO: Your implementation. 388 | } 389 | } 390 | ``` 391 | 392 | Obtain verification result: 393 | 394 | ```typescript 395 | @Recaptcha() 396 | @Resolver(of => Recipe) 397 | export class RecipesResolver { 398 | @Query(returns => Recipe) 399 | async recipe(@Args('id') id: string, 400 | @RecaptchaResult() recaptchaResult: RecaptchaVerificationResult): Promise { 401 | console.log(`Action: ${recaptchaResult.action} Score: ${recaptchaResult.score}`); 402 | // TODO: Your implementation. 403 | } 404 | } 405 | ``` 406 | 407 | You can override the default recaptcha property for a specific endpoint. 408 | 409 | ```typescript 410 | @Recaptcha() 411 | @Resolver(of => Recipe) 412 | export class RecipesResolver { 413 | @Query(returns => Recipe) 414 | async recipe(@Args('id') id: string): Promise { 415 | // TODO: Your implementation. 416 | } 417 | 418 | // Overridden default header. This query using X-Recaptcha header 419 | @Recaptcha({response: (req: IncomingMessage) => (req.headers['x-recaptcha'] || '').toString()}) 420 | @Query(returns => [Recipe]) 421 | recipes(@Args() recipesArgs: RecipesArgs): Promise { 422 | // TODO: Your implementation. 423 | } 424 | } 425 | ``` 426 | 427 | ### Validate in service 428 | 429 | ```typescript 430 | @Injectable() 431 | export class SomeService { 432 | constructor(private readonly recaptchaValidator: GoogleRecaptchaValidator) { 433 | } 434 | 435 | async someAction(recaptchaToken: string): Promise { 436 | const result = await this.recaptchaValidator.validate({ 437 | response: recaptchaToken, 438 | score: 0.8, 439 | action: 'SomeAction', 440 | }); 441 | 442 | if (!result.success) { 443 | throw new GoogleRecaptchaException(result.errors); 444 | } 445 | // TODO: Your implemetation 446 | } 447 | } 448 | ``` 449 | 450 | ### Validate in service (Enterprise) 451 | 452 | ```typescript 453 | @Injectable() 454 | export class SomeService { 455 | constructor(private readonly recaptchaEnterpriseValidator: GoogleRecaptchaEnterpriseValidator) { 456 | } 457 | 458 | async someAction(recaptchaToken: string): Promise { 459 | const result = await this.recaptchaEnterpriseValidator.validate({ 460 | response: recaptchaToken, 461 | score: 0.8, 462 | action: 'SomeAction', 463 | }); 464 | 465 | if (!result.success) { 466 | throw new GoogleRecaptchaException(result.errors); 467 | } 468 | 469 | const riskAnalytics = result.getEnterpriseRiskAnalytics(); 470 | 471 | // TODO: Your implemetation 472 | } 473 | } 474 | ``` 475 | 476 | ### Dynamic Recaptcha configuration 477 | The `RecaptchaConfigRef` class provides a convenient way to modify Recaptcha validation parameters within your application. 478 | This can be particularly useful in scenarios where the administration of Recaptcha is managed dynamically, such as by an administrator. 479 | The class exposes methods that allow the customization of various Recaptcha options. 480 | 481 | 482 | **RecaptchaConfigRef API:** 483 | 484 | ```typescript 485 | @Injectable() 486 | class RecaptchaConfigRef { 487 | // Sets the secret key for Recaptcha validation. 488 | setSecretKey(secretKey: string): this; 489 | 490 | // Sets enterprise-specific options for Recaptcha validation 491 | setEnterpriseOptions(options: GoogleRecaptchaEnterpriseOptions): this; 492 | 493 | // Sets the score threshold for Recaptcha validation. 494 | setScore(score: ScoreValidator): this; 495 | 496 | // Sets conditions under which Recaptcha validation should be skipped. 497 | setSkipIf(skipIf: SkipIfValue): this; 498 | } 499 | ``` 500 | 501 | **Usage example:** 502 | 503 | ```typescript 504 | @Injectable() 505 | export class RecaptchaAdminService implements OnApplicationBootstrap { 506 | constructor(private readonly recaptchaConfigRef: RecaptchaConfigRef) { 507 | } 508 | 509 | async onApplicationBootstrap(): Promise { 510 | // TODO: Pull recaptcha configs from your database 511 | 512 | this.recaptchaConfigRef 513 | .setSecretKey('SECRET_KEY_VALUE') 514 | .setScore(0.3); 515 | } 516 | 517 | async updateSecretKey(secretKey: string): Promise { 518 | // TODO: Save new secret key to your database 519 | 520 | this.recaptchaConfigRef.setSecretKey(secretKey); 521 | } 522 | } 523 | ``` 524 | 525 | After call `this.recaptchaConfigRef.setSecretKey(...)` - `@Recaptcha` guard and `GoogleRecaptchaValidator` will use new secret key. 526 | 527 | ### Error handling 528 | 529 | **GoogleRecaptchaException** 530 | 531 | `GoogleRecaptchaException` extends `HttpException` extends `Error`. 532 | 533 | The `GoogleRecaptchaException` is an exception that can be thrown by the `GoogleRecaptchaGuard` when an error occurs. It extends the `HttpException` class provided by NestJS, which means that it can be caught by an ExceptionFilter in the same way as any other HTTP exception. 534 | 535 | One important feature of the `GoogleRecaptchaException` is that it contains an array of Error Code values in the errorCodes property. These values can be used to diagnose and handle the error. 536 | 537 | 538 | 539 | | Error code | Description | Status code | 540 | |----------------------------------|-------------|-------------| 541 | | `ErrorCode.MissingInputSecret` | The secret parameter is missing. (Throws from reCAPTCHA api). | 500 | 542 | | `ErrorCode.InvalidInputSecret` | The secret parameter is invalid or malformed. (Throws from reCAPTCHA api). | 500 | 543 | | `ErrorCode.MissingInputResponse` | The response parameter is missing. (Throws from reCAPTCHA api). | 400 | 544 | | `ErrorCode.InvalidInputResponse` | The response parameter is invalid or malformed. (Throws from reCAPTCHA api). | 400 | 545 | | `ErrorCode.BadRequest` | The request is invalid or malformed. (Throws from reCAPTCHA api). | 500 | 546 | | `ErrorCode.TimeoutOrDuplicate` | The response is no longer valid: either is too old or has been used previously. (Throws from reCAPTCHA api). | 400 | 547 | | `ErrorCode.UnknownError` | Unknown error. (Throws from reCAPTCHA api). | 500 | 548 | | `ErrorCode.ForbiddenAction` | Forbidden action. (Throws from guard when expected action not equals to received). | 400 | 549 | | `ErrorCode.LowScore` | Low score (Throws from guard when expected score less than received). | 400 | 550 | | `ErrorCode.InvalidKeys` | keys were copied incorrectly, the wrong keys were used for the environment (e.g. development vs production), or if the keys were revoked or deleted from the Google reCAPTCHA admin console.. (Throws from reCAPTCHA api). | 400 | 551 | | `ErrorCode.NetworkError` | Network error (like ECONNRESET, ECONNREFUSED...). | 500 | 552 | | `ErrorCode.SiteMismatch` | Site mismatch (Throws from reCAPTCHA Enterprise api only). | 400 | 553 | | `ErrorCode.BrowserError` | Browser error (Throws from reCAPTCHA Enterprise api only). | 400 | 554 | 555 | 556 | **GoogleRecaptchaNetworkException** 557 | 558 | The `GoogleRecaptchaNetworkException` is an exception that extends the `GoogleRecaptchaException` class and is thrown in the case of a network error.
It contains a `networkErrorCode` property, which contains the error code of the network error, retrieved from the `code` property of the `AxiosError` object. 559 | 560 | You can handle it via [ExceptionFilter](https://docs.nestjs.com/exception-filters). 561 | 562 | Example exception filter implementation. 563 | 564 | ```typescript 565 | 566 | @Catch(GoogleRecaptchaException) 567 | export class GoogleRecaptchaFilter implements ExceptionFilter { 568 | catch(exception: GoogleRecaptchaException, host: ArgumentsHost): any { 569 | // TODO: Your exception filter implementation 570 | } 571 | } 572 | 573 | ``` 574 | 575 | And add your filter to application 576 | 577 | ```typescript 578 | 579 | async function bootstrap() { 580 | const app = await NestFactory.create(AppModule); 581 | app.useGlobalFilters(new ErrorFilter(), new GoogleRecaptchaFilter()); 582 | await app.listen(3000); 583 | } 584 | bootstrap(); 585 | 586 | 587 | ``` 588 | 589 | ## Contribution 590 | 591 | We welcome any contributions to improve our package! If you find a bug, have a feature request, or want to suggest an improvement, feel free to submit an issue on our GitHub repository. 592 | 593 | If you want to contribute to the codebase directly, please follow our contributing guidelines outlined in the [CONTRIBUTING.md](./CONTRIBUTING.md) file in the repository. 594 | 595 | We value the contributions of our community and appreciate all efforts to make this package better for everyone. Thank you for your support! 596 | 597 | ## License 598 | 599 | This project is licensed under the MIT License - see the [LICENSE.md](./LICENSE) file for details. 600 | --------------------------------------------------------------------------------