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:
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
import { CaptchaProviderScriptLoaderEffect } from '@/captcha/components/CaptchaProviderScriptLoaderEffect';
|
||||
|
||||
export const CaptchaProvider = ({ children }: React.PropsWithChildren) => {
|
||||
return (
|
||||
<>
|
||||
<div id="captcha-widget" data-size="invisible"></div>
|
||||
<CaptchaProviderScriptLoaderEffect />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,52 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
|
||||
import { getCaptchaUrlByProvider } from '@/captcha/utils/getCaptchaUrlByProvider';
|
||||
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
|
||||
import { CaptchaDriverType } from '~/generated/graphql';
|
||||
|
||||
export const CaptchaProviderScriptLoaderEffect = () => {
|
||||
const captchaProvider = useRecoilValue(captchaProviderState);
|
||||
const setIsCaptchaScriptLoaded = useSetRecoilState(
|
||||
isCaptchaScriptLoadedState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!captchaProvider?.provider || !captchaProvider.siteKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptUrl = getCaptchaUrlByProvider(
|
||||
captchaProvider.provider,
|
||||
captchaProvider.siteKey,
|
||||
);
|
||||
if (!scriptUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
let scriptElement: HTMLScriptElement | null = document.querySelector(
|
||||
`script[src="${scriptUrl}"]`,
|
||||
);
|
||||
if (!scriptElement) {
|
||||
scriptElement = document.createElement('script');
|
||||
scriptElement.src = scriptUrl;
|
||||
scriptElement.onload = () => {
|
||||
if (captchaProvider.provider === CaptchaDriverType.GoogleRecatpcha) {
|
||||
window.grecaptcha?.ready(() => {
|
||||
setIsCaptchaScriptLoaded(true);
|
||||
});
|
||||
} else {
|
||||
setIsCaptchaScriptLoaded(true);
|
||||
}
|
||||
};
|
||||
document.body.appendChild(scriptElement);
|
||||
}
|
||||
}, [
|
||||
captchaProvider?.provider,
|
||||
captchaProvider?.siteKey,
|
||||
setIsCaptchaScriptLoaded,
|
||||
]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
import { captchaTokenState } from '@/captcha/states/captchaTokenState';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
export const useReadCaptchaToken = () => {
|
||||
const readCaptchaToken = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
async () => {
|
||||
const existingCaptchaToken = snapshot
|
||||
.getLoadable(captchaTokenState)
|
||||
.getValue();
|
||||
|
||||
if (isDefined(existingCaptchaToken)) {
|
||||
return existingCaptchaToken;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { readCaptchaToken };
|
||||
};
|
||||
@ -0,0 +1,77 @@
|
||||
import { useRecoilCallback, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { captchaTokenState } from '@/captcha/states/captchaTokenState';
|
||||
import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
|
||||
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
|
||||
import { CaptchaDriverType } from '~/generated-metadata/graphql';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
grecaptcha?: any;
|
||||
turnstile?: any;
|
||||
}
|
||||
}
|
||||
|
||||
export const useRequestFreshCaptchaToken = () => {
|
||||
const setCaptchaToken = useSetRecoilState(captchaTokenState);
|
||||
const setIsRequestingCaptchaToken = useSetRecoilState(
|
||||
isRequestingCaptchaTokenState,
|
||||
);
|
||||
|
||||
const requestFreshCaptchaToken = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
async () => {
|
||||
const captchaProvider = snapshot
|
||||
.getLoadable(captchaProviderState)
|
||||
.getValue();
|
||||
|
||||
if (isUndefinedOrNull(captchaProvider)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingCaptchaToken = snapshot
|
||||
.getLoadable(captchaTokenState)
|
||||
.getValue();
|
||||
|
||||
setIsRequestingCaptchaToken(true);
|
||||
|
||||
let captchaWidget: any;
|
||||
|
||||
switch (captchaProvider.provider) {
|
||||
case CaptchaDriverType.GoogleRecatpcha:
|
||||
window.grecaptcha
|
||||
.execute(captchaProvider.siteKey, {
|
||||
action: 'submit',
|
||||
})
|
||||
.then((token: string) => {
|
||||
setCaptchaToken(token);
|
||||
setIsRequestingCaptchaToken(false);
|
||||
});
|
||||
break;
|
||||
case CaptchaDriverType.Turnstile:
|
||||
if (isDefined(existingCaptchaToken)) {
|
||||
// If we already have a token, we don't need to request a new one as turnstile will
|
||||
// automatically refresh the token when the widget is rendered.
|
||||
setIsRequestingCaptchaToken(false);
|
||||
break;
|
||||
}
|
||||
// TODO: fix workspace-no-hardcoded-colors rule
|
||||
// eslint-disable-next-line @nx/workspace-no-hardcoded-colors
|
||||
captchaWidget = window.turnstile.render('#captcha-widget', {
|
||||
sitekey: captchaProvider.siteKey,
|
||||
});
|
||||
window.turnstile.execute(captchaWidget, {
|
||||
callback: (token: string) => {
|
||||
setCaptchaToken(token);
|
||||
setIsRequestingCaptchaToken(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[setCaptchaToken, setIsRequestingCaptchaToken],
|
||||
);
|
||||
|
||||
return { requestFreshCaptchaToken };
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const captchaTokenState = createState<string | undefined>({
|
||||
key: 'captchaTokenState',
|
||||
defaultValue: undefined,
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const isCaptchaScriptLoadedState = createState<boolean>({
|
||||
key: 'isCaptchaScriptLoadedState',
|
||||
defaultValue: false,
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const isRequestingCaptchaTokenState = createState<boolean>({
|
||||
key: 'isRequestingCaptchaTokenState',
|
||||
defaultValue: false,
|
||||
});
|
||||
@ -0,0 +1,16 @@
|
||||
import { CaptchaDriverType } from '~/generated-metadata/graphql';
|
||||
|
||||
export const getCaptchaUrlByProvider = (name: string, siteKey: string) => {
|
||||
if (!name) {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case CaptchaDriverType.GoogleRecatpcha:
|
||||
return `https://www.google.com/recaptcha/api.js?render=${siteKey}`;
|
||||
case CaptchaDriverType.Turnstile:
|
||||
return 'https://challenges.cloudflare.com/turnstile/v0/api.js';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user