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:
@ -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 (
|
||||
<>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user