Implement Two-Factor Authentication (2FA) (#13141)
Implementation is very simple Established authentication dynamic is intercepted at getAuthTokensFromLoginToken. If 2FA is required, a pattern similar to EmailVerification is executed. That is, getAuthTokensFromLoginToken mutation fails with either of the following errors: 1. TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED 2. TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED UI knows how to respond accordingly. 2FA provisioning occurs at the 2FA resolver. 2FA verification, currently only OTP, is handled by auth.resolver's getAuthTokensFromOTP --------- Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions <github-actions@twenty.com> Co-authored-by: Jean-Baptiste Ronssin <65334819+jbronssin@users.noreply.github.com> Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@@ -31,6 +31,42 @@ jest.mock('@/object-metadata/hooks/useRefreshObjectMetadataItem', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@/domain-manager/hooks/useOrigin', () => ({
|
||||
useOrigin: jest.fn().mockImplementation(() => ({
|
||||
origin: 'http://localhost',
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@/captcha/hooks/useRequestFreshCaptchaToken', () => ({
|
||||
useRequestFreshCaptchaToken: jest.fn().mockImplementation(() => ({
|
||||
requestFreshCaptchaToken: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@/auth/sign-in-up/hooks/useSignUpInNewWorkspace', () => ({
|
||||
useSignUpInNewWorkspace: jest.fn().mockImplementation(() => ({
|
||||
createWorkspace: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@/domain-manager/hooks/useRedirectToWorkspaceDomain', () => ({
|
||||
useRedirectToWorkspaceDomain: jest.fn().mockImplementation(() => ({
|
||||
redirectToWorkspaceDomain: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace', () => ({
|
||||
useIsCurrentLocationOnAWorkspace: jest.fn().mockImplementation(() => ({
|
||||
isOnAWorkspace: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain', () => ({
|
||||
useLastAuthenticatedWorkspaceDomain: jest.fn().mockImplementation(() => ({
|
||||
setLastAuthenticateWorkspaceDomain: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<MockedProvider mocks={Object.values(mocks)} addTypename={false}>
|
||||
<RecoilRoot>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
AuthTokenPair,
|
||||
useCheckUserExistsLazyQuery,
|
||||
useGetAuthTokensFromLoginTokenMutation,
|
||||
useGetAuthTokensFromOtpMutation,
|
||||
useGetCurrentUserLazyQuery,
|
||||
useGetLoginTokenFromCredentialsMutation,
|
||||
useGetLoginTokenFromEmailVerificationTokenMutation,
|
||||
@@ -74,12 +75,15 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { APP_LOCALES } from 'twenty-shared/translations';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { iconsState } from 'twenty-ui/display';
|
||||
import { AuthToken } from '~/generated/graphql';
|
||||
import { cookieStorage } from '~/utils/cookie-storage';
|
||||
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
|
||||
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
|
||||
import { loginTokenState } from '../states/loginTokenState';
|
||||
|
||||
export const useAuth = () => {
|
||||
const setTokenPair = useSetRecoilState(tokenPairState);
|
||||
const setLoginToken = useSetRecoilState(loginTokenState);
|
||||
const setCurrentUser = useSetRecoilState(currentUserState);
|
||||
const setAvailableWorkspaces = useSetRecoilState(availableWorkspacesState);
|
||||
const setCurrentWorkspaceMember = useSetRecoilState(
|
||||
@@ -114,6 +118,7 @@ export const useAuth = () => {
|
||||
const [getLoginTokenFromEmailVerificationToken] =
|
||||
useGetLoginTokenFromEmailVerificationTokenMutation();
|
||||
const [getCurrentUser] = useGetCurrentUserLazyQuery();
|
||||
const [getAuthTokensFromOtp] = useGetAuthTokensFromOtpMutation();
|
||||
|
||||
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
|
||||
|
||||
@@ -368,26 +373,16 @@ export const useAuth = () => {
|
||||
[setTokenPair],
|
||||
);
|
||||
|
||||
const handleGetAuthTokensFromLoginToken = useCallback(
|
||||
async (loginToken: string) => {
|
||||
const getAuthTokensResult = await getAuthTokensFromLoginToken({
|
||||
variables: {
|
||||
loginToken,
|
||||
origin,
|
||||
},
|
||||
});
|
||||
const handleSetLoginToken = useCallback(
|
||||
(token: AuthToken['token']) => {
|
||||
setLoginToken(token);
|
||||
},
|
||||
[setLoginToken],
|
||||
);
|
||||
|
||||
if (isDefined(getAuthTokensResult.errors)) {
|
||||
throw getAuthTokensResult.errors;
|
||||
}
|
||||
|
||||
if (!getAuthTokensResult.data?.getAuthTokensFromLoginToken) {
|
||||
throw new Error('No getAuthTokensFromLoginToken result');
|
||||
}
|
||||
|
||||
handleSetAuthTokens(
|
||||
getAuthTokensResult.data.getAuthTokensFromLoginToken.tokens,
|
||||
);
|
||||
const handleLoadWorkspaceAfterAuthentication = useCallback(
|
||||
async (authTokens: AuthTokenPair) => {
|
||||
handleSetAuthTokens(authTokens);
|
||||
|
||||
// TODO: We can't parallelize this yet because when loadCurrentUSer is loaded
|
||||
// then UserProvider updates its children and PrefetchDataProvider is triggered
|
||||
@@ -395,12 +390,59 @@ export const useAuth = () => {
|
||||
await refreshObjectMetadataItems();
|
||||
await loadCurrentUser();
|
||||
},
|
||||
[loadCurrentUser, handleSetAuthTokens, refreshObjectMetadataItems],
|
||||
);
|
||||
|
||||
const handleGetAuthTokensFromLoginToken = useCallback(
|
||||
async (loginToken: string) => {
|
||||
try {
|
||||
const getAuthTokensResult = await getAuthTokensFromLoginToken({
|
||||
variables: {
|
||||
loginToken: loginToken,
|
||||
origin,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDefined(getAuthTokensResult.errors)) {
|
||||
throw getAuthTokensResult.errors;
|
||||
}
|
||||
|
||||
if (!getAuthTokensResult.data?.getAuthTokensFromLoginToken) {
|
||||
throw new Error('No getAuthTokensFromLoginToken result');
|
||||
}
|
||||
|
||||
await handleLoadWorkspaceAfterAuthentication(
|
||||
getAuthTokensResult.data.getAuthTokensFromLoginToken.tokens,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ApolloError &&
|
||||
error.graphQLErrors[0]?.extensions?.subCode ===
|
||||
'TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED'
|
||||
) {
|
||||
handleSetLoginToken(loginToken);
|
||||
navigate(AppPath.SignInUp);
|
||||
setSignInUpStep(SignInUpStep.TwoFactorAuthenticationProvision);
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof ApolloError &&
|
||||
error.graphQLErrors[0]?.extensions?.subCode ===
|
||||
'TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED'
|
||||
) {
|
||||
handleSetLoginToken(loginToken);
|
||||
navigate(AppPath.SignInUp);
|
||||
setSignInUpStep(SignInUpStep.TwoFactorAuthenticationVerification);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
handleSetLoginToken,
|
||||
getAuthTokensFromLoginToken,
|
||||
loadCurrentUser,
|
||||
origin,
|
||||
handleSetAuthTokens,
|
||||
refreshObjectMetadataItems,
|
||||
handleLoadWorkspaceAfterAuthentication,
|
||||
setSignInUpStep,
|
||||
navigate,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -654,6 +696,32 @@ export const useAuth = () => {
|
||||
[buildRedirectUrl, redirect],
|
||||
);
|
||||
|
||||
const handleGetAuthTokensFromOTP = useCallback(
|
||||
async (otp: string, loginToken: string, captchaToken?: string) => {
|
||||
const getAuthTokensFromOtpResult = await getAuthTokensFromOtp({
|
||||
variables: {
|
||||
captchaToken,
|
||||
origin,
|
||||
otp,
|
||||
loginToken,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDefined(getAuthTokensFromOtpResult.errors)) {
|
||||
throw getAuthTokensFromOtpResult.errors;
|
||||
}
|
||||
|
||||
if (!getAuthTokensFromOtpResult.data?.getAuthTokensFromOTP) {
|
||||
throw new Error('No getAuthTokensFromLoginToken result');
|
||||
}
|
||||
|
||||
await handleLoadWorkspaceAfterAuthentication(
|
||||
getAuthTokensFromOtpResult.data.getAuthTokensFromOTP.tokens,
|
||||
);
|
||||
},
|
||||
[getAuthTokensFromOtp, origin, handleLoadWorkspaceAfterAuthentication],
|
||||
);
|
||||
|
||||
return {
|
||||
getLoginTokenFromCredentials: handleGetLoginTokenFromCredentials,
|
||||
getLoginTokenFromEmailVerificationToken:
|
||||
@@ -672,5 +740,6 @@ export const useAuth = () => {
|
||||
signInWithGoogle: handleGoogleLogin,
|
||||
signInWithMicrosoft: handleMicrosoftLogin,
|
||||
setAuthTokens: handleSetAuthTokens,
|
||||
getAuthTokensFromOTP: handleGetAuthTokensFromOTP,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user