Skip to content

Commit

Permalink
GH-3546 Recaptcha on login form (#4626)
Browse files Browse the repository at this point in the history
## 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 <[email protected]>
Co-authored-by: Charles Bochet <[email protected]>
  • Loading branch information
3 people authored Apr 25, 2024
1 parent 44855f0 commit dc576d0
Show file tree
Hide file tree
Showing 46 changed files with 737 additions and 71 deletions.
8 changes: 8 additions & 0 deletions packages/twenty-docs/docs/start/self-hosting/self-hosting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,11 @@ import TabItem from '@theme/TabItem';
['WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION', '', 'Number of inactive days before sending workspace deleting warning email'],
['WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', '', 'Number of inactive days before deleting workspace'],
]}></OptionTable>

### Captcha

<OptionTable options={[
['CAPTCHA_DRIVER', '', "The captcha driver can be 'google-recaptcha' or 'turnstile'"],
['CAPTCHA_SITE_KEY', '', 'The captcha site key'],
['CAPTCHA_SECRET_KEY', '', 'The captcha secret key'],
]}></OptionTable>
14 changes: 14 additions & 0 deletions packages/twenty-front/src/effect-components/PageChangeEffect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateAct
import { useEventTracker } from '@/analytics/hooks/useEventTracker';
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus';
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
import { isSignUpDisabledState } from '@/client-config/states/isSignUpDisabledState';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CommandType } from '@/command-menu/types/Command';
Expand Down Expand Up @@ -248,5 +250,17 @@ export const PageChangeEffect = () => {
}, 500);
}, [eventTracker, location.pathname]);

const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
const isCaptchaScriptLoaded = useRecoilValue(isCaptchaScriptLoadedState);

useEffect(() => {
if (
isCaptchaScriptLoaded &&
isMatchingLocation(AppPath.SignInUp || AppPath.Invite)
) {
requestFreshCaptchaToken();
}
}, [isCaptchaScriptLoaded, isMatchingLocation, requestFreshCaptchaToken]);

return <></>;
};
15 changes: 15 additions & 0 deletions packages/twenty-front/src/generated-metadata/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,22 @@ export type BooleanFieldComparison = {
isNot?: InputMaybe<Scalars['Boolean']['input']>;
};

export type Captcha = {
__typename?: 'Captcha';
provider?: Maybe<CaptchaDriverType>;
siteKey?: Maybe<Scalars['String']['output']>;
};

export enum CaptchaDriverType {
GoogleRecatpcha = 'GoogleRecatpcha',
Turnstile = 'Turnstile'
}

