From dc576d0818f1be8e71a6ab067e070c2d19a5be95 Mon Sep 17 00:00:00 2001 From: Deepak Kumar Date: Fri, 26 Apr 2024 03:22:28 +0530 Subject: [PATCH] GH-3546 Recaptcha on login form (#4626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 - image 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 image --------- Co-authored-by: Félix Malfait Co-authored-by: Charles Bochet --- .../docs/start/self-hosting/self-hosting.mdx | 8 ++ .../effect-components/PageChangeEffect.tsx | 14 ++++ .../src/generated-metadata/graphql.ts | 15 ++++ .../twenty-front/src/generated/graphql.tsx | 38 +++++++-- packages/twenty-front/src/index.css | 5 ++ packages/twenty-front/src/index.tsx | 81 ++++++++++--------- .../auth/graphql/mutations/challenge.ts | 8 +- .../modules/auth/graphql/mutations/signUp.ts | 2 + .../auth/graphql/queries/checkUserExists.ts | 4 +- .../src/modules/auth/hooks/useAuth.ts | 28 +++++-- .../sign-in-up/components/SignInUpForm.tsx | 25 +++--- .../auth/sign-in-up/hooks/useSignInUp.tsx | 30 ++++++- .../auth/sign-in-up/hooks/useSignInUpForm.ts | 1 + .../captcha/components/CaptchaProvider.tsx | 13 +++ .../CaptchaProviderScriptLoaderEffect.tsx | 52 ++++++++++++ .../captcha/hooks/useReadCaptchaToken.ts | 22 +++++ .../hooks/useRequestFreshCaptchaToken.ts | 77 ++++++++++++++++++ .../captcha/states/captchaTokenState.ts | 6 ++ .../states/isCaptchaScriptLoadedState.ts | 6 ++ .../states/isRequestingCaptchaTokenState.ts | 6 ++ .../captcha/utils/getCaptchaUrlByProvider.ts | 16 ++++ .../components/ClientConfigProviderEffect.tsx | 9 +++ .../graphql/queries/getClientConfig.ts | 4 + .../states/captchaProviderState.ts | 8 ++ .../src/testing/mock-data/config.ts | 7 +- packages/twenty-server/.env.example | 3 + .../core-modules/auth/auth.resolver.spec.ts | 8 +- .../engine/core-modules/auth/auth.resolver.ts | 4 + .../core-modules/auth/dto/challenge.input.ts | 7 +- .../core-modules/auth/dto/sign-up.input.ts | 5 ++ .../auth/dto/user-exists.input.ts | 7 +- .../client-config/client-config.entity.ts | 14 ++++ .../client-config/client-config.resolver.ts | 4 + .../integrations/captcha/captcha.constants.ts | 1 + .../integrations/captcha/captcha.guard.ts | 28 +++++++ .../captcha/captcha.module-factory.ts | 31 +++++++ .../integrations/captcha/captcha.module.ts | 42 ++++++++++ .../integrations/captcha/captcha.service.ts | 21 +++++ .../drivers/google-recaptcha.driver.ts | 39 +++++++++ .../interfaces/captcha-driver.interface.ts | 5 ++ .../interfaces/captcha-server-response.ts | 6 ++ .../captcha/drivers/turnstile.driver.ts | 39 +++++++++ .../captcha/interfaces/captcha.interface.ts | 39 +++++++++ .../integrations/captcha/interfaces/index.ts | 1 + .../environment/environment-variables.ts | 13 +++ .../integrations/integrations.module.ts | 6 ++ 46 files changed, 737 insertions(+), 71 deletions(-) create mode 100644 packages/twenty-front/src/modules/captcha/components/CaptchaProvider.tsx create mode 100644 packages/twenty-front/src/modules/captcha/components/CaptchaProviderScriptLoaderEffect.tsx create mode 100644 packages/twenty-front/src/modules/captcha/hooks/useReadCaptchaToken.ts create mode 100644 packages/twenty-front/src/modules/captcha/hooks/useRequestFreshCaptchaToken.ts create mode 100644 packages/twenty-front/src/modules/captcha/states/captchaTokenState.ts create mode 100644 packages/twenty-front/src/modules/captcha/states/isCaptchaScriptLoadedState.ts create mode 100644 packages/twenty-front/src/modules/captcha/states/isRequestingCaptchaTokenState.ts create mode 100644 packages/twenty-front/src/modules/captcha/utils/getCaptchaUrlByProvider.ts create mode 100644 packages/twenty-front/src/modules/client-config/states/captchaProviderState.ts create mode 100644 packages/twenty-server/src/engine/integrations/captcha/captcha.constants.ts create mode 100644 packages/twenty-server/src/engine/integrations/captcha/captcha.guard.ts create mode 100644 packages/twenty-server/src/engine/integrations/captcha/captcha.module-factory.ts create mode 100644 packages/twenty-server/src/engine/integrations/captcha/captcha.module.ts create mode 100644 packages/twenty-server/src/engine/integrations/captcha/captcha.service.ts create mode 100644 packages/twenty-server/src/engine/integrations/captcha/drivers/google-recaptcha.driver.ts create mode 100644 packages/twenty-server/src/engine/integrations/captcha/drivers/interfaces/captcha-driver.interface.ts create mode 100644 packages/twenty-server/src/engine/integrations/captcha/drivers/interfaces/captcha-server-response.ts create mode 100644 packages/twenty-server/src/engine/integrations/captcha/drivers/turnstile.driver.ts create mode 100644 packages/twenty-server/src/engine/integrations/captcha/interfaces/captcha.interface.ts create mode 100644 packages/twenty-server/src/engine/integrations/captcha/interfaces/index.ts diff --git a/packages/twenty-docs/docs/start/self-hosting/self-hosting.mdx b/packages/twenty-docs/docs/start/self-hosting/self-hosting.mdx index 5efa3dc0e..44ad97cf7 100644 --- a/packages/twenty-docs/docs/start/self-hosting/self-hosting.mdx +++ b/packages/twenty-docs/docs/start/self-hosting/self-hosting.mdx @@ -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'], ]}> + +### Captcha + + \ No newline at end of file diff --git a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx index ea0b1a2e4..dba2d07ab 100644 --- a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx @@ -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'; @@ -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 <>; }; diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 1f656f653..ed8400877 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -122,10 +122,22 @@ export type BooleanFieldComparison = { isNot?: InputMaybe; }; +export type Captcha = { + __typename?: 'Captcha'; + provider?: Maybe; + siteKey?: Maybe; +}; + +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']; @@ -386,6 +398,7 @@ export type MutationAuthorizeAppArgs = { export type MutationChallengeArgs = { + captchaToken?: InputMaybe; email: Scalars['String']['input']; password: Scalars['String']['input']; }; @@ -469,6 +482,7 @@ export type MutationRenewTokenArgs = { export type MutationSignUpArgs = { + captchaToken?: InputMaybe; email: Scalars['String']['input']; password: Scalars['String']['input']; workspaceInviteHash?: InputMaybe; @@ -614,6 +628,7 @@ export type QueryBillingPortalSessionArgs = { export type QueryCheckUserExistsArgs = { + captchaToken?: InputMaybe; email: Scalars['String']['input']; }; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 100824406..295303497 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -117,10 +117,22 @@ export type BooleanFieldComparison = { isNot?: InputMaybe; }; +export type Captcha = { + __typename?: 'Captcha'; + provider?: Maybe; + siteKey?: Maybe; +}; + +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']; @@ -289,6 +301,7 @@ export type MutationAuthorizeAppArgs = { export type MutationChallengeArgs = { + captchaToken?: InputMaybe; email: Scalars['String']; password: Scalars['String']; }; @@ -339,6 +352,7 @@ export type MutationRenewTokenArgs = { export type MutationSignUpArgs = { + captchaToken?: InputMaybe; email: Scalars['String']; password: Scalars['String']; workspaceInviteHash?: InputMaybe; @@ -456,6 +470,7 @@ export type QueryBillingPortalSessionArgs = { export type QueryCheckUserExistsArgs = { + captchaToken?: InputMaybe; email: Scalars['String']; }; @@ -999,6 +1014,7 @@ export type AuthorizeAppMutation = { __typename?: 'Mutation', authorizeApp: { __ export type ChallengeMutationVariables = Exact<{ email: Scalars['String']; password: Scalars['String']; + captchaToken?: InputMaybe; }>; @@ -1049,6 +1065,7 @@ export type SignUpMutationVariables = Exact<{ email: Scalars['String']; password: Scalars['String']; workspaceInviteHash?: InputMaybe; + captchaToken?: InputMaybe; }>; @@ -1071,6 +1088,7 @@ export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: ' export type CheckUserExistsQueryVariables = Exact<{ email: Scalars['String']; + captchaToken?: InputMaybe; }>; @@ -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']; @@ -1559,8 +1577,8 @@ export type AuthorizeAppMutationHookResult = ReturnType; export type AuthorizeAppMutationOptions = Apollo.BaseMutationOptions; 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 } @@ -1584,6 +1602,7 @@ export type ChallengeMutationFn = Apollo.MutationFunction; export type RenewTokenMutationOptions = Apollo.BaseMutationOptions; 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 @@ -1835,6 +1855,7 @@ export type SignUpMutationFn = Apollo.MutationFunction; export type VerifyMutationResult = Apollo.MutationResult; export type VerifyMutationOptions = Apollo.BaseMutationOptions; export const CheckUserExistsDocument = gql` - query CheckUserExists($email: String!) { - checkUserExists(email: $email) { + query CheckUserExists($email: String!, $captchaToken: String) { + checkUserExists(email: $email, captchaToken: $captchaToken) { exists } } @@ -1942,6 +1963,7 @@ export const CheckUserExistsDocument = gql` * const { data, loading, error } = useCheckUserExistsQuery({ * variables: { * email: // value for 'email' + * captchaToken: // value for 'captchaToken' * }, * }); */ @@ -2165,6 +2187,10 @@ export const GetClientConfigDocument = gql` environment release } + captcha { + provider + siteKey + } } } `; diff --git a/packages/twenty-front/src/index.css b/packages/twenty-front/src/index.css index 5c9b489dc..2d696388b 100644 --- a/packages/twenty-front/src/index.css +++ b/packages/twenty-front/src/index.css @@ -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; +} \ No newline at end of file diff --git a/packages/twenty-front/src/index.tsx b/packages/twenty-front/src/index.tsx index 83e4c55e5..dfdc65277 100644 --- a/packages/twenty-front/src/index.tsx +++ b/packages/twenty-front/src/index.tsx @@ -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'; @@ -39,45 +40,47 @@ const root = ReactDOM.createRoot( root.render( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + , ); diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/challenge.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/challenge.ts index 7bf299695..be775eb1f 100644 --- a/packages/twenty-front/src/modules/auth/graphql/mutations/challenge.ts +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/challenge.ts @@ -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 } diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/signUp.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/signUp.ts index 43dca085f..85285b776 100644 --- a/packages/twenty-front/src/modules/auth/graphql/mutations/signUp.ts +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/signUp.ts @@ -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 diff --git a/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts b/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts index 2b35abb13..ddf5505bb 100644 --- a/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts +++ b/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts @@ -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 } } diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index ea204c95a..da9f421c8 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -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, }, }); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx index 9c6ce8de8..ccbd87b70 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx @@ -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) => { + const handleKeyDown = async ( + event: React.KeyboardEvent, + ) => { if (event.key === 'Enter') { event.preventDefault(); @@ -222,12 +228,11 @@ export const SignInUpForm = () => { /> )} - { + 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 /> diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx index 6c4bf6198..3c9edd5cb 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx @@ -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
) => { 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) => { setSignInUpStep(SignInUpStep.Password); }, }); - }, [setSignInUpStep, checkUserExistsQuery, form, setSignInUpMode]); + }, [ + readCaptchaToken, + form, + checkUserExistsQuery, + enqueueSnackBar, + requestFreshCaptchaToken, + ]); const submitCredentials: SubmitHandler = 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) => { ? 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) => { } }, [ + readCaptchaToken, signInUpMode, isInviteMode, signInWithCredentials, diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts index f7d4de9b4..817b27667 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts @@ -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(); diff --git a/packages/twenty-front/src/modules/captcha/components/CaptchaProvider.tsx b/packages/twenty-front/src/modules/captcha/components/CaptchaProvider.tsx new file mode 100644 index 000000000..0d88dbe43 --- /dev/null +++ b/packages/twenty-front/src/modules/captcha/components/CaptchaProvider.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import { CaptchaProviderScriptLoaderEffect } from '@/captcha/components/CaptchaProviderScriptLoaderEffect'; + +export const CaptchaProvider = ({ children }: React.PropsWithChildren) => { + return ( + <> +
+ + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/captcha/components/CaptchaProviderScriptLoaderEffect.tsx b/packages/twenty-front/src/modules/captcha/components/CaptchaProviderScriptLoaderEffect.tsx new file mode 100644 index 000000000..59dedab8f --- /dev/null +++ b/packages/twenty-front/src/modules/captcha/components/CaptchaProviderScriptLoaderEffect.tsx @@ -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 <>; +}; diff --git a/packages/twenty-front/src/modules/captcha/hooks/useReadCaptchaToken.ts b/packages/twenty-front/src/modules/captcha/hooks/useReadCaptchaToken.ts new file mode 100644 index 000000000..adc5aceb0 --- /dev/null +++ b/packages/twenty-front/src/modules/captcha/hooks/useReadCaptchaToken.ts @@ -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 }; +}; diff --git a/packages/twenty-front/src/modules/captcha/hooks/useRequestFreshCaptchaToken.ts b/packages/twenty-front/src/modules/captcha/hooks/useRequestFreshCaptchaToken.ts new file mode 100644 index 000000000..f97295225 --- /dev/null +++ b/packages/twenty-front/src/modules/captcha/hooks/useRequestFreshCaptchaToken.ts @@ -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 }; +}; diff --git a/packages/twenty-front/src/modules/captcha/states/captchaTokenState.ts b/packages/twenty-front/src/modules/captcha/states/captchaTokenState.ts new file mode 100644 index 000000000..24289cac6 --- /dev/null +++ b/packages/twenty-front/src/modules/captcha/states/captchaTokenState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const captchaTokenState = createState({ + key: 'captchaTokenState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/captcha/states/isCaptchaScriptLoadedState.ts b/packages/twenty-front/src/modules/captcha/states/isCaptchaScriptLoadedState.ts new file mode 100644 index 000000000..0db486bbe --- /dev/null +++ b/packages/twenty-front/src/modules/captcha/states/isCaptchaScriptLoadedState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const isCaptchaScriptLoadedState = createState({ + key: 'isCaptchaScriptLoadedState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/captcha/states/isRequestingCaptchaTokenState.ts b/packages/twenty-front/src/modules/captcha/states/isRequestingCaptchaTokenState.ts new file mode 100644 index 000000000..df7daeb4d --- /dev/null +++ b/packages/twenty-front/src/modules/captcha/states/isRequestingCaptchaTokenState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const isRequestingCaptchaTokenState = createState({ + key: 'isRequestingCaptchaTokenState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/captcha/utils/getCaptchaUrlByProvider.ts b/packages/twenty-front/src/modules/captcha/utils/getCaptchaUrlByProvider.ts new file mode 100644 index 000000000..5c0abe89e --- /dev/null +++ b/packages/twenty-front/src/modules/captcha/utils/getCaptchaUrlByProvider.ts @@ -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 ''; + } +}; diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx index 27bbb8923..66ccb5f38 100644 --- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx @@ -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 <>; diff --git a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts index 59d61c4a2..563543bba 100644 --- a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts @@ -29,6 +29,10 @@ export const GET_CLIENT_CONFIG = gql` environment release } + captcha { + provider + siteKey + } } } `; diff --git a/packages/twenty-front/src/modules/client-config/states/captchaProviderState.ts b/packages/twenty-front/src/modules/client-config/states/captchaProviderState.ts new file mode 100644 index 000000000..2440f11a4 --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/states/captchaProviderState.ts @@ -0,0 +1,8 @@ +import { createState } from 'twenty-ui'; + +import { Captcha } from '~/generated/graphql'; + +export const captchaProviderState = createState({ + key: 'captchaProviderState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index 0cdef85a6..bdbc895f3 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -1,3 +1,4 @@ +import { CaptchaDriverType } from '~/generated/graphql'; import { ClientConfig } from '~/generated-metadata/graphql'; export const mockedClientConfig: ClientConfig = { @@ -32,5 +33,9 @@ export const mockedClientConfig: ClientConfig = { billingFreeTrialDurationInDays: 10, __typename: 'Billing', }, - __typename: 'ClientConfig', + captcha: { + provider: CaptchaDriverType.GoogleRecatpcha, + siteKey: 'MOCKED_SITE_KEY', + __typename: 'Captcha', + }, }; diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 9f0973e10..e3e0a9112 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -67,6 +67,9 @@ SIGN_IN_PREFILLED=true # EMAIL_SMTP_USER= # EMAIL_SMTP_PASSWORD= # PASSWORD_RESET_TOKEN_EXPIRES_IN=5m +# CAPTCHA_DRIVER= +# CAPTCHA_SITE_KEY= +# CAPTCHA_SECRET_KEY= # API_RATE_LIMITING_TTL= # API_RATE_LIMITING_LIMIT= # MUTATION_MAXIMUM_RECORD_AFFECTED=100 diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts index 4edcf1479..30e521f7b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts @@ -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); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index aa1e08ce6..f3fb18a3d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -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 { 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 { const user = await this.authService.signInUp({ diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/challenge.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/challenge.input.ts index 6e1cee036..84c99e033 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/challenge.input.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/challenge.input.ts @@ -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; } diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/sign-up.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/sign-up.input.ts index 675376577..53a9a4788 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/sign-up.input.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/sign-up.input.ts @@ -18,4 +18,9 @@ export class SignUpInput { @IsString() @IsOptional() workspaceInviteHash?: string; + + @Field(() => String, { nullable: true }) + @IsString() + @IsOptional() + captchaToken?: string; } diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.input.ts index 9dfe662d2..b580371f6 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.input.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.input.ts @@ -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; } diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index 9a8a80101..103fbc9ea 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -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; } diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts index bfaaef882..ad609ca70 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts @@ -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); diff --git a/packages/twenty-server/src/engine/integrations/captcha/captcha.constants.ts b/packages/twenty-server/src/engine/integrations/captcha/captcha.constants.ts new file mode 100644 index 000000000..1a1ed4d45 --- /dev/null +++ b/packages/twenty-server/src/engine/integrations/captcha/captcha.constants.ts @@ -0,0 +1 @@ +export const CAPTCHA_DRIVER = Symbol('CAPTCHA_DRIVER'); diff --git a/packages/twenty-server/src/engine/integrations/captcha/captcha.guard.ts b/packages/twenty-server/src/engine/integrations/captcha/captcha.guard.ts new file mode 100644 index 000000000..46bed4fbd --- /dev/null +++ b/packages/twenty-server/src/engine/integrations/captcha/captcha.guard.ts @@ -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 { + 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', + ); + } +} diff --git a/packages/twenty-server/src/engine/integrations/captcha/captcha.module-factory.ts b/packages/twenty-server/src/engine/integrations/captcha/captcha.module-factory.ts new file mode 100644 index 000000000..fc391f405 --- /dev/null +++ b/packages/twenty-server/src/engine/integrations/captcha/captcha.module-factory.ts @@ -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, + }; +}; diff --git a/packages/twenty-server/src/engine/integrations/captcha/captcha.module.ts b/packages/twenty-server/src/engine/integrations/captcha/captcha.module.ts new file mode 100644 index 000000000..891bed2e5 --- /dev/null +++ b/packages/twenty-server/src/engine/integrations/captcha/captcha.module.ts @@ -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], + }; + } +} diff --git a/packages/twenty-server/src/engine/integrations/captcha/captcha.service.ts b/packages/twenty-server/src/engine/integrations/captcha/captcha.service.ts new file mode 100644 index 000000000..58af667e0 --- /dev/null +++ b/packages/twenty-server/src/engine/integrations/captcha/captcha.service.ts @@ -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 { + if (this.driver) { + return await this.driver.validate(token); + } else { + return { + success: true, + }; + } + } +} diff --git a/packages/twenty-server/src/engine/integrations/captcha/drivers/google-recaptcha.driver.ts b/packages/twenty-server/src/engine/integrations/captcha/drivers/google-recaptcha.driver.ts new file mode 100644 index 000000000..85b766124 --- /dev/null +++ b/packages/twenty-server/src/engine/integrations/captcha/drivers/google-recaptcha.driver.ts @@ -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 { + 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', + }), + }; + } +} diff --git a/packages/twenty-server/src/engine/integrations/captcha/drivers/interfaces/captcha-driver.interface.ts b/packages/twenty-server/src/engine/integrations/captcha/drivers/interfaces/captcha-driver.interface.ts new file mode 100644 index 000000000..532640a79 --- /dev/null +++ b/packages/twenty-server/src/engine/integrations/captcha/drivers/interfaces/captcha-driver.interface.ts @@ -0,0 +1,5 @@ +import { CaptchaValidateResult } from 'src/engine/integrations/captcha/interfaces'; + +export interface CaptchaDriver { + validate(token: string): Promise; +} diff --git a/packages/twenty-server/src/engine/integrations/captcha/drivers/interfaces/captcha-server-response.ts b/packages/twenty-server/src/engine/integrations/captcha/drivers/interfaces/captcha-server-response.ts new file mode 100644 index 000000000..91bc3225f --- /dev/null +++ b/packages/twenty-server/src/engine/integrations/captcha/drivers/interfaces/captcha-server-response.ts @@ -0,0 +1,6 @@ +export type CaptchaServerResponse = { + success: boolean; + challenge_ts: string; + hostname: string; + 'error-codes': string[]; +}; diff --git a/packages/twenty-server/src/engine/integrations/captcha/drivers/turnstile.driver.ts b/packages/twenty-server/src/engine/integrations/captcha/drivers/turnstile.driver.ts new file mode 100644 index 000000000..5dbe5d3e1 --- /dev/null +++ b/packages/twenty-server/src/engine/integrations/captcha/drivers/turnstile.driver.ts @@ -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 { + 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', + }), + }; + } +} diff --git a/packages/twenty-server/src/engine/integrations/captcha/interfaces/captcha.interface.ts b/packages/twenty-server/src/engine/integrations/captcha/interfaces/captcha.interface.ts new file mode 100644 index 000000000..e7f0ca562 --- /dev/null +++ b/packages/twenty-server/src/engine/integrations/captcha/interfaces/captcha.interface.ts @@ -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 | undefined; +} & Pick & + Pick; + +export type CaptchaValidateResult = { success: boolean; error?: string }; diff --git a/packages/twenty-server/src/engine/integrations/captcha/interfaces/index.ts b/packages/twenty-server/src/engine/integrations/captcha/interfaces/index.ts new file mode 100644 index 000000000..bd85706d7 --- /dev/null +++ b/packages/twenty-server/src/engine/integrations/captcha/interfaces/index.ts @@ -0,0 +1 @@ +export * from './captcha.interface'; diff --git a/packages/twenty-server/src/engine/integrations/environment/environment-variables.ts b/packages/twenty-server/src/engine/integrations/environment/environment-variables.ts index 6aec43e78..36c13dfbc 100644 --- a/packages/twenty-server/src/engine/integrations/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/integrations/environment/environment-variables.ts @@ -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() diff --git a/packages/twenty-server/src/engine/integrations/integrations.module.ts b/packages/twenty-server/src/engine/integrations/integrations.module.ts index 0f056fdf9..df855478a 100644 --- a/packages/twenty-server/src/engine/integrations/integrations.module.ts +++ b/packages/twenty-server/src/engine/integrations/integrations.module.ts @@ -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, }),