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, }),