Skip to content

Commit b7b5cd2

Browse files
committed
feat: dynamic recaptcha configuration.
1 parent f514656 commit b7b5cd2

19 files changed

+280
-70
lines changed

.editorconfig

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ trim_trailing_whitespace = true
99

1010
[*.ts]
1111
quote_type = single
12-
ij_typescript_use_double_quotes = true
12+
ij_typescript_use_double_quotes = false
1313
ij_typescript_use_semicolon_after_statement = true
1414
max_line_length = 140
1515
ij_smart_tabs = true

README.md

+52
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ This package provides protection for endpoints using [reCAPTCHA](https://www.goo
2727
* [Graphql application](#usage-in-graphql-application)
2828
* [Validate in service](#validate-in-service)
2929
* [Validate in service (Enterprise)](#validate-in-service-enterprise)
30+
* [Dynamic Recaptcha configuration](#dynamic-recaptcha-configuration)
3031
* [Error handling](#error-handling)
3132
* [Contribution](#contribution)
3233
* [License](#license)
@@ -471,6 +472,57 @@ export class SomeService {
471472
}
472473
```
473474

475+
### Dynamic Recaptcha configuration
476+
The `RecaptchaConfigRef` class provides a convenient way to modify Recaptcha validation parameters within your application.
477+
This can be particularly useful in scenarios where the administration of Recaptcha is managed dynamically, such as by an administrator.
478+
The class exposes methods that allow the customization of various Recaptcha options.
479+
480+
481+
**RecaptchaConfigRef API:**
482+
483+
```typescript
484+
@Injectable()
485+
class RecaptchaConfigRef {
486+
// Sets the secret key for Recaptcha validation.
487+
setSecretKey(secretKey: string): this;
488+
489+
// Sets enterprise-specific options for Recaptcha validation
490+
setEnterpriseOptions(options: GoogleRecaptchaEnterpriseOptions): this;
491+
492+
// Sets the score threshold for Recaptcha validation.
493+
setScore(score: ScoreValidator): this;
494+
495+
// Sets conditions under which Recaptcha validation should be skipped.
496+
setSkipIf(skipIf: SkipIfValue): this;
497+
}
498+
```
499+
500+
**Usage example:**
501+
502+
```typescript
503+
@Injectable()
504+
export class RecaptchaAdminService implements OnApplicationBootstrap {
505+
constructor(private readonly recaptchaConfigRef: RecaptchaConfigRef) {
506+
}
507+
508+
async onApplicationBootstrap(): Promise<void> {
509+
// TODO: Pull recaptcha configs from your database
510+
511+
this.recaptchaConfigRef
512+
.setSecretKey('SECRET_KEY_VALUE')
513+
.setScore(0.3);
514+
}
515+
516+
async updateSecretKey(secretKey: string): Promise<void> {
517+
// TODO: Save new secret key to your database
518+
519+
this.recaptchaConfigRef.setSecretKey(secretKey);
520+
}
521+
}
522+
```
523+
524+
After call `this.recaptchaConfigRef.setSecretKey(...)` - `@Recaptcha` guard and `GoogleRecaptchaValidator` will use new secret key.
525+
474526
### Error handling
475527

476528
**GoogleRecaptchaException**

src/google-recaptcha.module.ts

+10
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Agent } from 'https';
1515
import { RecaptchaValidatorResolver } from './services/recaptcha-validator.resolver';
1616
import { EnterpriseReasonTransformer } from './services/enterprise-reason.transformer';
1717
import { xor } from './helpers/xor';
18+
import { RecaptchaConfigRef } from './models/recaptcha-config-ref';
1819

1920
export class GoogleRecaptchaModule {
2021
private static axiosDefaultConfig: AxiosRequestConfig = {
@@ -39,6 +40,10 @@ export class GoogleRecaptchaModule {
3940
provide: RECAPTCHA_LOGGER,
4041
useFactory: () => options.logger || new Logger(),
4142
},
43+
{
44+
provide: RecaptchaConfigRef,
45+
useFactory: () => new RecaptchaConfigRef(options),
46+
},
4247
];
4348

4449
this.validateOptions(options);
@@ -72,6 +77,11 @@ export class GoogleRecaptchaModule {
7277
useFactory: (options: GoogleRecaptchaModuleOptions) => options.logger || new Logger(),
7378
inject: [RECAPTCHA_OPTIONS],
7479
},
80+
{
81+
provide: RecaptchaConfigRef,
82+
useFactory: (opts: GoogleRecaptchaModuleOptions) => new RecaptchaConfigRef(opts),
83+
inject: [RECAPTCHA_OPTIONS],
84+
},
7585
GoogleRecaptchaGuard,
7686
GoogleRecaptchaValidator,
7787
GoogleRecaptchaEnterpriseValidator,

src/guards/google-recaptcha.guard.ts

+9-8
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import { CanActivate, ExecutionContext, Inject, Injectable, Logger } from '@nestjs/common';
2-
import { RECAPTCHA_LOGGER, RECAPTCHA_OPTIONS, RECAPTCHA_VALIDATION_OPTIONS } from '../provider.declarations';
2+
import { RECAPTCHA_LOGGER, RECAPTCHA_VALIDATION_OPTIONS } from '../provider.declarations';
33
import { GoogleRecaptchaException } from '../exceptions/google-recaptcha.exception';
44
import { Reflector } from '@nestjs/core';
55
import { RecaptchaRequestResolver } from '../services/recaptcha-request.resolver';
66
import { VerifyResponseDecoratorOptions } from '../interfaces/verify-response-decorator-options';
7-
import { GoogleRecaptchaModuleOptions } from '../interfaces/google-recaptcha-module-options';
87
import { RecaptchaValidatorResolver } from '../services/recaptcha-validator.resolver';
98
import { GoogleRecaptchaContext } from '../enums/google-recaptcha-context';
109
import { AbstractGoogleRecaptchaValidator } from '../services/validators/abstract-google-recaptcha-validator';
1110
import { GoogleRecaptchaEnterpriseValidator } from '../services/validators/google-recaptcha-enterprise.validator';
1211
import { LiteralObject } from '../interfaces/literal-object';
12+
import { RecaptchaConfigRef } from '../models/recaptcha-config-ref';
1313

1414
@Injectable()
1515
export class GoogleRecaptchaGuard implements CanActivate {
@@ -18,13 +18,14 @@ export class GoogleRecaptchaGuard implements CanActivate {
1818
private readonly requestResolver: RecaptchaRequestResolver,
1919
private readonly validatorResolver: RecaptchaValidatorResolver,
2020
@Inject(RECAPTCHA_LOGGER) private readonly logger: Logger,
21-
@Inject(RECAPTCHA_OPTIONS) private readonly options: GoogleRecaptchaModuleOptions
21+
private readonly configRef: RecaptchaConfigRef,
2222
) {}
2323

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

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

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

3536
const [response, remoteIp] = await Promise.all([
36-
options?.response ? await options.response(request) : await this.options.response(request),
37-
options?.remoteIp ? await options.remoteIp(request) : await this.options.remoteIp && this.options.remoteIp(request),
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),
3839
]);
3940

40-
const score = options?.score || this.options.score;
41+
const score = options?.score || this.configRef.valueOf.score;
4142
const action = options?.action;
4243

4344
const validator = this.validatorResolver.resolve();
4445

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

47-
if (this.options.debug) {
48+
if (this.configRef.valueOf.debug) {
4849
const loggerCtx = this.resolveLogContext(validator);
4950
this.logger.debug(request.recaptchaValidationResult.toObject(), `${loggerCtx}.result`);
5051
}

src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export { GoogleRecaptchaValidator } from './services/validators/google-recaptcha
1212
export { GoogleRecaptchaEnterpriseValidator } from './services/validators/google-recaptcha-enterprise.validator';
1313
export { RecaptchaVerificationResult } from './models/recaptcha-verification-result';
1414
export { ClassificationReason } from './enums/classification-reason';
15+
export { RecaptchaConfigRef } from './models/recaptcha-config-ref';
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { RecaptchaRemoteIpProvider, RecaptchaResponseProvider, ScoreValidator } from "../types";
1+
import { RecaptchaRemoteIpProvider, RecaptchaResponseProvider, ScoreValidator, SkipIfValue } from '../types';
22

33
export interface GoogleRecaptchaGuardOptions {
44
response: RecaptchaResponseProvider;
55
remoteIp?: RecaptchaRemoteIpProvider;
6-
skipIf?: boolean | (<Req = unknown>(request: Req) => boolean | Promise<boolean>);
6+
skipIf?: SkipIfValue;
77
score?: ScoreValidator;
88
}

src/interfaces/verify-response-decorator-options.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { RecaptchaRemoteIpProvider, RecaptchaResponseProvider, ScoreValidator } from "../types";
1+
import { RecaptchaRemoteIpProvider, RecaptchaResponseProvider, ScoreValidator } from '../types';
22

33
export interface VerifyResponseDecoratorOptions {
44
response?: RecaptchaResponseProvider;

src/models/recaptcha-config-ref.ts

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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+
}

src/services/recaptcha-validator.resolver.ts

+7-7
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,24 @@
1-
import { Inject, Injectable } from '@nestjs/common';
1+
import { Injectable } from '@nestjs/common';
22
import { AbstractGoogleRecaptchaValidator } from './validators/abstract-google-recaptcha-validator';
3-
import { RECAPTCHA_OPTIONS } from '../provider.declarations';
4-
import { GoogleRecaptchaModuleOptions } from '../interfaces/google-recaptcha-module-options';
53
import { GoogleRecaptchaValidator } from './validators/google-recaptcha.validator';
64
import { GoogleRecaptchaEnterpriseValidator } from './validators/google-recaptcha-enterprise.validator';
5+
import { RecaptchaConfigRef } from '../models/recaptcha-config-ref';
76

87
@Injectable()
98
export class RecaptchaValidatorResolver {
109
constructor(
11-
@Inject(RECAPTCHA_OPTIONS) private readonly options: GoogleRecaptchaModuleOptions,
10+
private readonly configRef: RecaptchaConfigRef,
1211
protected readonly googleRecaptchaValidator: GoogleRecaptchaValidator,
13-
protected readonly googleRecaptchaEnterpriseValidator: GoogleRecaptchaEnterpriseValidator
12+
protected readonly googleRecaptchaEnterpriseValidator: GoogleRecaptchaEnterpriseValidator,
1413
) {}
1514

1615
resolve(): AbstractGoogleRecaptchaValidator<unknown> {
17-
if (this.options.secretKey) {
16+
const configValue = this.configRef.valueOf;
17+
if (configValue.secretKey) {
1818
return this.googleRecaptchaValidator;
1919
}
2020

21-
if (Object.keys(this.options.enterprise || {}).length) {
21+
if (Object.keys(configValue.enterprise || {}).length) {
2222
return this.googleRecaptchaEnterpriseValidator;
2323
}
2424

src/services/validators/abstract-google-recaptcha-validator.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { VerifyResponseOptions } from '../../interfaces/verify-response-decorator-options';
22
import { ScoreValidator } from '../../types';
3-
import { GoogleRecaptchaModuleOptions } from '../../interfaces/google-recaptcha-module-options';
43
import { RecaptchaVerificationResult } from '../../models/recaptcha-verification-result';
4+
import { RecaptchaConfigRef } from '../../models/recaptcha-config-ref';
55

66
export abstract class AbstractGoogleRecaptchaValidator<Res> {
7-
protected constructor(protected readonly options: GoogleRecaptchaModuleOptions) {}
7+
protected constructor(protected readonly options: RecaptchaConfigRef) {}
88

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

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

16-
return this.options.actions ? this.options.actions.includes(action) : true;
16+
return this.options.valueOf.actions ? this.options.valueOf.actions.includes(action) : true;
1717
}
1818

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

2222
if (finalValidator) {
2323
if (typeof finalValidator === 'function') {

src/services/validators/google-recaptcha-enterprise.validator.ts

+10-10
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Inject, Injectable, Logger } from '@nestjs/common';
2-
import { RECAPTCHA_AXIOS_INSTANCE, RECAPTCHA_LOGGER, RECAPTCHA_OPTIONS } from '../../provider.declarations';
3-
import { GoogleRecaptchaModuleOptions } from '../../interfaces/google-recaptcha-module-options';
2+
import { RECAPTCHA_AXIOS_INSTANCE, RECAPTCHA_LOGGER } from '../../provider.declarations';
43
import { VerifyResponseOptions } from '../../interfaces/verify-response-decorator-options';
54
import { AbstractGoogleRecaptchaValidator } from './abstract-google-recaptcha-validator';
65
import { RecaptchaVerificationResult } from '../../models/recaptcha-verification-result';
@@ -13,6 +12,7 @@ import { EnterpriseReasonTransformer } from '../enterprise-reason.transformer';
1312
import { getErrorInfo } from '../../helpers/get-error-info';
1413
import { AxiosInstance } from 'axios';
1514
import { LiteralObject } from '../../interfaces/literal-object';
15+
import { RecaptchaConfigRef } from '../../models/recaptcha-config-ref';
1616

1717
type VerifyResponse = [VerifyResponseEnterprise, LiteralObject];
1818

@@ -23,10 +23,10 @@ export class GoogleRecaptchaEnterpriseValidator extends AbstractGoogleRecaptchaV
2323
constructor(
2424
@Inject(RECAPTCHA_AXIOS_INSTANCE) private readonly axios: AxiosInstance,
2525
@Inject(RECAPTCHA_LOGGER) private readonly logger: Logger,
26-
@Inject(RECAPTCHA_OPTIONS) options: GoogleRecaptchaModuleOptions,
26+
configRef: RecaptchaConfigRef,
2727
private readonly enterpriseReasonTransformer: EnterpriseReasonTransformer
2828
) {
29-
super(options);
29+
super(configRef);
3030
}
3131

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

7575
private verifyResponse(response: string, expectedAction: string, remoteIp: string): Promise<VerifyResponse> {
76-
const projectId = this.options.enterprise.projectId;
76+
const projectId = this.options.valueOf.enterprise.projectId;
7777
const body: { event: VerifyTokenEnterpriseEvent } = {
7878
event: {
7979
expectedAction,
80-
siteKey: this.options.enterprise.siteKey,
80+
siteKey: this.options.valueOf.enterprise.siteKey,
8181
token: response,
8282
userIpAddress: remoteIp,
8383
},
@@ -88,25 +88,25 @@ export class GoogleRecaptchaEnterpriseValidator extends AbstractGoogleRecaptchaV
8888
const config: axios.AxiosRequestConfig = {
8989
headers: this.headers,
9090
params: {
91-
key: this.options.enterprise.apiKey,
91+
key: this.options.valueOf.enterprise.apiKey,
9292
},
9393
};
9494

95-
if (this.options.debug) {
95+
if (this.options.valueOf.debug) {
9696
this.logger.debug({ body }, `${GoogleRecaptchaContext.GoogleRecaptchaEnterprise}.request`);
9797
}
9898

9999
return this.axios.post<VerifyResponseEnterprise>(url, body, config)
100100
.then((res) => res.data)
101101
.then((data): VerifyResponse => {
102-
if (this.options.debug) {
102+
if (this.options.valueOf.debug) {
103103
this.logger.debug(data, `${GoogleRecaptchaContext.GoogleRecaptchaEnterprise}.response`);
104104
}
105105

106106
return [data, null];
107107
})
108108
.catch((err: axios.AxiosError): VerifyResponse => {
109-
if (this.options.debug) {
109+
if (this.options.valueOf.debug) {
110110
this.logger.debug(getErrorInfo(err), `${GoogleRecaptchaContext.GoogleRecaptchaEnterprise}.error`);
111111
}
112112

0 commit comments

Comments
 (0)