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

View File

@ -23,6 +23,8 @@ import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/consta
import { useMemo } from 'react';
import { SignInUpGlobalScopeFormEffect } from '@/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect';
import { SignInUpTwoFactorAuthenticationProvision } from '@/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationProvision';
import { SignInUpTOTPVerification } from '@/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationVerification';
import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { useLingui } from '@lingui/react/macro';
@ -55,8 +57,12 @@ const StandardContent = ({
</AnimatedEaseIn>
<Title animate>{title}</Title>
{signInUpForm}
{signInUpStep !== SignInUpStep.Password &&
signInUpStep !== SignInUpStep.WorkspaceSelection && <FooterNote />}
{![
SignInUpStep.Password,
SignInUpStep.TwoFactorAuthenticationProvision,
SignInUpStep.TwoFactorAuthenticationVerification,
SignInUpStep.WorkspaceSelection,
].includes(signInUpStep) && <FooterNote />}
</Modal.Content>
);
};
@ -91,6 +97,14 @@ export const SignInUp = () => {
return t`Choose a Workspace`;
}
if (signInUpStep === SignInUpStep.TwoFactorAuthenticationProvision) {
return t`Setup your 2FA`;
}
if (signInUpStep === SignInUpStep.TwoFactorAuthenticationVerification) {
return t`Verify code from the app`;
}
const workspaceName = !isDefined(workspacePublicData?.displayName)
? DEFAULT_WORKSPACE_NAME
: workspacePublicData?.displayName === ''
@ -124,6 +138,15 @@ export const SignInUp = () => {
) {
return <SignInUpSSOIdentityProviderSelection />;
}
if (signInUpStep === SignInUpStep.TwoFactorAuthenticationProvision) {
return <SignInUpTwoFactorAuthenticationProvision />;
}
if (signInUpStep === SignInUpStep.TwoFactorAuthenticationVerification) {
return <SignInUpTOTPVerification />;
}
if (isDefined(workspacePublicData) && isOnAWorkspace) {
return (
<>

View File

@ -1,20 +1,36 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ChangePassword } from '@/settings/profile/components/ChangePassword';
import { DeleteAccount } from '@/settings/profile/components/DeleteAccount';
import { EmailField } from '@/settings/profile/components/EmailField';
import { NameFields } from '@/settings/profile/components/NameFields';
import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader';
import { useCurrentUserWorkspaceTwoFactorAuthentication } from '@/settings/two-factor-authentication/hooks/useCurrentUserWorkspaceTwoFactorAuthentication';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { H2Title } from 'twenty-ui/display';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { H2Title, IconShield, Status } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
export const SettingsProfile = () => {
const { t } = useLingui();
const { currentUserWorkspaceTwoFactorAuthenticationMethods } =
useCurrentUserWorkspaceTwoFactorAuthentication();
const isTwoFactorAuthenticationEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_TWO_FACTOR_AUTHENTICATION_ENABLED,
);
const has2FAMethod =
currentUserWorkspaceTwoFactorAuthenticationMethods['TOTP']?.status ===
'VERIFIED';
return (
<SubMenuTopBarContainer
title={t`Profile`}
@ -45,6 +61,32 @@ export const SettingsProfile = () => {
/>
<EmailField />
</Section>
{isTwoFactorAuthenticationEnabled && (
<Section>
<H2Title
title={t`Two Factor Authentication`}
description={t`Enhances security by requiring a code along with your password`}
/>
<UndecoratedLink
to={getSettingsPath(
SettingsPath.TwoFactorAuthenticationStrategyConfig,
{ twoFactorAuthenticationStrategy: 'TOTP' },
)}
>
<SettingsCard
title={t`Authenticator App`}
Icon={<IconShield />}
Status={
has2FAMethod ? (
<Status text={'Active'} color={'turquoise'} />
) : (
<Status text={'Setup'} color={'blue'} />
)
}
/>
</UndecoratedLink>
</Section>
)}
<Section>
<ChangePassword />
</Section>

View File

@ -0,0 +1,172 @@
import styled from '@emotion/styled';
import { Trans, useLingui } from '@lingui/react/macro';
import { FormProvider } from 'react-hook-form';
import QRCode from 'react-qr-code';
import { useRecoilValue } from 'recoil';
import { qrCodeState } from '@/auth/states/qrCode';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { DeleteTwoFactorAuthentication } from '@/settings/two-factor-authentication/components/DeleteTwoFactorAuthenticationMethod';
import { TwoFactorAuthenticationSetupForSettingsEffect } from '@/settings/two-factor-authentication/components/TwoFactorAuthenticationSetupForSettingsEffect';
import {
TwoFactorAuthenticationVerificationForSettings,
useTwoFactorVerificationForSettings,
} from '@/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationForSettings';
import { useCurrentUserWorkspaceTwoFactorAuthentication } from '@/settings/two-factor-authentication/hooks/useCurrentUserWorkspaceTwoFactorAuthentication';
import { extractSecretFromOtpUri } from '@/settings/two-factor-authentication/utils/extractSecretFromOtpUri';
import { SettingsPath } from '@/types/SettingsPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useTheme } from '@emotion/react';
import { H2Title, IconCopy } from 'twenty-ui/display';
import { Loader } from 'twenty-ui/feedback';
import { Section } from 'twenty-ui/layout';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledQRCodeContainer = styled.div`
margin: ${({ theme }) => theme.spacing(4)} 0;
`;
const StyledInstructions = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
margin-bottom: ${({ theme }) => theme.spacing(4)};
max-width: 400px;
`;
const StyledDivider = styled.div`
width: 100%;
height: 1px;
background-color: ${({ theme }) => theme.border.color.light};
margin: ${({ theme }) => theme.spacing(6)} 0;
`;
const StyledCopySetupKeyLink = styled.button`
background: none;
border: none;
color: ${({ theme }) => theme.font.color.secondary};
cursor: pointer;
display: flex;
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
font-size: ${({ theme }) => theme.font.size.sm};
margin-top: ${({ theme }) => theme.spacing(2)};
padding: 0;
text-decoration: underline;
&:hover {
color: ${({ theme }) => theme.font.color.primary};
}
`;
export const SettingsTwoFactorAuthenticationMethod = () => {
const { t } = useLingui();
const theme = useTheme();
const { enqueueSuccessSnackBar } = useSnackBar();
const qrCode = useRecoilValue(qrCodeState);
const { currentUserWorkspaceTwoFactorAuthenticationMethods } =
useCurrentUserWorkspaceTwoFactorAuthentication();
const has2FAMethod =
currentUserWorkspaceTwoFactorAuthenticationMethods['TOTP']?.status ===
'VERIFIED';
const verificationForm = useTwoFactorVerificationForSettings();
const shouldShowActionButtons = !has2FAMethod;
const handleCopySetupKey = async () => {
if (!qrCode) return;
const secret = extractSecretFromOtpUri(qrCode);
if (secret !== null) {
await navigator.clipboard.writeText(secret);
enqueueSuccessSnackBar({
message: t`Setup key copied to clipboard`,
options: {
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
},
});
}
};
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<FormProvider {...verificationForm.formConfig}>
<SubMenuTopBarContainer
title={t`Two Factor Authentication`}
links={[
{
children: <Trans>User</Trans>,
href: getSettingsPath(SettingsPath.ProfilePage),
},
{
children: <Trans>Profile</Trans>,
href: getSettingsPath(SettingsPath.ProfilePage),
},
{
children: <Trans>Two-Factor Authentication</Trans>,
},
]}
actionButton={
shouldShowActionButtons ? (
<SaveAndCancelButtons
isSaveDisabled={!verificationForm.canSave}
isCancelDisabled={verificationForm.isSubmitting}
isLoading={verificationForm.isLoading}
onCancel={verificationForm.handleCancel}
onSave={verificationForm.formConfig.handleSubmit(
verificationForm.handleSave,
)}
/>
) : undefined
}
>
<SettingsPageContainer>
{has2FAMethod ? (
<Section>
<DeleteTwoFactorAuthentication />
</Section>
) : (
<Section>
<TwoFactorAuthenticationSetupForSettingsEffect />
<H2Title title={t`1. Scan the QR code`} />
<StyledInstructions>
<Trans>
Use an authenticator app like Google Authenticator, Authy, or
Microsoft Authenticator to scan this QR code.
</Trans>
</StyledInstructions>
<StyledQRCodeContainer>
{!qrCode ? <Loader /> : <QRCode value={qrCode} />}
{qrCode && (
<StyledCopySetupKeyLink onClick={handleCopySetupKey}>
<IconCopy size={theme.icon.size.sm} />
<Trans>Copy Setup Key</Trans>
</StyledCopySetupKeyLink>
)}
</StyledQRCodeContainer>
<StyledDivider />
<H2Title title={t`2. Enter the code`} />
<StyledInstructions>
<Trans>
Enter the 6-digit verification code from your authenticator
app to complete the setup.
</Trans>
</StyledInstructions>
<TwoFactorAuthenticationVerificationForSettings />
</Section>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
</FormProvider>
);
};

View File

@ -21,11 +21,11 @@ const StyledTableCell = styled(TableCell)`
display: block;
padding: 0 ${({ theme }) => theme.spacing(3)} 0 0;
&:first-child {
&:first-of-type {
padding-left: 0;
}
&:last-child {
&:last-of-type {
padding-right: 0;
}
`;