export type ClientConfig = {
__typename?: 'ClientConfig';
authProviders: AuthProviders;
billing: Billing;
captcha: Captcha;
debugMode: Scalars['Boolean']['output'];
sentry: Sentry;
signInPrefilled: Scalars['Boolean']['output'];
Expand Down Expand Up @@ -386,6 +398,7 @@ export type MutationAuthorizeAppArgs = {


export type MutationChallengeArgs = {
captchaToken?: InputMaybe<Scalars['String']['input']>;
email: Scalars['String']['input'];
password: Scalars['String']['input'];
};
Expand Down Expand Up @@ -469,6 +482,7 @@ export type MutationRenewTokenArgs = {


export type MutationSignUpArgs = {
captchaToken?: InputMaybe<Scalars['String']['input']>;
email: Scalars['String']['input'];
password: Scalars['String']['input'];
workspaceInviteHash?: InputMaybe<Scalars['String']['input']>;
Expand Down Expand Up @@ -614,6 +628,7 @@ export type QueryBillingPortalSessionArgs = {


export type QueryCheckUserExistsArgs = {
captchaToken?: InputMaybe<Scalars['String']['input']>;
email: Scalars['String']['input'];
};

Expand Down
38 changes: 32 additions & 6 deletions packages/twenty-front/src/generated/graphql.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,22 @@ export type BooleanFieldComparison = {
isNot?: InputMaybe<Scalars['Boolean']>;
};

export type Captcha = {
__typename?: 'Captcha';
provider?: Maybe<CaptchaDriverType>;
siteKey?: Maybe<Scalars['String']>;
};

export enum CaptchaDriverType {
GoogleRecatpcha = 'GoogleRecatpcha',
Turnstile = 'Turnstile'
}

export type ClientConfig = {
__typename?: 'ClientConfig';
authProviders: AuthProviders;
billing: Billing;
captcha: Captcha;
debugMode: Scalars['Boolean'];
sentry: Sentry;
signInPrefilled: Scalars['Boolean'];
Expand Down Expand Up @@ -289,6 +301,7 @@ export type MutationAuthorizeAppArgs = {


export type MutationChallengeArgs = {
captchaToken?: InputMaybe<Scalars['String']>;
email: Scalars['String'];
password: Scalars['String'];
};
Expand Down Expand Up @@ -339,6 +352,7 @@ export type MutationRenewTokenArgs = {


export type MutationSignUpArgs = {
captchaToken?: InputMaybe<Scalars['String']>;
email: Scalars['String'];
password: Scalars['String'];
workspaceInviteHash?: InputMaybe<Scalars['String']>;
Expand Down Expand Up @@ -456,6 +470,7 @@ export type QueryBillingPortalSessionArgs = {


export type QueryCheckUserExistsArgs = {
captchaToken?: InputMaybe<Scalars['String']>;
email: Scalars['String'];
};

Expand Down Expand Up @@ -999,6 +1014,7 @@ export type AuthorizeAppMutation = { __typename?: 'Mutation', authorizeApp: { __
export type ChallengeMutationVariables = Exact<{
email: Scalars['String'];
password: Scalars['String'];
captchaToken?: InputMaybe<Scalars['String']>;
}>;


Expand Down Expand Up @@ -1049,6 +1065,7 @@ export type SignUpMutationVariables = Exact<{
email: Scalars['String'];
password: Scalars['String'];
workspaceInviteHash?: InputMaybe<Scalars['String']>;
captchaToken?: InputMaybe<Scalars['String']>;
}>;


Expand All @@ -1071,6 +1088,7 @@ export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: '

export type CheckUserExistsQueryVariables = Exact<{
email: Scalars['String'];
captchaToken?: InputMaybe<Scalars['String']>;
}>;


Expand Down Expand Up @@ -1113,7 +1131,7 @@ export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updat
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;


export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null } } };
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null } } };

export type UploadFileMutationVariables = Exact<{
file: Scalars['Upload'];
Expand Down Expand Up @@ -1559,8 +1577,8 @@ export type AuthorizeAppMutationHookResult = ReturnType<typeof useAuthorizeAppMu
export type AuthorizeAppMutationResult = Apollo.MutationResult<AuthorizeAppMutation>;
export type AuthorizeAppMutationOptions = Apollo.BaseMutationOptions<AuthorizeAppMutation, AuthorizeAppMutationVariables>;
export const ChallengeDocument = 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
}
Expand All @@ -1584,6 +1602,7 @@ export type ChallengeMutationFn = Apollo.MutationFunction<ChallengeMutation, Cha
* variables: {
* email: // value for 'email'
* password: // value for 'password'
* captchaToken: // value for 'captchaToken'
* },
* });
*/
Expand Down Expand Up @@ -1805,11 +1824,12 @@ export type RenewTokenMutationHookResult = ReturnType<typeof useRenewTokenMutati
export type RenewTokenMutationResult = Apollo.MutationResult<RenewTokenMutation>;
export type RenewTokenMutationOptions = Apollo.BaseMutationOptions<RenewTokenMutation, RenewTokenMutationVariables>;
export const SignUpDocument = gql`
mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String) {
mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $captchaToken: String) {
signUp(
email: $email
password: $password
workspaceInviteHash: $workspaceInviteHash
captchaToken: $captchaToken
) {
loginToken {
...AuthTokenFragment
Expand All @@ -1835,6 +1855,7 @@ export type SignUpMutationFn = Apollo.MutationFunction<SignUpMutation, SignUpMut
* email: // value for 'email'
* password: // value for 'password'
* workspaceInviteHash: // value for 'workspaceInviteHash'
* captchaToken: // value for 'captchaToken'
* },
* });
*/
Expand Down Expand Up @@ -1922,8 +1943,8 @@ export type VerifyMutationHookResult = ReturnType<typeof useVerifyMutation>;
export type VerifyMutationResult = Apollo.MutationResult<VerifyMutation>;
export type VerifyMutationOptions = Apollo.BaseMutationOptions<VerifyMutation, VerifyMutationVariables>;
export const CheckUserExistsDocument = gql`
query CheckUserExists($email: String!) {
checkUserExists(email: $email) {
query CheckUserExists($email: String!, $captchaToken: String) {
checkUserExists(email: $email, captchaToken: $captchaToken) {
exists
}
}
Expand All @@ -1942,6 +1963,7 @@ export const CheckUserExistsDocument = gql`
* const { data, loading, error } = useCheckUserExistsQuery({
* variables: {
* email: // value for 'email'
* captchaToken: // value for 'captchaToken'
* },
* });
*/
Expand Down Expand Up @@ -2165,6 +2187,10 @@ export const GetClientConfigDocument = gql`
environment
release
}
captcha {
provider
siteKey
}
}
}
`;
Expand Down
5 changes: 5 additions & 0 deletions packages/twenty-front/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,8 @@ body {
html {
font-size: 13px;
}

/* https://stackoverflow.com/questions/44543157/how-to-hide-the-google-invisible-recaptcha-badge */
.grecaptcha-badge {
visibility: hidden !important;
}
81 changes: 42 additions & 39 deletions packages/twenty-front/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { RecoilRoot } from 'recoil';
import { IconsProvider } from 'twenty-ui';

import { ApolloProvider } from '@/apollo/components/ApolloProvider';
import { CaptchaProvider } from '@/captcha/components/CaptchaProvider';
import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider';
import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect';
import { ApolloDevLogEffect } from '@/debug/components/ApolloDevLogEffect';
Expand Down Expand Up @@ -39,45 +40,47 @@ const root = ReactDOM.createRoot(
root.render(
<RecoilRoot>
<AppErrorBoundary>
<RecoilDebugObserverEffect />
<ApolloDevLogEffect />
<BrowserRouter>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<IconsProvider>
<ExceptionHandlerProvider>
<ApolloProvider>
<HelmetProvider>
<ClientConfigProviderEffect />
<ClientConfigProvider>
<UserProviderEffect />
<UserProvider>
<ApolloMetadataClientProvider>
<ObjectMetadataItemsProvider>
<PrefetchDataProvider>
<AppThemeProvider>
<SnackBarProvider>
<DialogManagerScope dialogManagerScopeId="dialog-manager">
<DialogManager>
<StrictMode>
<PromiseRejectionEffect />
<App />
</StrictMode>
</DialogManager>
</DialogManagerScope>
</SnackBarProvider>
</AppThemeProvider>
</PrefetchDataProvider>
<PageChangeEffect />
</ObjectMetadataItemsProvider>
</ApolloMetadataClientProvider>
</UserProvider>
</ClientConfigProvider>
</HelmetProvider>
</ApolloProvider>
</ExceptionHandlerProvider>
</IconsProvider>
</SnackBarProviderScope>
</BrowserRouter>
<CaptchaProvider>
<RecoilDebugObserverEffect />
<ApolloDevLogEffect />
<BrowserRouter>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<IconsProvider>
<ExceptionHandlerProvider>
<ApolloProvider>
<HelmetProvider>
<ClientConfigProviderEffect />
<ClientConfigProvider>
<UserProviderEffect />
<UserProvider>
<ApolloMetadataClientProvider>
<ObjectMetadataItemsProvider>
<PrefetchDataProvider>
<AppThemeProvider>
<SnackBarProvider>
<DialogManagerScope dialogManagerScopeId="dialog-manager">
<DialogManager>
<StrictMode>
<PromiseRejectionEffect />
<App />
</StrictMode>
</DialogManager>
</DialogManagerScope>
</SnackBarProvider>
</AppThemeProvider>
</PrefetchDataProvider>
<PageChangeEffect />
</ObjectMetadataItemsProvider>
</ApolloMetadataClientProvider>
</UserProvider>
</ClientConfigProvider>
</HelmetProvider>
</ApolloProvider>
</ExceptionHandlerProvider>
</IconsProvider>
</SnackBarProviderScope>
</BrowserRouter>
</CaptchaProvider>
</AppErrorBoundary>
</RecoilRoot>,
);
Original file line number Diff line number Diff line change
@@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Expand Down
Loading

0 comments on commit dc576d0

Please sign in to comment.