Skip to content

Commit

Permalink
Merge pull request #140 from chvarkov/develop
Browse files Browse the repository at this point in the history
feat: dynamic recaptcha configuration.
  • Loading branch information
chvarkov authored Jan 29, 2024
2 parents 57253e8 + b7b5cd2 commit 589a80d
Show file tree
Hide file tree
Showing 19 changed files with 280 additions and 70 deletions.
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ trim_trailing_whitespace = true

[*.ts]
quote_type = single
ij_typescript_use_double_quotes = true
ij_typescript_use_double_quotes = false
ij_typescript_use_semicolon_after_statement = true
max_line_length = 140
ij_smart_tabs = true
Expand Down
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ This package provides protection for endpoints using [reCAPTCHA](https://www.goo
* [Graphql application](#usage-in-graphql-application)
* [Validate in service](#validate-in-service)
* [Validate in service (Enterprise)](#validate-in-service-enterprise)
* [Dynamic Recaptcha configuration](#dynamic-recaptcha-configuration)
* [Error handling](#error-handling)
* [Contribution](#contribution)
* [License](#license)
Expand Down Expand Up @@ -471,6 +472,57 @@ export class SomeService {
}
```

### Dynamic Recaptcha configuration
The `RecaptchaConfigRef` class provides a convenient way to modify Recaptcha validation parameters within your application.
This can be particularly useful in scenarios where the administration of Recaptcha is managed dynamically, such as by an administrator.
The class exposes methods that allow the customization of various Recaptcha options.


**RecaptchaConfigRef API:**

```typescript
@Injectable()
class RecaptchaConfigRef {
// Sets the secret key for Recaptcha validation.
setSecretKey(secretKey: string): this;

// Sets enterprise-specific options for Recaptcha validation
setEnterpriseOptions(options: GoogleRecaptchaEnterpriseOptions): this;

// Sets the score threshold for Recaptcha validation.
setScore(score: ScoreValidator): this;

// Sets conditions under which Recaptcha validation should be skipped.
setSkipIf(skipIf: SkipIfValue): this;
}
```

**Usage example:**

```typescript
@Injectable()
export class RecaptchaAdminService implements OnApplicationBootstrap {
constructor(private readonly recaptchaConfigRef: RecaptchaConfigRef) {
}

async onApplicationBootstrap(): Promise<void> {
// TODO: Pull recaptcha configs from your database

this.recaptchaConfigRef
.setSecretKey('SECRET_KEY_VALUE')
.setScore(0.3);
}

async updateSecretKey(secretKey: string): Promise<void> {
// TODO: Save new secret key to your database

this.recaptchaConfigRef.setSecretKey(secretKey);
}
}
```

After call `this.recaptchaConfigRef.setSecretKey(...)` - `@Recaptcha` guard and `GoogleRecaptchaValidator` will use new secret key.

### Error handling

**GoogleRecaptchaException**
Expand Down
10 changes: 10 additions & 0 deletions src/google-recaptcha.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Agent } from 'https';
import { RecaptchaValidatorResolver } from './services/recaptcha-validator.resolver';
import { EnterpriseReasonTransformer } from './services/enterprise-reason.transformer';
import { xor } from './helpers/xor';
import { RecaptchaConfigRef } from './models/recaptcha-config-ref';

export class GoogleRecaptchaModule {
private static axiosDefaultConfig: AxiosRequestConfig = {
Expand All @@ -39,6 +40,10 @@ export class GoogleRecaptchaModule {
provide: RECAPTCHA_LOGGER,
useFactory: () => options.logger || new Logger(),
},
{
provide: RecaptchaConfigRef,
useFactory: () => new RecaptchaConfigRef(options),
},
];

this.validateOptions(options);
Expand Down Expand Up @@ -72,6 +77,11 @@ export class GoogleRecaptchaModule {
useFactory: (options: GoogleRecaptchaModuleOptions) => options.logger || new Logger(),
inject: [RECAPTCHA_OPTIONS],
},
{
provide: RecaptchaConfigRef,
useFactory: (opts: GoogleRecaptchaModuleOptions) => new RecaptchaConfigRef(opts),
inject: [RECAPTCHA_OPTIONS],
},
GoogleRecaptchaGuard,
GoogleRecaptchaValidator,
GoogleRecaptchaEnterpriseValidator,
Expand Down
17 changes: 9 additions & 8 deletions src/guards/google-recaptcha.guard.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { CanActivate, ExecutionContext, Inject, Injectable, Logger } from '@nestjs/common';
import { RECAPTCHA_LOGGER, RECAPTCHA_OPTIONS, RECAPTCHA_VALIDATION_OPTIONS } from '../provider.declarations';
import { RECAPTCHA_LOGGER, RECAPTCHA_VALIDATION_OPTIONS } from '../provider.declarations';
import { GoogleRecaptchaException } from '../exceptions/google-recaptcha.exception';
import { Reflector } from '@nestjs/core';
import { RecaptchaRequestResolver } from '../services/recaptcha-request.resolver';
import { VerifyResponseDecoratorOptions } from '../interfaces/verify-response-decorator-options';
import { GoogleRecaptchaModuleOptions } from '../interfaces/google-recaptcha-module-options';
import { RecaptchaValidatorResolver } from '../services/recaptcha-validator.resolver';
import { GoogleRecaptchaContext } from '../enums/google-recaptcha-context';
import { AbstractGoogleRecaptchaValidator } from '../services/validators/abstract-google-recaptcha-validator';
import { GoogleRecaptchaEnterpriseValidator } from '../services/validators/google-recaptcha-enterprise.validator';
import { LiteralObject } from '../interfaces/literal-object';
import { RecaptchaConfigRef } from '../models/recaptcha-config-ref';

@Injectable()
export class GoogleRecaptchaGuard implements CanActivate {
Expand All @@ -18,13 +18,14 @@ export class GoogleRecaptchaGuard implements CanActivate {
private readonly requestResolver: RecaptchaRequestResolver,
private readonly validatorResolver: RecaptchaValidatorResolver,
@Inject(RECAPTCHA_LOGGER) private readonly logger: Logger,
@Inject(RECAPTCHA_OPTIONS) private readonly options: GoogleRecaptchaModuleOptions
private readonly configRef: RecaptchaConfigRef,
) {}

async canActivate(context: ExecutionContext): Promise<true | never> {
const request: LiteralObject = this.requestResolver.resolve(context);

const skip = typeof this.options.skipIf === 'function' ? await this.options.skipIf(request) : !!this.options.skipIf;
const skipIfValue = this.configRef.valueOf.skipIf;
const skip = typeof skipIfValue === 'function' ? await skipIfValue(request) : !!skipIfValue;

if (skip) {
return true;
Expand All @@ -33,18 +34,18 @@ export class GoogleRecaptchaGuard implements CanActivate {
const options: VerifyResponseDecoratorOptions = this.reflector.get(RECAPTCHA_VALIDATION_OPTIONS, context.getHandler());

const [response, remoteIp] = await Promise.all([
options?.response ? await options.response(request) : await this.options.response(request),
options?.remoteIp ? await options.remoteIp(request) : await this.options.remoteIp && this.options.remoteIp(request),
options?.response ? await options.response(request) : await this.configRef.valueOf.response(request),
options?.remoteIp ? await options.remoteIp(request) : await this.configRef.valueOf.remoteIp && this.configRef.valueOf.remoteIp(request),
]);

const score = options?.score || this.options.score;
const score = options?.score || this.configRef.valueOf.score;
const action = options?.action;

const validator = this.validatorResolver.resolve();

request.recaptchaValidationResult = await validator.validate({ response, remoteIp, score, action });

if (this.options.debug) {
if (this.configRef.valueOf.debug) {
const loggerCtx = this.resolveLogContext(validator);
this.logger.debug(request.recaptchaValidationResult.toObject(), `${loggerCtx}.result`);
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export { GoogleRecaptchaValidator } from './services/validators/google-recaptcha
export { GoogleRecaptchaEnterpriseValidator } from './services/validators/google-recaptcha-enterprise.validator';
export { RecaptchaVerificationResult } from './models/recaptcha-verification-result';
export { ClassificationReason } from './enums/classification-reason';
export { RecaptchaConfigRef } from './models/recaptcha-config-ref';
4 changes: 2 additions & 2 deletions src/interfaces/google-recaptcha-guard-options.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { RecaptchaRemoteIpProvider, RecaptchaResponseProvider, ScoreValidator } from "../types";
import { RecaptchaRemoteIpProvider, RecaptchaResponseProvider, ScoreValidator, SkipIfValue } from '../types';

export interface GoogleRecaptchaGuardOptions {
response: RecaptchaResponseProvider;
remoteIp?: RecaptchaRemoteIpProvider;
skipIf?: boolean | (<Req = unknown>(request: Req) => boolean | Promise<boolean>);
skipIf?: SkipIfValue;
score?: ScoreValidator;
}
2 changes: 1 addition & 1 deletion src/interfaces/verify-response-decorator-options.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { RecaptchaRemoteIpProvider, RecaptchaResponseProvider, ScoreValidator } from "../types";
import { RecaptchaRemoteIpProvider, RecaptchaResponseProvider, ScoreValidator } from '../types';

export interface VerifyResponseDecoratorOptions {
response?: RecaptchaResponseProvider;
Expand Down
38 changes: 38 additions & 0 deletions src/models/recaptcha-config-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { GoogleRecaptchaModuleOptions } from '../interfaces/google-recaptcha-module-options';
import { GoogleRecaptchaEnterpriseOptions } from '../interfaces/google-recaptcha-enterprise-options';
import { ScoreValidator, SkipIfValue } from '../types';

export class RecaptchaConfigRef {
get valueOf(): GoogleRecaptchaModuleOptions {
return this.value;
}

constructor(private readonly value: GoogleRecaptchaModuleOptions) {
}

setSecretKey(secretKey: string): this {
this.value.secretKey = secretKey;
this.value.enterprise = undefined;

return this;
}

setEnterpriseOptions(options: GoogleRecaptchaEnterpriseOptions): this {
this.value.secretKey = undefined;
this.value.enterprise = options;

return this;
}

setScore(score: ScoreValidator): this {
this.value.score = score;

return this;
}

setSkipIf(skipIf: SkipIfValue): this {
this.value.skipIf = skipIf;

return this;
}
}
14 changes: 7 additions & 7 deletions src/services/recaptcha-validator.resolver.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import { Inject, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { AbstractGoogleRecaptchaValidator } from './validators/abstract-google-recaptcha-validator';
import { RECAPTCHA_OPTIONS } from '../provider.declarations';
import { GoogleRecaptchaModuleOptions } from '../interfaces/google-recaptcha-module-options';
import { GoogleRecaptchaValidator } from './validators/google-recaptcha.validator';
import { GoogleRecaptchaEnterpriseValidator } from './validators/google-recaptcha-enterprise.validator';
import { RecaptchaConfigRef } from '../models/recaptcha-config-ref';

@Injectable()
export class RecaptchaValidatorResolver {
constructor(
@Inject(RECAPTCHA_OPTIONS) private readonly options: GoogleRecaptchaModuleOptions,
private readonly configRef: RecaptchaConfigRef,
protected readonly googleRecaptchaValidator: GoogleRecaptchaValidator,
protected readonly googleRecaptchaEnterpriseValidator: GoogleRecaptchaEnterpriseValidator
protected readonly googleRecaptchaEnterpriseValidator: GoogleRecaptchaEnterpriseValidator,
) {}

resolve(): AbstractGoogleRecaptchaValidator<unknown> {
if (this.options.secretKey) {
const configValue = this.configRef.valueOf;
if (configValue.secretKey) {
return this.googleRecaptchaValidator;
}

if (Object.keys(this.options.enterprise || {}).length) {
if (Object.keys(configValue.enterprise || {}).length) {
return this.googleRecaptchaEnterpriseValidator;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { VerifyResponseOptions } from '../../interfaces/verify-response-decorator-options';
import { ScoreValidator } from '../../types';
import { GoogleRecaptchaModuleOptions } from '../../interfaces/google-recaptcha-module-options';
import { RecaptchaVerificationResult } from '../../models/recaptcha-verification-result';
import { RecaptchaConfigRef } from '../../models/recaptcha-config-ref';

export abstract class AbstractGoogleRecaptchaValidator<Res> {
protected constructor(protected readonly options: GoogleRecaptchaModuleOptions) {}
protected constructor(protected readonly options: RecaptchaConfigRef) {}

abstract validate(options: VerifyResponseOptions): Promise<RecaptchaVerificationResult<Res>>;

Expand All @@ -13,11 +13,11 @@ export abstract class AbstractGoogleRecaptchaValidator<Res> {
return options.action === action;
}

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

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

if (finalValidator) {
if (typeof finalValidator === 'function') {
Expand Down
20 changes: 10 additions & 10 deletions src/services/validators/google-recaptcha-enterprise.validator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { RECAPTCHA_AXIOS_INSTANCE, RECAPTCHA_LOGGER, RECAPTCHA_OPTIONS } from '../../provider.declarations';
import { GoogleRecaptchaModuleOptions } from '../../interfaces/google-recaptcha-module-options';
import { RECAPTCHA_AXIOS_INSTANCE, RECAPTCHA_LOGGER } from '../../provider.declarations';
import { VerifyResponseOptions } from '../../interfaces/verify-response-decorator-options';
import { AbstractGoogleRecaptchaValidator } from './abstract-google-recaptcha-validator';
import { RecaptchaVerificationResult } from '../../models/recaptcha-verification-result';
Expand All @@ -13,6 +12,7 @@ import { EnterpriseReasonTransformer } from '../enterprise-reason.transformer';
import { getErrorInfo } from '../../helpers/get-error-info';
import { AxiosInstance } from 'axios';
import { LiteralObject } from '../../interfaces/literal-object';
import { RecaptchaConfigRef } from '../../models/recaptcha-config-ref';

type VerifyResponse = [VerifyResponseEnterprise, LiteralObject];

Expand All @@ -23,10 +23,10 @@ export class GoogleRecaptchaEnterpriseValidator extends AbstractGoogleRecaptchaV
constructor(
@Inject(RECAPTCHA_AXIOS_INSTANCE) private readonly axios: AxiosInstance,
@Inject(RECAPTCHA_LOGGER) private readonly logger: Logger,
@Inject(RECAPTCHA_OPTIONS) options: GoogleRecaptchaModuleOptions,
configRef: RecaptchaConfigRef,
private readonly enterpriseReasonTransformer: EnterpriseReasonTransformer
) {
super(options);
super(configRef);
}

async validate(options: VerifyResponseOptions): Promise<RecaptchaVerificationResult<VerifyResponseEnterprise>> {
Expand Down Expand Up @@ -73,11 +73,11 @@ export class GoogleRecaptchaEnterpriseValidator extends AbstractGoogleRecaptchaV
}

private verifyResponse(response: string, expectedAction: string, remoteIp: string): Promise<VerifyResponse> {
const projectId = this.options.enterprise.projectId;
const projectId = this.options.valueOf.enterprise.projectId;
const body: { event: VerifyTokenEnterpriseEvent } = {
event: {
expectedAction,
siteKey: this.options.enterprise.siteKey,
siteKey: this.options.valueOf.enterprise.siteKey,
token: response,
userIpAddress: remoteIp,
},
Expand All @@ -88,25 +88,25 @@ export class GoogleRecaptchaEnterpriseValidator extends AbstractGoogleRecaptchaV
const config: axios.AxiosRequestConfig = {
headers: this.headers,
params: {
key: this.options.enterprise.apiKey,
key: this.options.valueOf.enterprise.apiKey,
},
};

if (this.options.debug) {
if (this.options.valueOf.debug) {
this.logger.debug({ body }, `${GoogleRecaptchaContext.GoogleRecaptchaEnterprise}.request`);
}

return this.axios.post<VerifyResponseEnterprise>(url, body, config)
.then((res) => res.data)
.then((data): VerifyResponse => {
if (this.options.debug) {
if (this.options.valueOf.debug) {
this.logger.debug(data, `${GoogleRecaptchaContext.GoogleRecaptchaEnterprise}.response`);
}

return [data, null];
})
.catch((err: axios.AxiosError): VerifyResponse => {
if (this.options.debug) {
if (this.options.valueOf.debug) {
this.logger.debug(getErrorInfo(err), `${GoogleRecaptchaContext.GoogleRecaptchaEnterprise}.error`);
}

Expand Down
Loading

0 comments on commit 589a80d

Please sign in to comment.