mirror of
https://github.com/twentyhq/twenty.git
synced 2024-10-04 12:57:10 +03:00
GH-3546 Recaptcha on login form (#4626)
## Description This PR adds recaptcha on login form. One can add any one of three recaptcha vendor - 1. Google Recaptcha - https://developers.google.com/recaptcha/docs/v3#programmatically_invoke_the_challenge 2. HCaptcha - https://docs.hcaptcha.com/invisible#programmatically-invoke-the-challenge 3. Turnstile - https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#execution-modes ### Issue - #3546 ### Environment variables - 1. `CAPTCHA_DRIVER` - `google-recaptcha` | `hcaptcha` | `turnstile` 2. `CAPTCHA_SITE_KEY` - site key 3. `CAPTCHA_SECRET_KEY` - secret key ### Engineering choices 1. If some of the above env variable provided, then, backend generates an error - <img width="990" alt="image" src="https://github.com/twentyhq/twenty/assets/60139930/9fb00fab-9261-4ff3-b23e-2c2e06f1bf89"> Please note that login/signup form will keep working as expected. 2. I'm using a Captcha guard that intercepts the request. If "captchaToken" is present in the body and all env is set, then, the captcha token is verified by backend through the service. 3. One can use this guard on any resolver to protect it by the captcha. 4. On frontend, two hooks `useGenerateCaptchaToken` and `useInsertCaptchaScript` is created. `useInsertCaptchaScript` adds the respective captcha JS script on frontend. `useGenerateCaptchaToken` returns a function that one can use to trigger captcha token generation programatically. This allows one to generate token keeping recaptcha invisible. ### Note This PR contains some changes in unrelated files like indentation, spacing, inverted comma etc. I ran "yarn nx fmt:fix twenty-front" and "yarn nx lint twenty-front -- --fix". ### Screenshots <img width="869" alt="image" src="https://github.com/twentyhq/twenty/assets/60139930/a75f5677-9b66-47f7-9730-4ec916073f8c"> --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
parent
44855f0317
commit
dc576d0818
@ -201,3 +201,11 @@ import TabItem from '@theme/TabItem';
|
||||
['WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION', '', 'Number of inactive days before sending workspace deleting warning email'],
|
||||
['WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', '', 'Number of inactive days before deleting workspace'],
|
||||
]}></OptionTable>
|
||||
|
||||
### Captcha
|
||||
|
||||
<OptionTable options={[
|
||||
['CAPTCHA_DRIVER', '', "The captcha driver can be 'google-recaptcha' or 'turnstile'"],
|
||||
['CAPTCHA_SITE_KEY', '', 'The captcha site key'],
|
||||
['CAPTCHA_SECRET_KEY', '', 'The captcha secret key'],
|
||||
]}></OptionTable>
|
@ -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 <></>;
|
||||
};
|
||||
|
@ -122,10 +122,22 @@ export type BooleanFieldComparison = {
|
||||
isNot?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
};
|
||||
|
||||
export type Captcha = {
|
||||
__typename?: 'Captcha';
|
||||
provider?: Maybe<CaptchaDriverType>;
|
||||
siteKey?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export enum CaptchaDriverType {
|
||||
GoogleRecatpcha = 'GoogleRecatpcha',
|
||||
Turnstile = 'Turnstile'
|
||||
}
|
||||
|
||||
export type ClientConfig = {
|
||||
__typename?: 'ClientConfig';
|
||||
authProviders: AuthProviders;
|
||||
billing: Billing;
|
||||
captcha: Captcha;
|
||||
debugMode: Scalars['Boolean']['output'];
|
||||
sentry: Sentry;
|
||||
signInPrefilled: Scalars['Boolean']['output'];
|
||||
@ -386,6 +398,7 @@ export type MutationAuthorizeAppArgs = {
|
||||
|
||||
|
||||
export type MutationChallengeArgs = {
|
||||
captchaToken?: InputMaybe<Scalars['String']['input']>;
|
||||
email: Scalars['String']['input'];
|
||||
password: Scalars['String']['input'];
|
||||
};
|
||||
@ -469,6 +482,7 @@ export type MutationRenewTokenArgs = {
|
||||
|
||||
|
||||
export type MutationSignUpArgs = {
|
||||
captchaToken?: InputMaybe<Scalars['String']['input']>;
|
||||
email: Scalars['String']['input'];
|
||||
password: Scalars['String']['input'];
|
||||
workspaceInviteHash?: InputMaybe<Scalars['String']['input']>;
|
||||
@ -614,6 +628,7 @@ export type QueryBillingPortalSessionArgs = {
|
||||
|
||||
|
||||
export type QueryCheckUserExistsArgs = {
|
||||
captchaToken?: InputMaybe<Scalars['String']['input']>;
|
||||
email: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
@ -117,10 +117,22 @@ export type BooleanFieldComparison = {
|
||||
isNot?: InputMaybe<Scalars['Boolean']>;
|
||||
};
|
||||
|
||||
export type Captcha = {
|
||||
__typename?: 'Captcha';
|
||||
provider?: Maybe<CaptchaDriverType>;
|
||||
siteKey?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export enum CaptchaDriverType {
|
||||
GoogleRecatpcha = 'GoogleRecatpcha',
|
||||
Turnstile = 'Turnstile'
|
||||
}
|
||||
|
||||
export type ClientConfig = {
|
||||
__typename?: 'ClientConfig';
|
||||
authProviders: AuthProviders;
|
||||
billing: Billing;
|
||||
captcha: Captcha;
|
||||
debugMode: Scalars['Boolean'];
|
||||
sentry: Sentry;
|
||||
signInPrefilled: Scalars['Boolean'];
|
||||
@ -289,6 +301,7 @@ export type MutationAuthorizeAppArgs = {
|
||||
|
||||
|
||||
export type MutationChallengeArgs = {
|
||||
captchaToken?: InputMaybe<Scalars['String']>;
|
||||
email: Scalars['String'];
|
||||
password: Scalars['String'];
|
||||
};
|
||||
@ -339,6 +352,7 @@ export type MutationRenewTokenArgs = {
|
||||
|
||||
|
||||
export type MutationSignUpArgs = {
|
||||
captchaToken?: InputMaybe<Scalars['String']>;
|
||||
email: Scalars['String'];
|
||||
password: Scalars['String'];
|
||||
workspaceInviteHash?: InputMaybe<Scalars['String']>;
|
||||
@ -456,6 +470,7 @@ export type QueryBillingPortalSessionArgs = {
|
||||
|
||||
|
||||
export type QueryCheckUserExistsArgs = {
|
||||
captchaToken?: InputMaybe<Scalars['String']>;
|
||||
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<Scalars['String']>;
|
||||
}>;
|
||||
|
||||
|
||||
@ -1049,6 +1065,7 @@ export type SignUpMutationVariables = Exact<{
|
||||
email: Scalars['String'];
|
||||
password: Scalars['String'];
|
||||
workspaceInviteHash?: InputMaybe<Scalars['String']>;
|
||||
captchaToken?: InputMaybe<Scalars['String']>;
|
||||
}>;
|
||||
|
||||
|
||||
@ -1071,6 +1088,7 @@ export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: '
|
||||
|
||||
export type CheckUserExistsQueryVariables = Exact<{
|
||||
email: Scalars['String'];
|
||||
captchaToken?: InputMaybe<Scalars['String']>;
|
||||
}>;
|
||||
|
||||
|
||||
@ -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<typeof useAuthorizeAppMu
|
||||
export type AuthorizeAppMutationResult = Apollo.MutationResult<AuthorizeAppMutation>;
|
||||
export type AuthorizeAppMutationOptions = Apollo.BaseMutationOptions<AuthorizeAppMutation, AuthorizeAppMutationVariables>;
|
||||
export const ChallengeDocument = gql`
|
||||
mutation Challenge($email: String!, $password: String!) {
|
||||
challenge(email: $email, password: $password) {
|
||||
mutation Challenge($email: String!, $password: String!, $captchaToken: String) {
|
||||
challenge(email: $email, password: $password, captchaToken: $captchaToken) {
|
||||
loginToken {
|
||||
...AuthTokenFragment
|
||||
}
|
||||
@ -1584,6 +1602,7 @@ export type ChallengeMutationFn = Apollo.MutationFunction<ChallengeMutation, Cha
|
||||
* variables: {
|
||||
* email: // value for 'email'
|
||||
* password: // value for 'password'
|
||||
* captchaToken: // value for 'captchaToken'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
@ -1805,11 +1824,12 @@ export type RenewTokenMutationHookResult = ReturnType<typeof useRenewTokenMutati
|
||||
export type RenewTokenMutationResult = Apollo.MutationResult<RenewTokenMutation>;
|
||||
export type RenewTokenMutationOptions = Apollo.BaseMutationOptions<RenewTokenMutation, RenewTokenMutationVariables>;
|
||||
export const SignUpDocument = gql`
|
||||
mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String) {
|
||||
mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $captchaToken: String) {
|
||||
signUp(
|
||||
email: $email
|
||||
password: $password
|
||||
workspaceInviteHash: $workspaceInviteHash
|
||||
captchaToken: $captchaToken
|
||||
) {
|
||||
loginToken {
|
||||
...AuthTokenFragment
|
||||
@ -1835,6 +1855,7 @@ export type SignUpMutationFn = Apollo.MutationFunction<SignUpMutation, SignUpMut
|
||||
* email: // value for 'email'
|
||||
* password: // value for 'password'
|
||||
* workspaceInviteHash: // value for 'workspaceInviteHash'
|
||||
* captchaToken: // value for 'captchaToken'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
@ -1922,8 +1943,8 @@ export type VerifyMutationHookResult = ReturnType<typeof useVerifyMutation>;
|
||||
export type VerifyMutationResult = Apollo.MutationResult<VerifyMutation>;
|
||||
export type VerifyMutationOptions = Apollo.BaseMutationOptions<VerifyMutation, VerifyMutationVariables>;
|
||||
export const CheckUserExistsDocument = gql`
|
||||
query CheckUserExists($email: String!) {
|
||||
checkUserExists(email: $email) {
|
||||
query CheckUserExists($email: String!, $captchaToken: String) {
|
||||
checkUserExists(email: $email, captchaToken: $captchaToken) {
|
||||
exists
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -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;
|
||||
}
|
@ -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(
|
||||
<RecoilRoot>
|
||||
<AppErrorBoundary>
|
||||
<RecoilDebugObserverEffect />
|
||||
<ApolloDevLogEffect />
|
||||
<BrowserRouter>
|
||||
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
|
||||
<IconsProvider>
|
||||
<ExceptionHandlerProvider>
|
||||
<ApolloProvider>
|
||||
<HelmetProvider>
|
||||
<ClientConfigProviderEffect />
|
||||
<ClientConfigProvider>
|
||||
<UserProviderEffect />
|
||||
<UserProvider>
|
||||
<ApolloMetadataClientProvider>
|
||||
<ObjectMetadataItemsProvider>
|
||||
<PrefetchDataProvider>
|
||||
<AppThemeProvider>
|
||||
<SnackBarProvider>
|
||||
<DialogManagerScope dialogManagerScopeId="dialog-manager">
|
||||
<DialogManager>
|
||||
<StrictMode>
|
||||
<PromiseRejectionEffect />
|
||||
<App />
|
||||
</StrictMode>
|
||||
</DialogManager>
|
||||
</DialogManagerScope>
|
||||
</SnackBarProvider>
|
||||
</AppThemeProvider>
|
||||
</PrefetchDataProvider>
|
||||
<PageChangeEffect />
|
||||
</ObjectMetadataItemsProvider>
|
||||
</ApolloMetadataClientProvider>
|
||||
</UserProvider>
|
||||
</ClientConfigProvider>
|
||||
</HelmetProvider>
|
||||
</ApolloProvider>
|
||||
</ExceptionHandlerProvider>
|
||||
</IconsProvider>
|
||||
</SnackBarProviderScope>
|
||||
</BrowserRouter>
|
||||
<CaptchaProvider>
|
||||
<RecoilDebugObserverEffect />
|
||||
<ApolloDevLogEffect />
|
||||
<BrowserRouter>
|
||||
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
|
||||
<IconsProvider>
|
||||
<ExceptionHandlerProvider>
|
||||
<ApolloProvider>
|
||||
<HelmetProvider>
|
||||
<ClientConfigProviderEffect />
|
||||
<ClientConfigProvider>
|
||||
<UserProviderEffect />
|
||||
<UserProvider>
|
||||
<ApolloMetadataClientProvider>
|
||||
<ObjectMetadataItemsProvider>
|
||||
<PrefetchDataProvider>
|
||||
<AppThemeProvider>
|
||||
<SnackBarProvider>
|
||||
<DialogManagerScope dialogManagerScopeId="dialog-manager">
|
||||
<DialogManager>
|
||||
<StrictMode>
|
||||
<PromiseRejectionEffect />
|
||||
<App />
|
||||
</StrictMode>
|
||||
</DialogManager>
|
||||
</DialogManagerScope>
|
||||
</SnackBarProvider>
|
||||
</AppThemeProvider>
|
||||
</PrefetchDataProvider>
|
||||
<PageChangeEffect />
|
||||
</ObjectMetadataItemsProvider>
|
||||
</ApolloMetadataClientProvider>
|
||||
</UserProvider>
|
||||
</ClientConfigProvider>
|
||||
</HelmetProvider>
|
||||
</ApolloProvider>
|
||||
</ExceptionHandlerProvider>
|
||||
</IconsProvider>
|
||||
</SnackBarProviderScope>
|
||||
</BrowserRouter>
|
||||
</CaptchaProvider>
|
||||
</AppErrorBoundary>
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
@ -1,8 +1,12 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CHALLENGE = gql`
|
||||
mutation Challenge($email: String!, $password: String!) {
|
||||
challenge(email: $email, password: $password) {
|
||||
mutation Challenge(
|
||||
$email: String!
|
||||
$password: String!
|
||||
$captchaToken: String
|
||||
) {
|
||||
challenge(email: $email, password: $password, captchaToken: $captchaToken) {
|
||||
loginToken {
|
||||
...AuthTokenFragment
|
||||
}
|
||||
|
@ -5,11 +5,13 @@ export const SIGN_UP = gql`
|
||||
$email: String!
|
||||
$password: String!
|
||||
$workspaceInviteHash: String
|
||||
$captchaToken: String
|
||||
) {
|
||||
signUp(
|
||||
email: $email
|
||||
password: $password
|
||||
workspaceInviteHash: $workspaceInviteHash
|
||||
captchaToken: $captchaToken
|
||||
) {
|
||||
loginToken {
|
||||
...AuthTokenFragment
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CHECK_USER_EXISTS = gql`
|
||||
query CheckUserExists($email: String!) {
|
||||
checkUserExists(email: $email) {
|
||||
query CheckUserExists($email: String!, $captchaToken: String) {
|
||||
checkUserExists(email: $email, captchaToken: $captchaToken) {
|
||||
exists
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ import { isVerifyPendingState } from '@/auth/states/isVerifyPendingState';
|
||||
import { workspacesState } from '@/auth/states/workspaces';
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
|
||||
import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState';
|
||||
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
|
||||
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
|
||||
@ -56,11 +57,12 @@ export const useAuth = () => {
|
||||
const goToRecoilSnapshot = useGotoRecoilSnapshot();
|
||||
|
||||
const handleChallenge = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
async (email: string, password: string, captchaToken?: string) => {
|
||||
const challengeResult = await challenge({
|
||||
variables: {
|
||||
email,
|
||||
password,
|
||||
captchaToken,
|
||||
},
|
||||
});
|
||||
|
||||
@ -133,8 +135,12 @@ export const useAuth = () => {
|
||||
);
|
||||
|
||||
const handleCrendentialsSignIn = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
const { loginToken } = await handleChallenge(email, password);
|
||||
async (email: string, password: string, captchaToken?: string) => {
|
||||
const { loginToken } = await handleChallenge(
|
||||
email,
|
||||
password,
|
||||
captchaToken,
|
||||
);
|
||||
setIsVerifyPendingState(true);
|
||||
|
||||
const { user, workspaceMember, workspace } = await handleVerify(
|
||||
@ -167,6 +173,9 @@ export const useAuth = () => {
|
||||
const supportChat = snapshot.getLoadable(supportChatState).getValue();
|
||||
const telemetry = snapshot.getLoadable(telemetryState).getValue();
|
||||
const isDebugMode = snapshot.getLoadable(isDebugModeState).getValue();
|
||||
const captchaProvider = snapshot
|
||||
.getLoadable(captchaProviderState)
|
||||
.getValue();
|
||||
const isClientConfigLoaded = snapshot
|
||||
.getLoadable(isClientConfigLoadedState)
|
||||
.getValue();
|
||||
@ -175,8 +184,6 @@ export const useAuth = () => {
|
||||
.getValue();
|
||||
|
||||
const initialSnapshot = emptySnapshot.map(({ set }) => {
|
||||
set(isClientConfigLoadedState, isClientConfigLoaded);
|
||||
set(isCurrentUserLoadedState, isCurrentUserLoaded);
|
||||
set(iconsState, iconsValue);
|
||||
set(authProvidersState, authProvidersValue);
|
||||
set(billingState, billing);
|
||||
@ -184,6 +191,9 @@ export const useAuth = () => {
|
||||
set(supportChatState, supportChat);
|
||||
set(telemetryState, telemetry);
|
||||
set(isDebugModeState, isDebugMode);
|
||||
set(captchaProviderState, captchaProvider);
|
||||
set(isClientConfigLoadedState, isClientConfigLoaded);
|
||||
set(isCurrentUserLoadedState, isCurrentUserLoaded);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
@ -196,7 +206,12 @@ export const useAuth = () => {
|
||||
);
|
||||
|
||||
const handleCredentialsSignUp = useCallback(
|
||||
async (email: string, password: string, workspaceInviteHash?: string) => {
|
||||
async (
|
||||
email: string,
|
||||
password: string,
|
||||
workspaceInviteHash?: string,
|
||||
captchaToken?: string,
|
||||
) => {
|
||||
setIsVerifyPendingState(true);
|
||||
|
||||
const signUpResult = await signUp({
|
||||
@ -204,6 +219,7 @@ export const useAuth = () => {
|
||||
email,
|
||||
password,
|
||||
workspaceInviteHash,
|
||||
captchaToken,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -3,7 +3,7 @@ import { Controller } from 'react-hook-form';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { IconGoogle, IconMicrosoft } from 'twenty-ui';
|
||||
|
||||
import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword';
|
||||
@ -11,6 +11,7 @@ import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm';
|
||||
import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle';
|
||||
import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft';
|
||||
import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash';
|
||||
import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
import { Loader } from '@/ui/feedback/loader/components/Loader';
|
||||
import { MainButton } from '@/ui/input/button/components/MainButton';
|
||||
@ -46,6 +47,9 @@ const StyledInputContainer = styled.div`
|
||||
`;
|
||||
|
||||
export const SignInUpForm = () => {
|
||||
const isRequestingCaptchaToken = useRecoilValue(
|
||||
isRequestingCaptchaTokenState,
|
||||
);
|
||||
const [authProviders] = useRecoilState(authProvidersState);
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const { handleResetPassword } = useHandleResetPassword();
|
||||
@ -63,7 +67,9 @@ export const SignInUpForm = () => {
|
||||
submitCredentials,
|
||||
} = useSignInUp(form);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const handleKeyDown = async (
|
||||
event: React.KeyboardEvent<HTMLInputElement>,
|
||||
) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
@ -222,12 +228,11 @@ export const SignInUpForm = () => {
|
||||
/>
|
||||
</StyledFullWidthMotionDiv>
|
||||
)}
|
||||
|
||||
<MainButton
|
||||
variant="secondary"
|
||||
title={buttonTitle}
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
if (signInUpStep === SignInUpStep.Init) {
|
||||
continueWithEmail();
|
||||
return;
|
||||
@ -243,11 +248,13 @@ export const SignInUpForm = () => {
|
||||
disabled={
|
||||
signInUpStep === SignInUpStep.Init
|
||||
? false
|
||||
: signInUpStep === SignInUpStep.Email
|
||||
? !form.watch('email')
|
||||
: !form.watch('email') ||
|
||||
!form.watch('password') ||
|
||||
form.formState.isSubmitting
|
||||
: isRequestingCaptchaToken
|
||||
? true
|
||||
: signInUpStep === SignInUpStep.Email
|
||||
? !form.watch('email')
|
||||
: !form.watch('email') ||
|
||||
!form.watch('password') ||
|
||||
form.formState.isSubmitting
|
||||
}
|
||||
fullWidth
|
||||
/>
|
||||
|
@ -4,6 +4,8 @@ import { useParams } from 'react-router-dom';
|
||||
|
||||
import { useNavigateAfterSignInUp } from '@/auth/sign-in-up/hooks/useNavigateAfterSignInUp';
|
||||
import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm';
|
||||
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
|
||||
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
|
||||
@ -48,24 +50,36 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
checkUserExists: { checkUserExistsQuery },
|
||||
} = useAuth();
|
||||
|
||||
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
|
||||
const { readCaptchaToken } = useReadCaptchaToken();
|
||||
|
||||
const continueWithEmail = useCallback(() => {
|
||||
requestFreshCaptchaToken();
|
||||
setSignInUpStep(SignInUpStep.Email);
|
||||
setSignInUpMode(
|
||||
isMatchingLocation(AppPath.SignInUp)
|
||||
? SignInUpMode.SignIn
|
||||
: SignInUpMode.SignUp,
|
||||
);
|
||||
}, [setSignInUpStep, setSignInUpMode, isMatchingLocation]);
|
||||
}, [isMatchingLocation, requestFreshCaptchaToken]);
|
||||
|
||||
const continueWithCredentials = useCallback(() => {
|
||||
const continueWithCredentials = useCallback(async () => {
|
||||
const token = await readCaptchaToken();
|
||||
if (!form.getValues('email')) {
|
||||
return;
|
||||
}
|
||||
checkUserExistsQuery({
|
||||
variables: {
|
||||
email: form.getValues('email').toLowerCase().trim(),
|
||||
captchaToken: token,
|
||||
},
|
||||
onError: (error) => {
|
||||
enqueueSnackBar(`${error.message}`, {
|
||||
variant: 'error',
|
||||
});
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
requestFreshCaptchaToken();
|
||||
if (data?.checkUserExists.exists) {
|
||||
setSignInUpMode(SignInUpMode.SignIn);
|
||||
} else {
|
||||
@ -74,10 +88,17 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
setSignInUpStep(SignInUpStep.Password);
|
||||
},
|
||||
});
|
||||
}, [setSignInUpStep, checkUserExistsQuery, form, setSignInUpMode]);
|
||||
}, [
|
||||
readCaptchaToken,
|
||||
form,
|
||||
checkUserExistsQuery,
|
||||
enqueueSnackBar,
|
||||
requestFreshCaptchaToken,
|
||||
]);
|
||||
|
||||
const submitCredentials: SubmitHandler<Form> = useCallback(
|
||||
async (data) => {
|
||||
const token = await readCaptchaToken();
|
||||
try {
|
||||
if (!data.email || !data.password) {
|
||||
throw new Error('Email and password are required');
|
||||
@ -91,11 +112,13 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
? await signInWithCredentials(
|
||||
data.email.toLowerCase().trim(),
|
||||
data.password,
|
||||
token,
|
||||
)
|
||||
: await signUpWithCredentials(
|
||||
data.email.toLowerCase().trim(),
|
||||
data.password,
|
||||
workspaceInviteHash,
|
||||
token,
|
||||
);
|
||||
|
||||
navigateAfterSignInUp(currentWorkspace, currentWorkspaceMember);
|
||||
@ -106,6 +129,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
|
||||
}
|
||||
},
|
||||
[
|
||||
readCaptchaToken,
|
||||
signInUpMode,
|
||||
isInviteMode,
|
||||
signInWithCredentials,
|
||||
|
@ -14,6 +14,7 @@ const validationSchema = z
|
||||
password: z
|
||||
.string()
|
||||
.regex(PASSWORD_REGEX, 'Password must contain at least 8 characters'),
|
||||
captchaToken: z.string().default(''),
|
||||
})
|
||||
.required();
|
||||
|
||||
|
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
import { CaptchaProviderScriptLoaderEffect } from '@/captcha/components/CaptchaProviderScriptLoaderEffect';
|
||||
|
||||
export const CaptchaProvider = ({ children }: React.PropsWithChildren) => {
|
||||
return (
|
||||
<>
|
||||
<div id="captcha-widget" data-size="invisible"></div>
|
||||
<CaptchaProviderScriptLoaderEffect />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,52 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
|
||||
import { getCaptchaUrlByProvider } from '@/captcha/utils/getCaptchaUrlByProvider';
|
||||
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
|
||||
import { CaptchaDriverType } from '~/generated/graphql';
|
||||
|
||||
export const CaptchaProviderScriptLoaderEffect = () => {
|
||||
const captchaProvider = useRecoilValue(captchaProviderState);
|
||||
const setIsCaptchaScriptLoaded = useSetRecoilState(
|
||||
isCaptchaScriptLoadedState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!captchaProvider?.provider || !captchaProvider.siteKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptUrl = getCaptchaUrlByProvider(
|
||||
captchaProvider.provider,
|
||||
captchaProvider.siteKey,
|
||||
);
|
||||
if (!scriptUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
let scriptElement: HTMLScriptElement | null = document.querySelector(
|
||||
`script[src="${scriptUrl}"]`,
|
||||
);
|
||||
if (!scriptElement) {
|
||||
scriptElement = document.createElement('script');
|
||||
scriptElement.src = scriptUrl;
|
||||
scriptElement.onload = () => {
|
||||
if (captchaProvider.provider === CaptchaDriverType.GoogleRecatpcha) {
|
||||
window.grecaptcha?.ready(() => {
|
||||
setIsCaptchaScriptLoaded(true);
|
||||
});
|
||||
} else {
|
||||
setIsCaptchaScriptLoaded(true);
|
||||
}
|
||||
};
|
||||
document.body.appendChild(scriptElement);
|
||||
}
|
||||
}, [
|
||||
captchaProvider?.provider,
|
||||
captchaProvider?.siteKey,
|
||||
setIsCaptchaScriptLoaded,
|
||||
]);
|
||||
|
||||
return <></>;
|
||||
};
|
@ -0,0 +1,22 @@
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
import { captchaTokenState } from '@/captcha/states/captchaTokenState';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
export const useReadCaptchaToken = () => {
|
||||
const readCaptchaToken = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
async () => {
|
||||
const existingCaptchaToken = snapshot
|
||||
.getLoadable(captchaTokenState)
|
||||
.getValue();
|
||||
|
||||
if (isDefined(existingCaptchaToken)) {
|
||||
return existingCaptchaToken;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { readCaptchaToken };
|
||||
};
|
@ -0,0 +1,77 @@
|
||||
import { useRecoilCallback, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { captchaTokenState } from '@/captcha/states/captchaTokenState';
|
||||
import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState';
|
||||
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
|
||||
import { CaptchaDriverType } from '~/generated-metadata/graphql';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
grecaptcha?: any;
|
||||
turnstile?: any;
|
||||
}
|
||||
}
|
||||
|
||||
export const useRequestFreshCaptchaToken = () => {
|
||||
const setCaptchaToken = useSetRecoilState(captchaTokenState);
|
||||
const setIsRequestingCaptchaToken = useSetRecoilState(
|
||||
isRequestingCaptchaTokenState,
|
||||
);
|
||||
|
||||
const requestFreshCaptchaToken = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
async () => {
|
||||
const captchaProvider = snapshot
|
||||
.getLoadable(captchaProviderState)
|
||||
.getValue();
|
||||
|
||||
if (isUndefinedOrNull(captchaProvider)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingCaptchaToken = snapshot
|
||||
.getLoadable(captchaTokenState)
|
||||
.getValue();
|
||||
|
||||
setIsRequestingCaptchaToken(true);
|
||||
|
||||
let captchaWidget: any;
|
||||
|
||||
switch (captchaProvider.provider) {
|
||||
case CaptchaDriverType.GoogleRecatpcha:
|
||||
window.grecaptcha
|
||||
.execute(captchaProvider.siteKey, {
|
||||
action: 'submit',
|
||||
})
|
||||
.then((token: string) => {
|
||||
setCaptchaToken(token);
|
||||
setIsRequestingCaptchaToken(false);
|
||||
});
|
||||
break;
|
||||
case CaptchaDriverType.Turnstile:
|
||||
if (isDefined(existingCaptchaToken)) {
|
||||
// If we already have a token, we don't need to request a new one as turnstile will
|
||||
// automatically refresh the token when the widget is rendered.
|
||||
setIsRequestingCaptchaToken(false);
|
||||
break;
|
||||
}
|
||||
// TODO: fix workspace-no-hardcoded-colors rule
|
||||
// eslint-disable-next-line @nx/workspace-no-hardcoded-colors
|
||||
captchaWidget = window.turnstile.render('#captcha-widget', {
|
||||
sitekey: captchaProvider.siteKey,
|
||||
});
|
||||
window.turnstile.execute(captchaWidget, {
|
||||
callback: (token: string) => {
|
||||
setCaptchaToken(token);
|
||||
setIsRequestingCaptchaToken(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[setCaptchaToken, setIsRequestingCaptchaToken],
|
||||
);
|
||||
|
||||
return { requestFreshCaptchaToken };
|
||||
};
|
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const captchaTokenState = createState<string | undefined>({
|
||||
key: 'captchaTokenState',
|
||||
defaultValue: undefined,
|
||||
});
|
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const isCaptchaScriptLoadedState = createState<boolean>({
|
||||
key: 'isCaptchaScriptLoadedState',
|
||||
defaultValue: false,
|
||||
});
|
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const isRequestingCaptchaTokenState = createState<boolean>({
|
||||
key: 'isRequestingCaptchaTokenState',
|
||||
defaultValue: false,
|
||||
});
|
@ -0,0 +1,16 @@
|
||||
import { CaptchaDriverType } from '~/generated-metadata/graphql';
|
||||
|
||||
export const getCaptchaUrlByProvider = (name: string, siteKey: string) => {
|
||||
if (!name) {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case CaptchaDriverType.GoogleRecatpcha:
|
||||
return `https://www.google.com/recaptcha/api.js?render=${siteKey}`;
|
||||
case CaptchaDriverType.Turnstile:
|
||||
return 'https://challenges.cloudflare.com/turnstile/v0/api.js';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
@ -3,6 +3,7 @@ import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
|
||||
import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState';
|
||||
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
|
||||
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
|
||||
@ -29,6 +30,8 @@ export const ClientConfigProviderEffect = () => {
|
||||
isClientConfigLoadedState,
|
||||
);
|
||||
|
||||
const setCaptchaProvider = useSetRecoilState(captchaProviderState);
|
||||
|
||||
const { data, loading } = useGetClientConfigQuery({
|
||||
skip: isClientConfigLoaded,
|
||||
});
|
||||
@ -55,6 +58,11 @@ export const ClientConfigProviderEffect = () => {
|
||||
release: data?.clientConfig?.sentry?.release,
|
||||
environment: data?.clientConfig?.sentry?.environment,
|
||||
});
|
||||
|
||||
setCaptchaProvider({
|
||||
provider: data?.clientConfig?.captcha?.provider,
|
||||
siteKey: data?.clientConfig?.captcha?.siteKey,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
data,
|
||||
@ -68,6 +76,7 @@ export const ClientConfigProviderEffect = () => {
|
||||
setSentryConfig,
|
||||
loading,
|
||||
setIsClientConfigLoaded,
|
||||
setCaptchaProvider,
|
||||
]);
|
||||
|
||||
return <></>;
|
||||
|
@ -29,6 +29,10 @@ export const GET_CLIENT_CONFIG = gql`
|
||||
environment
|
||||
release
|
||||
}
|
||||
captcha {
|
||||
provider
|
||||
siteKey
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -0,0 +1,8 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
import { Captcha } from '~/generated/graphql';
|
||||
|
||||
export const captchaProviderState = createState<Captcha | null>({
|
||||
key: 'captchaProviderState',
|
||||
defaultValue: null,
|
||||
});
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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>(AuthResolver);
|
||||
});
|
||||
|
@ -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<LoginToken> {
|
||||
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<LoginToken> {
|
||||
const user = await this.authService.signInUp({
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -18,4 +18,9 @@ export class SignUpInput {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
workspaceInviteHash?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
captchaToken?: string;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -0,0 +1 @@
|
||||
export const CAPTCHA_DRIVER = Symbol('CAPTCHA_DRIVER');
|
@ -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<boolean> {
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
||||
};
|
@ -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],
|
||||
};
|
||||
}
|
||||
}
|
@ -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<CaptchaValidateResult> {
|
||||
if (this.driver) {
|
||||
return await this.driver.validate(token);
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -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<CaptchaValidateResult> {
|
||||
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',
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { CaptchaValidateResult } from 'src/engine/integrations/captcha/interfaces';
|
||||
|
||||
export interface CaptchaDriver {
|
||||
validate(token: string): Promise<CaptchaValidateResult>;
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
export type CaptchaServerResponse = {
|
||||
success: boolean;
|
||||
challenge_ts: string;
|
||||
hostname: string;
|
||||
'error-codes': string[];
|
||||
};
|
@ -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<CaptchaValidateResult> {
|
||||
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',
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
@ -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<CaptchaModuleOptions> | undefined;
|
||||
} & Pick<ModuleMetadata, 'imports'> &
|
||||
Pick<FactoryProvider, 'inject'>;
|
||||
|
||||
export type CaptchaValidateResult = { success: boolean; error?: string };
|
@ -0,0 +1 @@
|
||||
export * from './captcha.interface';
|
@ -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()
|
||||
|
@ -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,
|
||||
}),
|
||||
|
Loading…
Reference in New Issue
Block a user