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 5efa3dc0e3..44ad97cf73 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 ea0b1a2e42..dba2d07ab9 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 1f656f653c..ed84008778 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 100824406a..295303497d 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 5c9b489dc4..2d696388b8 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 83e4c55e56..dfdc65277a 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 7bf299695c..be775eb1f8 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 43dca085fe..85285b7769 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 2b35abb13b..ddf5505bba 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 ea204c95ac..da9f421c8a 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 9c6ce8de83..ccbd87b707 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 6c4bf61983..3c9edd5cbf 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 f7d4de9b48..817b276678 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 0000000000..0d88dbe436 --- /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 0000000000..59dedab8fe --- /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 0000000000..adc5aceb0c --- /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 0000000000..f97295225a --- /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 0000000000..24289cac6c --- /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 0000000000..0db486bbe7 --- /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 0000000000..df7daeb4dd --- /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 0000000000..5c0abe89e8 --- /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 27bbb8923c..66ccb5f383 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 59d61c4a21..563543bba7 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 0000000000..2440f11a45 --- /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 0cdef85a6e..bdbc895f38 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 9f0973e104..e3e0a91121 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 4edcf14796..30e521f7ba 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 aa1e08ce6d..f3fb18a3de 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 6e1cee036b..84c99e033a 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 675376577a..53a9a47880 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 9dfe662d2a..b580371f6d 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 9a8a80101b..103fbc9eaf 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 bfaaef8829..ad609ca70e 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 0000000000..1a1ed4d454 --- /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 0000000000..46bed4fbde --- /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 0000000000..fc391f4050 --- /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 0000000000..891bed2e5e --- /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 0000000000..58af667e04 --- /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 0000000000..85b766124f --- /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 0000000000..532640a79b --- /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 0000000000..91bc3225f3 --- /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 0000000000..5dbe5d3e1e --- /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 0000000000..e7f0ca5620 --- /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 0000000000..bd85706d79 --- /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 6aec43e78a..36c13dfbcc 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 0f056fdf9e..df855478af 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, }),