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:
+126
@@ -0,0 +1,126 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { useAuth } from '@/auth/hooks/useAuth';
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { useDeleteTwoFactorAuthenticationMethodMutation } from '~/generated-metadata/graphql';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
import { useCurrentUserWorkspaceTwoFactorAuthentication } from '../hooks/useCurrentUserWorkspaceTwoFactorAuthentication';
|
||||
import { useCurrentWorkspaceTwoFactorAuthenticationPolicy } from '../hooks/useWorkspaceTwoFactorAuthenticationPolicy';
|
||||
|
||||
const DELETE_TWO_FACTOR_AUTHENTICATION_MODAL_ID =
|
||||
'delete-two-factor-authentication-modal';
|
||||
export const DeleteTwoFactorAuthentication = () => {
|
||||
const { t } = useLingui();
|
||||
const { openModal } = useModal();
|
||||
|
||||
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
|
||||
const { signOut, loadCurrentUser } = useAuth();
|
||||
const [deleteTwoFactorAuthenticationMethod] =
|
||||
useDeleteTwoFactorAuthenticationMethodMutation();
|
||||
const currentUser = useRecoilValue(currentUserState);
|
||||
const userEmail = currentUser?.email;
|
||||
const navigate = useNavigateSettings();
|
||||
const twoFactorAuthenticationStrategy =
|
||||
useParams().twoFactorAuthenticationStrategy;
|
||||
|
||||
const { currentUserWorkspaceTwoFactorAuthenticationMethods } =
|
||||
useCurrentUserWorkspaceTwoFactorAuthentication();
|
||||
|
||||
const { isEnforced: isTwoFactorAuthenticationEnforced } =
|
||||
useCurrentWorkspaceTwoFactorAuthenticationPolicy();
|
||||
|
||||
const reset2FA = async () => {
|
||||
if (
|
||||
!isDefined(twoFactorAuthenticationStrategy) ||
|
||||
!isDefined(
|
||||
currentUserWorkspaceTwoFactorAuthenticationMethods[
|
||||
twoFactorAuthenticationStrategy
|
||||
]?.twoFactorAuthenticationMethodId,
|
||||
)
|
||||
) {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Invalid 2FA information.`,
|
||||
options: {
|
||||
dedupeKey: '2fa-dedupe-key',
|
||||
},
|
||||
});
|
||||
return navigate(SettingsPath.ProfilePage);
|
||||
}
|
||||
|
||||
await deleteTwoFactorAuthenticationMethod({
|
||||
variables: {
|
||||
twoFactorAuthenticationMethodId:
|
||||
currentUserWorkspaceTwoFactorAuthenticationMethods[
|
||||
twoFactorAuthenticationStrategy
|
||||
].twoFactorAuthenticationMethodId,
|
||||
},
|
||||
});
|
||||
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`2FA Method has been deleted successfully.`,
|
||||
options: {
|
||||
dedupeKey: '2fa-dedupe-key',
|
||||
},
|
||||
});
|
||||
|
||||
if (isTwoFactorAuthenticationEnforced === true) {
|
||||
await signOut();
|
||||
} else {
|
||||
navigate(SettingsPath.ProfilePage);
|
||||
await loadCurrentUser();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<H2Title
|
||||
title={t`Delete Two-Factor Authentication Method`}
|
||||
description={t`Deleting this method will remove it permanently from your account.`}
|
||||
/>
|
||||
|
||||
<Button
|
||||
accent="danger"
|
||||
onClick={() => openModal(DELETE_TWO_FACTOR_AUTHENTICATION_MODAL_ID)}
|
||||
variant="secondary"
|
||||
title={t`Reset 2FA`}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
confirmationValue={userEmail}
|
||||
confirmationPlaceholder={userEmail ?? ''}
|
||||
modalId={DELETE_TWO_FACTOR_AUTHENTICATION_MODAL_ID}
|
||||
title={t`2FA Method Reset`}
|
||||
subtitle={
|
||||
isTwoFactorAuthenticationEnforced ? (
|
||||
<>
|
||||
This will permanently delete your two factor authentication
|
||||
method.
|
||||
<br />
|
||||
Since 2FA is mandatory in your workspace, you will be logged out
|
||||
after deletion and will be asked to configure it again upon login.{' '}
|
||||
<br />
|
||||
Please type in your email to confirm.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This action cannot be undone. This will permanently reset your two
|
||||
factor authentication method. <br /> Please type in your email to
|
||||
confirm.
|
||||
</>
|
||||
)
|
||||
}
|
||||
onConfirmClick={reset2FA}
|
||||
confirmButtonText={t`Reset 2FA`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { qrCodeState } from '@/auth/states/qrCode';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { gql, useMutation } from '@apollo/client';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const INITIATE_OTP_PROVISIONING_FOR_AUTHENTICATED_USER = gql`
|
||||
mutation initiateOTPProvisioningForAuthenticatedUser {
|
||||
initiateOTPProvisioningForAuthenticatedUser {
|
||||
uri
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const TwoFactorAuthenticationSetupForSettingsEffect = () => {
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const qrCode = useRecoilValue(qrCodeState);
|
||||
const setQrCodeState = useSetRecoilState(qrCodeState);
|
||||
const { t } = useLingui();
|
||||
|
||||
const [initiateOTPProvisioningForAuthenticatedUser] = useMutation(
|
||||
INITIATE_OTP_PROVISIONING_FOR_AUTHENTICATED_USER,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefined(qrCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleTwoFactorAuthenticationProvisioningInitiation = async () => {
|
||||
try {
|
||||
const initiateOTPProvisioningResult =
|
||||
await initiateOTPProvisioningForAuthenticatedUser();
|
||||
|
||||
if (
|
||||
!initiateOTPProvisioningResult.data
|
||||
?.initiateOTPProvisioningForAuthenticatedUser.uri
|
||||
) {
|
||||
throw new Error('No URI returned from OTP provisioning');
|
||||
}
|
||||
|
||||
setQrCodeState(
|
||||
initiateOTPProvisioningResult.data
|
||||
.initiateOTPProvisioningForAuthenticatedUser.uri,
|
||||
);
|
||||
} catch (error) {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Two factor authentication provisioning failed.`,
|
||||
options: {
|
||||
dedupeKey:
|
||||
'two-factor-authentication-provisioning-initiation-failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleTwoFactorAuthenticationProvisioningInitiation();
|
||||
|
||||
// Two factor authentication provisioning only needs to run once at mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { OTPInput, SlotProps } from 'input-otp';
|
||||
import { useState } from 'react';
|
||||
import { Controller, useForm, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { useAuth } from '@/auth/hooks/useAuth';
|
||||
import { VERIFY_TWO_FACTOR_AUTHENTICATION_METHOD_FOR_AUTHENTICATED_USER } from '@/settings/two-factor-authentication/graphql/mutations/verifyTwoFactorAuthenticationMethod';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
// OTP Form Types
|
||||
type OTPFormValues = {
|
||||
otp: string;
|
||||
};
|
||||
|
||||
const StyledOTPContainer = styled.div`
|
||||
display: flex;
|
||||
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
|
||||
&:has(:disabled) {
|
||||
opacity: 0.3;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSlotGroup = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledSlot = styled.div<{ isActive: boolean }>`
|
||||
position: relative;
|
||||
width: 2.5rem;
|
||||
height: 3.5rem;
|
||||
font-size: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
border-top: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-right: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
|
||||
&:first-of-type {
|
||||
border-left: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-top-left-radius: 0.375rem;
|
||||
border-bottom-left-radius: 0.375rem;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-top-right-radius: 0.375rem;
|
||||
border-bottom-right-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.group:hover &,
|
||||
.group:focus-within & {
|
||||
border-color: ${({ theme }) => theme.border.color.medium};
|
||||
}
|
||||
|
||||
outline: 0;
|
||||
outline-color: ${({ theme }) => theme.border.color.medium};
|
||||
|
||||
${({ isActive, theme }) =>
|
||||
isActive &&
|
||||
css`
|
||||
outline-width: 1px;
|
||||
outline-style: solid;
|
||||
outline-color: ${theme.border.color.strong};
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledPlaceholderChar = styled.div`
|
||||
.group:has(input[data-input-otp-placeholder-shown]) & {
|
||||
opacity: 0.2;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCaretContainer = styled.div`
|
||||
align-items: center;
|
||||
animation: caret-blink 1s steps(2, start) infinite;
|
||||
display: flex;
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
|
||||
@keyframes caret-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCaret = styled.div`
|
||||
width: 1px;
|
||||
height: 2rem;
|
||||
background-color: ${({ theme }) => theme.font.color.primary};
|
||||
`;
|
||||
|
||||
const StyledDashContainer = styled.div`
|
||||
display: flex;
|
||||
width: 2.5rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const StyledDash = styled.div`
|
||||
background-color: ${({ theme }) => theme.font.color.tertiary};
|
||||
border-radius: 9999px;
|
||||
height: 0.25rem;
|
||||
width: 0.75rem;
|
||||
`;
|
||||
|
||||
const FakeCaret = () => {
|
||||
return (
|
||||
<StyledCaretContainer>
|
||||
<StyledCaret />
|
||||
</StyledCaretContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const FakeDash = () => {
|
||||
return (
|
||||
<StyledDashContainer>
|
||||
<StyledDash />
|
||||
</StyledDashContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export const Slot = (props: SlotProps) => {
|
||||
return (
|
||||
<StyledSlot isActive={props.isActive}>
|
||||
<StyledPlaceholderChar>
|
||||
{props.char ?? props.placeholderChar}
|
||||
</StyledPlaceholderChar>
|
||||
{props.hasFakeCaret && <FakeCaret />}
|
||||
</StyledSlot>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTwoFactorVerificationForSettings = () => {
|
||||
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
|
||||
const navigate = useNavigateSettings();
|
||||
const { t } = useLingui();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { loadCurrentUser } = useAuth();
|
||||
|
||||
const [verifyTwoFactorAuthenticationMethod] = useMutation(
|
||||
VERIFY_TWO_FACTOR_AUTHENTICATION_METHOD_FOR_AUTHENTICATED_USER,
|
||||
);
|
||||
|
||||
const formConfig = useForm<OTPFormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
otp: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { isSubmitting } = formConfig.formState;
|
||||
const otpValue = formConfig.watch('otp');
|
||||
const canSave = !isSubmitting && otpValue?.length === 6;
|
||||
|
||||
const handleVerificationSuccess = async () => {
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Two-factor authentication setup completed successfully!`,
|
||||
});
|
||||
|
||||
// Reload current user to refresh 2FA status
|
||||
await loadCurrentUser();
|
||||
|
||||
// Navigate back to profile page
|
||||
navigate(SettingsPath.ProfilePage);
|
||||
};
|
||||
|
||||
const handleSave = async (values: OTPFormValues) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
await verifyTwoFactorAuthenticationMethod({
|
||||
variables: {
|
||||
otp: values.otp,
|
||||
},
|
||||
});
|
||||
|
||||
await handleVerificationSuccess();
|
||||
} catch (error) {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Invalid verification code. Please try again.`,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Reset form and navigate back to profile page
|
||||
formConfig.reset();
|
||||
navigate(SettingsPath.ProfilePage);
|
||||
};
|
||||
|
||||
return {
|
||||
formConfig,
|
||||
isLoading,
|
||||
canSave,
|
||||
isSubmitting,
|
||||
handleSave,
|
||||
handleCancel,
|
||||
};
|
||||
};
|
||||
|
||||
export const TwoFactorAuthenticationVerificationForSettings = () => {
|
||||
// Use the form context from the parent instead of creating a new form instance
|
||||
const formContext = useFormContext<OTPFormValues>();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name="otp"
|
||||
control={formContext.control}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<OTPInput
|
||||
maxLength={6}
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
render={({ slots }) => (
|
||||
<StyledOTPContainer>
|
||||
<StyledSlotGroup>
|
||||
{slots.slice(0, 3).map((slot, idx) => (
|
||||
<Slot
|
||||
key={idx}
|
||||
char={slot.char}
|
||||
placeholderChar={slot.placeholderChar}
|
||||
isActive={slot.isActive}
|
||||
hasFakeCaret={slot.hasFakeCaret}
|
||||
/>
|
||||
))}
|
||||
</StyledSlotGroup>
|
||||
<FakeDash />
|
||||
<StyledSlotGroup>
|
||||
{slots.slice(3).map((slot, idx) => (
|
||||
<Slot
|
||||
key={idx}
|
||||
char={slot.char}
|
||||
placeholderChar={slot.placeholderChar}
|
||||
isActive={slot.isActive}
|
||||
hasFakeCaret={slot.hasFakeCaret}
|
||||
/>
|
||||
))}
|
||||
</StyledSlotGroup>
|
||||
</StyledOTPContainer>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const VERIFY_TWO_FACTOR_AUTHENTICATION_METHOD_FOR_AUTHENTICATED_USER = gql`
|
||||
mutation verifyTwoFactorAuthenticationMethodForAuthenticatedUser(
|
||||
$otp: String!
|
||||
) {
|
||||
verifyTwoFactorAuthenticationMethodForAuthenticatedUser(otp: $otp) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
|
||||
import { useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import {
|
||||
TwoFactorAuthenticationMethodDto,
|
||||
useInitiateOtpProvisioningMutation,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const useCurrentUserWorkspaceTwoFactorAuthentication = () => {
|
||||
const currentUserWorkspace = useRecoilValue(currentUserWorkspaceState);
|
||||
const [initiateCurrentUserWorkspaceOtpProvisioning] =
|
||||
useInitiateOtpProvisioningMutation();
|
||||
|
||||
const currentUserWorkspaceTwoFactorAuthenticationMethods = useMemo(() => {
|
||||
const methods: Record<string, TwoFactorAuthenticationMethodDto> = {};
|
||||
|
||||
(currentUserWorkspace?.twoFactorAuthenticationMethodSummary ?? []).forEach(
|
||||
(method) => (methods[method.strategy] = method),
|
||||
);
|
||||
|
||||
return methods;
|
||||
}, [currentUserWorkspace]);
|
||||
|
||||
return {
|
||||
currentUserWorkspaceTwoFactorAuthenticationMethods,
|
||||
initiateCurrentUserWorkspaceOtpProvisioning,
|
||||
};
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
export const useCurrentWorkspaceTwoFactorAuthenticationPolicy = () => {
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
|
||||
return {
|
||||
isEnforced: currentWorkspace?.isTwoFactorAuthenticationEnforced ?? false,
|
||||
};
|
||||
};
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Extracts the secret from an OTP URI (otpauth://totp/...)
|
||||
* @param otpUri - The OTP URI containing the secret
|
||||
* @returns The secret string or null if not found
|
||||
*/
|
||||
export const extractSecretFromOtpUri = (otpUri: string): string | null => {
|
||||
try {
|
||||
const url = new URL(otpUri);
|
||||
return url.searchParams.get('secret');
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user