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:
oliver
2025-07-23 06:42:01 -06:00
committed by GitHub
parent dd5ae66449
commit 4d3124f840
106 changed files with 5103 additions and 103 deletions
@@ -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`}
/>
</>
);
};
@@ -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 <></>;
};
@@ -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>
)}
/>
)}
/>
);
};
@@ -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
}
}
`;
@@ -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,
};
};
@@ -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,
};
};
@@ -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;
}
};