GH-3546 Recaptcha on login form (#4626)

## Description

This PR adds recaptcha on login form. One can add any one of three
recaptcha vendor -
1. Google Recaptcha -
https://developers.google.com/recaptcha/docs/v3#programmatically_invoke_the_challenge
2. HCaptcha -
https://docs.hcaptcha.com/invisible#programmatically-invoke-the-challenge
3. Turnstile -
https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#execution-modes

### Issue
- #3546 

### Environment variables - 
1. `CAPTCHA_DRIVER` - `google-recaptcha` | `hcaptcha` | `turnstile`
2. `CAPTCHA_SITE_KEY` - site key
3. `CAPTCHA_SECRET_KEY` - secret key

### Engineering choices
1. If some of the above env variable provided, then, backend generates
an error -
<img width="990" alt="image"
src="https://github.com/twentyhq/twenty/assets/60139930/9fb00fab-9261-4ff3-b23e-2c2e06f1bf89">
    Please note that login/signup form will keep working as expected.
2. I'm using a Captcha guard that intercepts the request. If
"captchaToken" is present in the body and all env is set, then, the
captcha token is verified by backend through the service.
3. One can use this guard on any resolver to protect it by the captcha.
4. On frontend, two hooks `useGenerateCaptchaToken` and
`useInsertCaptchaScript` is created. `useInsertCaptchaScript` adds the
respective captcha JS script on frontend. `useGenerateCaptchaToken`
returns a function that one can use to trigger captcha token generation
programatically. This allows one to generate token keeping recaptcha
invisible.

### Note
This PR contains some changes in unrelated files like indentation,
spacing, inverted comma etc. I ran "yarn nx fmt:fix twenty-front" and
"yarn nx lint twenty-front -- --fix".

### Screenshots

<img width="869" alt="image"
src="https://github.com/twentyhq/twenty/assets/60139930/a75f5677-9b66-47f7-9730-4ec916073f8c">

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Deepak Kumar
2024-04-26 03:22:28 +05:30
committed by GitHub
parent 44855f0317
commit dc576d0818
46 changed files with 737 additions and 71 deletions
@@ -1,10 +1,12 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { CanActivate } from '@nestjs/common';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { CaptchaGuard } from 'src/engine/integrations/captcha/captcha.guard';
import { AuthResolver } from './auth.resolver';
@@ -13,6 +15,7 @@ import { AuthService } from './services/auth.service';
describe('AuthResolver', () => {
let resolver: AuthResolver;
const mock_CaptchaGuard: CanActivate = { canActivate: jest.fn(() => true) };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -43,7 +46,10 @@ describe('AuthResolver', () => {
useValue: {},
},
],
}).compile();
})
.overrideGuard(CaptchaGuard)
.useValue(mock_CaptchaGuard)
.compile();
resolver = module.get<AuthResolver>(AuthResolver);
});
@@ -32,6 +32,7 @@ import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.ent
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
import { CaptchaGuard } from 'src/engine/integrations/captcha/captcha.guard';
import { ApiKeyToken, AuthTokens } from './dto/token.entity';
import { TokenService } from './services/token.service';
@@ -58,6 +59,7 @@ export class AuthResolver {
private userWorkspaceService: UserWorkspaceService,
) {}
@UseGuards(CaptchaGuard)
@Query(() => UserExists)
async checkUserExists(
@Args() checkUserExistsInput: CheckUserExistsInput,
@@ -87,6 +89,7 @@ export class AuthResolver {
});
}
@UseGuards(CaptchaGuard)
@Mutation(() => LoginToken)
async challenge(@Args() challengeInput: ChallengeInput): Promise<LoginToken> {
const user = await this.authService.challenge(challengeInput);
@@ -95,6 +98,7 @@ export class AuthResolver {
return { loginToken };
}
@UseGuards(CaptchaGuard)
@Mutation(() => LoginToken)
async signUp(@Args() signUpInput: SignUpInput): Promise<LoginToken> {
const user = await this.authService.signInUp({
@@ -1,6 +1,6 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
@ArgsType()
export class ChallengeInput {
@@ -13,4 +13,9 @@ export class ChallengeInput {
@IsNotEmpty()
@IsString()
password: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
captchaToken?: string;
}
@@ -18,4 +18,9 @@ export class SignUpInput {
@IsString()
@IsOptional()
workspaceInviteHash?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
captchaToken?: string;
}
@@ -1,6 +1,6 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
@ArgsType()
export class CheckUserExistsInput {
@@ -8,4 +8,9 @@ export class CheckUserExistsInput {
@IsString()
@IsNotEmpty()
email: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
captchaToken?: string;
}
@@ -1,5 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { CaptchaDriverType } from 'src/engine/integrations/captcha/interfaces';
@ObjectType()
class AuthProviders {
@Field(() => Boolean)
@@ -57,6 +59,15 @@ class Sentry {
dsn?: string;
}
@ObjectType()
class Captcha {
@Field(() => CaptchaDriverType, { nullable: true })
provider: CaptchaDriverType | undefined;
@Field(() => String, { nullable: true })
siteKey: string | undefined;
}
@ObjectType()
export class ClientConfig {
@Field(() => AuthProviders, { nullable: false })
@@ -82,4 +93,7 @@ export class ClientConfig {
@Field(() => Sentry)
sentry: Sentry;
@Field(() => Captcha)
captcha: Captcha;
}
@@ -44,6 +44,10 @@ export class ClientConfigResolver {
release: this.environmentService.get('SENTRY_RELEASE'),
dsn: this.environmentService.get('SENTRY_FRONT_DSN'),
},
captcha: {
provider: this.environmentService.get('CAPTCHA_DRIVER'),
siteKey: this.environmentService.get('CAPTCHA_SITE_KEY'),
},
};
return Promise.resolve(clientConfig);
@@ -0,0 +1 @@
export const CAPTCHA_DRIVER = Symbol('CAPTCHA_DRIVER');
@@ -0,0 +1,28 @@
import {
BadRequestException,
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { CaptchaService } from 'src/engine/integrations/captcha/captcha.service';
@Injectable()
export class CaptchaGuard implements CanActivate {
constructor(private captchaService: CaptchaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const ctx = GqlExecutionContext.create(context);
const { captchaToken: token } = ctx.getArgs();
const result = await this.captchaService.validate(token || '');
if (result.success) return true;
else
throw new BadRequestException(
'Invalid Captcha, please try another device',
);
}
}
@@ -0,0 +1,31 @@
import {
CaptchaDriverOptions,
CaptchaModuleOptions,
} from 'src/engine/integrations/captcha/interfaces';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
export const captchaModuleFactory = (
environmentService: EnvironmentService,
): CaptchaModuleOptions | undefined => {
const driver = environmentService.get('CAPTCHA_DRIVER');
const siteKey = environmentService.get('CAPTCHA_SITE_KEY');
const secretKey = environmentService.get('CAPTCHA_SECRET_KEY');
if (!driver) {
return;
}
if (!siteKey || !secretKey) {
throw new Error('Captcha driver requires site key and secret key');
}
const captchaOptions: CaptchaDriverOptions = {
siteKey,
secretKey,
};
return {
type: driver,
options: captchaOptions,
};
};
@@ -0,0 +1,42 @@
import { DynamicModule, Global } from '@nestjs/common';
import { CAPTCHA_DRIVER } from 'src/engine/integrations/captcha/captcha.constants';
import { CaptchaService } from 'src/engine/integrations/captcha/captcha.service';
import { GoogleRecaptchaDriver } from 'src/engine/integrations/captcha/drivers/google-recaptcha.driver';
import { TurnstileDriver } from 'src/engine/integrations/captcha/drivers/turnstile.driver';
import {
CaptchaDriverType,
CaptchaModuleAsyncOptions,
} from 'src/engine/integrations/captcha/interfaces';
@Global()
export class CaptchaModule {
static forRoot(options: CaptchaModuleAsyncOptions): DynamicModule {
const provider = {
provide: CAPTCHA_DRIVER,
useFactory: async (...args: any[]) => {
const config = await options.useFactory(...args);
if (!config) {
return;
}
switch (config.type) {
case CaptchaDriverType.GoogleRecatpcha:
return new GoogleRecaptchaDriver(config.options);
case CaptchaDriverType.Turnstile:
return new TurnstileDriver(config.options);
default:
return;
}
},
inject: options.inject || [],
};
return {
module: CaptchaModule,
providers: [CaptchaService, provider],
exports: [CaptchaService],
};
}
}
@@ -0,0 +1,21 @@
import { Inject, Injectable } from '@nestjs/common';
import { CaptchaDriver } from 'src/engine/integrations/captcha/drivers/interfaces/captcha-driver.interface';
import { CAPTCHA_DRIVER } from 'src/engine/integrations/captcha/captcha.constants';
import { CaptchaValidateResult } from 'src/engine/integrations/captcha/interfaces';
@Injectable()
export class CaptchaService implements CaptchaDriver {
constructor(@Inject(CAPTCHA_DRIVER) private driver: CaptchaDriver) {}
async validate(token: string): Promise<CaptchaValidateResult> {
if (this.driver) {
return await this.driver.validate(token);
} else {
return {
success: true,
};
}
}
}
@@ -0,0 +1,39 @@
import axios, { AxiosInstance } from 'axios';
import { CaptchaDriver } from 'src/engine/integrations/captcha/drivers/interfaces/captcha-driver.interface';
import { CaptchaServerResponse } from 'src/engine/integrations/captcha/drivers/interfaces/captcha-server-response';
import {
CaptchaDriverOptions,
CaptchaValidateResult,
} from 'src/engine/integrations/captcha/interfaces';
export class GoogleRecaptchaDriver implements CaptchaDriver {
private readonly siteKey: string;
private readonly secretKey: string;
private readonly httpService: AxiosInstance;
constructor(private options: CaptchaDriverOptions) {
this.siteKey = options.siteKey;
this.secretKey = options.secretKey;
this.httpService = axios.create({
baseURL: 'https://www.google.com/recaptcha/api/siteverify',
});
}
async validate(token: string): Promise<CaptchaValidateResult> {
const formData = new URLSearchParams({
secret: this.secretKey,
response: token,
});
const response = await this.httpService.post('', formData);
const responseData = response.data as CaptchaServerResponse;
return {
success: responseData.success,
...(!responseData.success && {
error: responseData['error-codes']?.[0] ?? 'Captcha Error',
}),
};
}
}
@@ -0,0 +1,5 @@
import { CaptchaValidateResult } from 'src/engine/integrations/captcha/interfaces';
export interface CaptchaDriver {
validate(token: string): Promise<CaptchaValidateResult>;
}
@@ -0,0 +1,6 @@
export type CaptchaServerResponse = {
success: boolean;
challenge_ts: string;
hostname: string;
'error-codes': string[];
};
@@ -0,0 +1,39 @@
import axios, { AxiosInstance } from 'axios';
import { CaptchaDriver } from 'src/engine/integrations/captcha/drivers/interfaces/captcha-driver.interface';
import { CaptchaServerResponse } from 'src/engine/integrations/captcha/drivers/interfaces/captcha-server-response';
import {
CaptchaDriverOptions,
CaptchaValidateResult,
} from 'src/engine/integrations/captcha/interfaces';
export class TurnstileDriver implements CaptchaDriver {
private readonly siteKey: string;
private readonly secretKey: string;
private readonly httpService: AxiosInstance;
constructor(private options: CaptchaDriverOptions) {
this.siteKey = options.siteKey;
this.secretKey = options.secretKey;
this.httpService = axios.create({
baseURL: 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
});
}
async validate(token: string): Promise<CaptchaValidateResult> {
const formData = new URLSearchParams({
secret: this.secretKey,
response: token,
});
const response = await this.httpService.post('', formData);
const responseData = response.data as CaptchaServerResponse;
return {
success: responseData.success,
...(!responseData.success && {
error: responseData['error-codes']?.[0] ?? 'Captcha Error',
}),
};
}
}
@@ -0,0 +1,39 @@
import { FactoryProvider, ModuleMetadata } from '@nestjs/common';
import { registerEnumType } from '@nestjs/graphql';
export enum CaptchaDriverType {
GoogleRecatpcha = 'google-recaptcha',
Turnstile = 'turnstile',
}
registerEnumType(CaptchaDriverType, {
name: 'CaptchaDriverType',
});
export type CaptchaDriverOptions = {
siteKey: string;
secretKey: string;
};
export interface GoogleRecatpchaDriverFactoryOptions {
type: CaptchaDriverType.GoogleRecatpcha;
options: CaptchaDriverOptions;
}
export interface TurnstileDriverFactoryOptions {
type: CaptchaDriverType.Turnstile;
options: CaptchaDriverOptions;
}
export type CaptchaModuleOptions =
| GoogleRecatpchaDriverFactoryOptions
| TurnstileDriverFactoryOptions;
export type CaptchaModuleAsyncOptions = {
useFactory: (
...args: any[]
) => CaptchaModuleOptions | Promise<CaptchaModuleOptions> | undefined;
} & Pick<ModuleMetadata, 'imports'> &
Pick<FactoryProvider, 'inject'>;
export type CaptchaValidateResult = { success: boolean; error?: string };
@@ -0,0 +1 @@
export * from './captcha.interface';
@@ -24,6 +24,7 @@ import { ExceptionHandlerDriver } from 'src/engine/integrations/exception-handle
import { StorageDriverType } from 'src/engine/integrations/file-storage/interfaces';
import { LoggerDriverType } from 'src/engine/integrations/logger/interfaces';
import { IsStrictlyLowerThan } from 'src/engine/integrations/environment/decorators/is-strictly-lower-than.decorator';
import { CaptchaDriverType } from 'src/engine/integrations/captcha/interfaces';
import { MessageQueueDriverType } from 'src/engine/integrations/message-queue/interfaces';
import { IsDuration } from './decorators/is-duration.decorator';
@@ -312,6 +313,18 @@ export class EnvironmentVariables {
@IsBoolean()
IS_SIGN_UP_DISABLED = false;
@IsEnum(CaptchaDriverType)
@IsOptional()
CAPTCHA_DRIVER?: CaptchaDriverType;
@IsString()
@IsOptional()
CAPTCHA_SITE_KEY?: string;
@IsString()
@IsOptional()
CAPTCHA_SECRET_KEY?: string;
@CastToPositiveNumber()
@IsOptional()
@IsNumber()
@@ -10,6 +10,8 @@ import { messageQueueModuleFactory } from 'src/engine/integrations/message-queue
import { EmailModule } from 'src/engine/integrations/email/email.module';
import { emailModuleFactory } from 'src/engine/integrations/email/email.module-factory';
import { CacheStorageModule } from 'src/engine/integrations/cache-storage/cache-storage.module';
import { CaptchaModule } from 'src/engine/integrations/captcha/captcha.module';
import { captchaModuleFactory } from 'src/engine/integrations/captcha/captcha.module-factory';
import { EnvironmentModule } from './environment/environment.module';
import { EnvironmentService } from './environment/environment.service';
@@ -40,6 +42,10 @@ import { MessageQueueModule } from './message-queue/message-queue.module';
useFactory: emailModuleFactory,
inject: [EnvironmentService],
}),
CaptchaModule.forRoot({
useFactory: captchaModuleFactory,
inject: [EnvironmentService],
}),
EventEmitterModule.forRoot({
wildcard: true,
}),