aws-amplify / amplify-ui

Amplify UI is a collection of accessible, themeable, performant React (and more!) components that can connect directly to the cloud.
https://ui.docs.amplify.aws
Apache License 2.0
931 stars 297 forks source link

Configure TOTP from within the app in Amplify UI v2 #2499

Open moghaddam01 opened 2 years ago

moghaddam01 commented 2 years ago

I'm trying to upgrade Amplify UI to v2 and since AmplifyTotpSetup has been removed from '@aws-amplify/ui-react' package, the question is what is the replacement for that in v2 to use it in ?

I want to allow my users to set up their 2FA from within the settings page of my app (i.e. not from the login page itself). For this, I want to show only the TOTP setup view from Amplify-UI - not the login/sign-up/etc view. With Amplify-UI v1, I used the AmplifyTotpSetup as a standalone component to achieve this:

 import { AmplifyTotpSetup } from '@aws-amplify/ui-react';

<AmplifyTotpSetup
    user={cognitoUser}
    issuer={`NEXT (${tenant})`}
    headerText=""
    handleAuthStateChange={() => loadCognitoUser()}
    className={classes.mfaEmbed}
/>

I already tried these:

Any guide would be appreciated.

Related issue: https://github.com/aws-amplify/amplify-ui/issues/1150

ErikCH commented 2 years ago

Hi @moghaddam01 !

You are correct, we no longer support the standalone AmplifyTotpSetup page. To access the Setup TOTP page your users would need to sign up or sign in first. We will then detect if they have TOTP and if it still needs to be setup, we'll show them the Setup TOTP page.

We are looking into adding in more setup pages, in the future. I'll mark this as a feature request.

In the meantime, you maybe better off with using the Amplify JS library to create your own Setup TOTP page, as described here. https://docs.amplify.aws/lib/auth/mfa/q/platform/js/

Then you can customize that page exactly the way you want it. Sorry about the confusion!

ErikCH commented 2 years ago

It's worth mentioning that you can customize the Setup TOTP page. You just can't use it as a standalone component, or have it be the initialState. https://ui.docs.amplify.aws/react/connected-components/authenticator/customization#update-setup-totp-qr-issuer-and-username

moghaddam01 commented 2 years ago

Hi @ErikCH! Thanks for your guidance!

I have implemented the standalone custom setup TOTP page and put the code here, maybe useful for other developers that have same problem or want to upgrade it to v2.

import { CognitoUserAmplify } from '@aws-amplify/ui';
import { Alert, Button, TextField } from '@mui/material';
import { Auth } from 'aws-amplify';
import QRCode from 'qrcode';
import React, { useState } from 'react';
import intl from 'react-intl-universal';
import { makeStyles } from 'tss-react/mui';

interface CustomSetupTOTPProps {
    user: CognitoUserAmplify | undefined;
    issuer: string;
    handleAuthStateChange: () => void;
}

const useStyles = makeStyles()(theme => ({
    form: {
        width: '100%',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        gap: theme.spacing(1.5),
        '& input': {
            padding: theme.spacing(1),
            width: '100%',
        },
        '& button': {
            padding: theme.spacing(2),
            width: '100%',
            textTransform: 'uppercase',
        },
    },
}));

export function CustomSetupTOTP(props: CustomSetupTOTPProps) {
    const [isLoading, setIsLoading] = useState(true);
    const [isVerifyingToken, setIsVerifyingToken] = useState(false);
    const [qrCode, setQrCode] = React.useState('');
    const [token, setToken] = React.useState('');
    const [errorMessage, setErrorMessage] = React.useState('');
    const { classes } = useStyles();

    const getTotpCode = (
        issuer: string,
        username: string,
        secret: string,
    ): string =>
        encodeURI(
            `otpauth://totp/${issuer}:${username}?secret=${secret}&issuer=${issuer}`,
        );

    const totpUsername = props.user?.getUsername() || '';

    const generateQRCode = React.useCallback(
        async (currentUser: CognitoUserAmplify): Promise<void> => {
            try {
                const newSecretKey = await Auth.setupTOTP(currentUser);
                const totpCode = getTotpCode(props.issuer, totpUsername, newSecretKey);
                const qrCodeImageSource = await QRCode.toDataURL(totpCode);
                setQrCode(qrCodeImageSource);
            } catch (error) {
                console.error(error);
            } finally {
                setIsLoading(false);
            }
        },
        [props.issuer, totpUsername],
    );

    const verifyTotpToken = () => {
        // After verifying, user will have TOTP account in his TOTP-generating app (like Google Authenticator)
        // Use the generated one-time password to verify the setup
        setErrorMessage('');
        setIsVerifyingToken(true);
        Auth.verifyTotpToken(props.user, token)
            .then(async () => {
                await Auth.setPreferredMFA(props.user, 'TOTP');
                props.handleAuthStateChange();
                return null;
            })
            .catch(e => {
                console.error(e);
                if (/Code mismatch/.test(e.toString())) {
                    setErrorMessage(intl.get('custom-setup-totp.security-code-mismatch'));
                }
            })
            .finally(() => setIsVerifyingToken(false));
    };

    React.useEffect(() => {
        if (!props.user) {
            return;
        }
        void generateQRCode(props.user);
    }, [generateQRCode, props.user]);

    const isValidToken = () => {
        return /^\d{6}$/gm.test(token);
    };

    return (
        <form onSubmit={() => false} className={classes.form}>
            {isLoading && <div>{intl.get('generic.loading')}</div>}
            {!isLoading && (
                <>
                    <img
                        data-amplify-qrcode
                        src={qrCode}
                        alt="qr code"
                        width="228"
                        height="228"
                    />
                    <div>{intl.get('custom-setup-totp.enter-security-code')}</div>
                    <TextField
                        variant="outlined"
                        onChange={e => {
                            setToken(e.target.value);
                        }}
                    ></TextField>
                    {errorMessage && (
                        <Alert
                            variant="filled"
                            severity="error"
                            onClose={() => {
                                setErrorMessage('');
                            }}
                        >
                            {errorMessage}
                        </Alert>
                    )}

                    <Button
                        size="large"
                        disabled={!isValidToken() || isVerifyingToken}
                        color="primary"
                        variant="contained"
                        type="submit"
                        onClick={verifyTotpToken}
                    >
                        {isVerifyingToken
                            ? intl.get('custom-setup-totp.verifying-security-code')
                            : intl.get('custom-setup-totp.verify-security-code')}
                    </Button>
                </>
            )}
        </form>
    );
}
ErikCH commented 2 years ago

Thanks @moghaddam01 for sharing this! I appreciate it!