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,8 +1,12 @@
import { gql } from '@apollo/client';
export const CHALLENGE = gql`
mutation Challenge($email: String!, $password: String!) {
challenge(email: $email, password: $password) {
mutation Challenge(
$email: String!
$password: String!
$captchaToken: String
) {
challenge(email: $email, password: $password, captchaToken: $captchaToken) {
loginToken {
...AuthTokenFragment
}
@@ -5,11 +5,13 @@ export const SIGN_UP = gql`
$email: String!
$password: String!
$workspaceInviteHash: String
$captchaToken: String
) {
signUp(
email: $email
password: $password
workspaceInviteHash: $workspaceInviteHash
captchaToken: $captchaToken
) {
loginToken {
...AuthTokenFragment
@@ -1,8 +1,8 @@
import { gql } from '@apollo/client';
export const CHECK_USER_EXISTS = gql`
query CheckUserExists($email: String!) {
checkUserExists(email: $email) {
query CheckUserExists($email: String!, $captchaToken: String) {
checkUserExists(email: $email, captchaToken: $captchaToken) {
exists
}
}
@@ -16,6 +16,7 @@ import { isVerifyPendingState } from '@/auth/states/isVerifyPendingState';
import { workspacesState } from '@/auth/states/workspaces';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { billingState } from '@/client-config/states/billingState';
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
@@ -56,11 +57,12 @@ export const useAuth = () => {
const goToRecoilSnapshot = useGotoRecoilSnapshot();
const handleChallenge = useCallback(
async (email: string, password: string) => {
async (email: string, password: string, captchaToken?: string) => {
const challengeResult = await challenge({
variables: {
email,
password,
captchaToken,
},
});
@@ -133,8 +135,12 @@ export const useAuth = () => {
);
const handleCrendentialsSignIn = useCallback(
async (email: string, password: string) => {
const { loginToken } = await handleChallenge(email, password);
async (email: string, password: string, captchaToken?: string) => {
const { loginToken } = await handleChallenge(
email,
password,
captchaToken,
);
setIsVerifyPendingState(true);
const { user, workspaceMember, workspace } = await handleVerify(
@@ -167,6 +173,9 @@ export const useAuth = () => {
const supportChat = snapshot.getLoadable(supportChatState).getValue();
const telemetry = snapshot.getLoadable(telemetryState).getValue();
const isDebugMode = snapshot.getLoadable(isDebugModeState).getValue();
const captchaProvider = snapshot
.getLoadable(captchaProviderState)
.getValue();
const isClientConfigLoaded = snapshot
.getLoadable(isClientConfigLoadedState)
.getValue();
@@ -175,8 +184,6 @@ export const useAuth = () => {
.getValue();
const initialSnapshot = emptySnapshot.map(({ set }) => {
set(isClientConfigLoadedState, isClientConfigLoaded);
set(isCurrentUserLoadedState, isCurrentUserLoaded);
set(iconsState, iconsValue);
set(authProvidersState, authProvidersValue);
set(billingState, billing);
@@ -184,6 +191,9 @@ export const useAuth = () => {
set(supportChatState, supportChat);
set(telemetryState, telemetry);
set(isDebugModeState, isDebugMode);
set(captchaProviderState, captchaProvider);
set(isClientConfigLoadedState, isClientConfigLoaded);
set(isCurrentUserLoadedState, isCurrentUserLoaded);
return undefined;
});
@@ -196,7 +206,12 @@ export const useAuth = () => {
);
const handleCredentialsSignUp = useCallback(
async (email: string, password: string, workspaceInviteHash?: string) => {
async (
email: string,
password: string,
workspaceInviteHash?: string,
captchaToken?: string,
) => {
setIsVerifyPendingState(true);
const signUpResult = await signUp({
@@ -204,6 +219,7 @@ export const useAuth = () => {
email,
password,
workspaceInviteHash,
captchaToken,
},
});
@@ -3,7 +3,7 @@ import { Controller } from 'react-hook-form';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useRecoilState } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import { IconGoogle, IconMicrosoft } from 'twenty-ui';
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword';
@@ -11,6 +11,7 @@ import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle';
import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft';
import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash';
import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { Loader } from '@/ui/feedback/loader/components/Loader';
import { MainButton } from '@/ui/input/button/components/MainButton';
@@ -46,6 +47,9 @@ const StyledInputContainer = styled.div`
`;
export const SignInUpForm = () => {
const isRequestingCaptchaToken = useRecoilValue(
isRequestingCaptchaTokenState,
);
const [authProviders] = useRecoilState(authProvidersState);
const [showErrors, setShowErrors] = useState(false);
const { handleResetPassword } = useHandleResetPassword();
@@ -63,7 +67,9 @@ export const SignInUpForm = () => {
submitCredentials,
} = useSignInUp(form);
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const handleKeyDown = async (
event: React.KeyboardEvent<HTMLInputElement>,
) => {
if (event.key === 'Enter') {
event.preventDefault();
@@ -222,12 +228,11 @@ export const SignInUpForm = () => {
/>
</StyledFullWidthMotionDiv>
)}
<MainButton
variant="secondary"
title={buttonTitle}
type="submit"
onClick={() => {
onClick={async () => {
if (signInUpStep === SignInUpStep.Init) {
continueWithEmail();
return;
@@ -243,11 +248,13 @@ export const SignInUpForm = () => {
disabled={
signInUpStep === SignInUpStep.Init
? false
: signInUpStep === SignInUpStep.Email
? !form.watch('email')
: !form.watch('email') ||
!form.watch('password') ||
form.formState.isSubmitting
: isRequestingCaptchaToken
? true
: signInUpStep === SignInUpStep.Email
? !form.watch('email')
: !form.watch('email') ||
!form.watch('password') ||
form.formState.isSubmitting
}
fullWidth
/>
@@ -4,6 +4,8 @@ import { useParams } from 'react-router-dom';
import { useNavigateAfterSignInUp } from '@/auth/sign-in-up/hooks/useNavigateAfterSignInUp';
import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { AppPath } from '@/types/AppPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
@@ -48,24 +50,36 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
checkUserExists: { checkUserExistsQuery },
} = useAuth();
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
const { readCaptchaToken } = useReadCaptchaToken();
const continueWithEmail = useCallback(() => {
requestFreshCaptchaToken();
setSignInUpStep(SignInUpStep.Email);
setSignInUpMode(
isMatchingLocation(AppPath.SignInUp)
? SignInUpMode.SignIn
: SignInUpMode.SignUp,
);
}, [setSignInUpStep, setSignInUpMode, isMatchingLocation]);
}, [isMatchingLocation, requestFreshCaptchaToken]);
const continueWithCredentials = useCallback(() => {
const continueWithCredentials = useCallback(async () => {
const token = await readCaptchaToken();
if (!form.getValues('email')) {
return;
}
checkUserExistsQuery({
variables: {
email: form.getValues('email').toLowerCase().trim(),
captchaToken: token,
},
onError: (error) => {
enqueueSnackBar(`${error.message}`, {
variant: 'error',
});
},
onCompleted: (data) => {
requestFreshCaptchaToken();
if (data?.checkUserExists.exists) {
setSignInUpMode(SignInUpMode.SignIn);
} else {
@@ -74,10 +88,17 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
setSignInUpStep(SignInUpStep.Password);
},
});
}, [setSignInUpStep, checkUserExistsQuery, form, setSignInUpMode]);
}, [
readCaptchaToken,
form,
checkUserExistsQuery,
enqueueSnackBar,
requestFreshCaptchaToken,
]);
const submitCredentials: SubmitHandler<Form> = useCallback(
async (data) => {
const token = await readCaptchaToken();
try {
if (!data.email || !data.password) {
throw new Error('Email and password are required');
@@ -91,11 +112,13 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
? await signInWithCredentials(
data.email.toLowerCase().trim(),
data.password,
token,
)
: await signUpWithCredentials(
data.email.toLowerCase().trim(),
data.password,
workspaceInviteHash,
token,
);
navigateAfterSignInUp(currentWorkspace, currentWorkspaceMember);
@@ -106,6 +129,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
}
},
[
readCaptchaToken,
signInUpMode,
isInviteMode,
signInWithCredentials,
@@ -14,6 +14,7 @@ const validationSchema = z
password: z
.string()
.regex(PASSWORD_REGEX, 'Password must contain at least 8 characters'),
captchaToken: z.string().default(''),
})
.required();
@@ -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 '';
}
};
@@ -3,6 +3,7 @@ import { useRecoilState, useSetRecoilState } from 'recoil';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { billingState } from '@/client-config/states/billingState';
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
@@ -29,6 +30,8 @@ export const ClientConfigProviderEffect = () => {
isClientConfigLoadedState,
);
const setCaptchaProvider = useSetRecoilState(captchaProviderState);
const { data, loading } = useGetClientConfigQuery({
skip: isClientConfigLoaded,
});
@@ -55,6 +58,11 @@ export const ClientConfigProviderEffect = () => {
release: data?.clientConfig?.sentry?.release,
environment: data?.clientConfig?.sentry?.environment,
});
setCaptchaProvider({
provider: data?.clientConfig?.captcha?.provider,
siteKey: data?.clientConfig?.captcha?.siteKey,
});
}
}, [
data,
@@ -68,6 +76,7 @@ export const ClientConfigProviderEffect = () => {
setSentryConfig,
loading,
setIsClientConfigLoaded,
setCaptchaProvider,
]);
return <></>;
@@ -29,6 +29,10 @@ export const GET_CLIENT_CONFIG = gql`
environment
release
}
captcha {
provider
siteKey
}
}
}
`;
@@ -0,0 +1,8 @@
import { createState } from 'twenty-ui';
import { Captcha } from '~/generated/graphql';
export const captchaProviderState = createState<Captcha | null>({
key: 'captchaProviderState',
defaultValue: null,
});