Front-End Web & Mobile

Extending Amazon Cognito with Email OTP for 2FA using Amazon SES

This blog post was contributed by Pratik Pednekar@McAfee, Kanishk Mahajan@AWS and Krishnaraj Barvathaya@McAfee


Amazon Cognito is a fully managed AWS service that scales to millions of users and lets you add user sign-up, sign-in, and access control to your web and mobile apps quickly and easily. The two main components of Amazon Cognito are user pools and identity pools. User pools are user directories that provide sign-up and sign-in options for your web and mobile app users. Identity pools provide AWS credentials to grant those users access to other AWS services.

Amazon Simple Email Service (SES) is a fully managed AWS service that lets you send email securely, globally, and at scale. It is a cost-effective, flexible, and scalable email service that enables developers to send mail from within any application.

You can add multi-factor authentication (MFA) to a Cognito user pool to protect the identity of your users. MFA adds a second authentication method that doesn’t rely solely on user name and password. One time passwords (OTPs) are a popular MFA choice for organizations looking to step up their security with two-factor authentication (2FA). OTPs delivered through email and SMS messages are a widely used form of 2FA that many organizations choose for user convenience, ease of administration, and low associated costs.

Cognito user pools currently only support SMS text messages or time-based one-time (TOTP) passwords as second factors in signing in your users and don’t provide out of the box support for the use of email based OTP as an MFA option.

A key feature of Amazon Cognito user pools is the ability to customize authentication flows such as during user sign-up, confirmation, sign-in (authentication) with AWS Lambda functions called Lambda triggers. In this blog post, we demonstrate how to extend Amazon Cognito with email based OTP using SES and AWS Lambda triggers.

Solution overview

In this blog post, you will use the Wild Rydes application from the AWS Identity: Using Cognito for serverless consumer apps workshop as a sample application to demonstrate how to extend Cognito with email based OTP as 2FA.

Note that when using email as a second factor for authentication, we recommend that you use a different channel for account recovery to reduce the risk of account takeover. In order to do that you set the account recovery settings in your user pool to “Phone only” to allow recovery using SMS to a verified phone number or set to “none” which means users will have to contact an administrator to reset their password.

In the Wild Rydes application, a single page React JS web app hosts the HTML, CSS, and JavaScript to render the front-end. Cognito provides user identity management and authentication functions to secure the backend API that is built using Amazon API Gateway and AWS Lambda. The architecture section of the Cognito workshop provides detailed description of the architecture and modules of the Wild Rydes application.

The Wild Rydes application uses a Cognito user pool to store user profile information and provide sign-up and sign-in capabilities and the React JS single page application that renders its front end integrates with Cognito for these purposes via the AWS Amplify JavaScript library.

It initiates the sign-in process from the React JS client that embeds the Amplify JavaScript library by setting the authenticationFlowType property as CUSTOM_AUTH in the amplify-config.js file. This file is used for manual configuration of the Amplify library. Once you set this property, then it triggers a chain of Lambda functions that implement our custom authentication for email based OTP.

The code snippet below show this property that you will need to add in your amplify-config.js file of the Wild Rydes application.

Auth.configure({ // other configurations... // ... authenticationFlowType: 'USER_SRP_AUTH' | 'USER_PASSWORD_AUTH' | 'CUSTOM_AUTH', })

Figure 1: Configure the authenticationFlowType property as CUSTOM_AUTH to initiate custom authentication flow with the Amplify JavaScript library

Define Auth Challenge Lambda Trigger

In this trigger, we layer the email based OTP verification mechanism on top of the Cognito Secure Remote Password (SRP) challenge response protocol that provides username and password authentication. This trigger implements a challenge loop, where the user moves from one challenge to the next. Then the loop repeats (or throws an error response) until all challenges are answered.

Cognito implements the first 2 challenges (SRP_A and PASSWORD_VERIFIER) that complete the username and password authentication flow for the user. We then define the 3rd challenge (CUSTOM_CHALLENGE) for email-based OTP, which triggers the Create Auth Challenge Lambda function that we will describe next. The code below shows the entire Lambda trigger code for the Define Auth Challenge Lambda Trigger in our solution:

exports.handler = async (event) => {
        if (event.request.session && event.request.session.length === 1
            && event.request.session[0].challengeName === 'SRP_A'
            && event.request.session[0].challengeResult === true) {
            //SRP_A is the first challenge, this will be implemented by cognito. Set next challenge as PASSWORD_VERIFIER.
            event.response.issueTokens = false;
            event.response.failAuthentication = false;
            event.response.challengeName = 'PASSWORD_VERIFIER';
            
        } else if (event.request.session && event.request.session.length === 2
            && event.request.session[1].challengeName === 'PASSWORD_VERIFIER'
            && event.request.session[1].challengeResult === true) {
            //If password verification is successful then set next challenge as CUSTOM_CHALLENGE.
            event.response.issueTokens = false;
            event.response.failAuthentication = false;
            event.response.challengeName = 'CUSTOM_CHALLENGE';
            
        } else if (event.request.session && event.request.session.length >= 5
            && event.request.session.slice(-1)[0].challengeName === 'CUSTOM_CHALLENGE'
            && event.request.session.slice(-1)[0].challengeResult === false) {
            //The user has exhausted 3 attempts to enter correct otp.
            event.response.issueTokens = false;
            event.response.failAuthentication = true;
            
        } else if (event.request.session  && event.request.session.slice(-1)[0].challengeName === 'CUSTOM_CHALLENGE'
            && event.request.session.slice(-1)[0].challengeResult === true) {
            //User entered the correct OTP. Issue tokens.
            event.response.issueTokens = true;
            event.response.failAuthentication = false;
            
        } else {
            //user did not provide a correct answer yet.
            event.response.issueTokens = false;
            event.response.failAuthentication = false;
            event.response.challengeName = 'CUSTOM_CHALLENGE';
        }

        return event;
};

Figure 2: Define Auth Challenge Lambda Trigger

Create Auth Challenge Lambda Trigger

This Lambda trigger generates the OTP and then integrates with Amazon SES to send the OTP as an email to the user. It is triggered from the Define Auth Challenge Lambda, described earlier, if the session length is equal to 2 (i.e. just after the PASSWORD_VERIFIER challenge is verified).

Also, in the custom logic, it allows 3 attempts for the user to enter the correct OTP. This is implemented in the code where it checks if the session length is greater than 2; the else clause reads the OTP from the previous session instead of creating and sending a new one. Figure 3 below shows the entire lambda code for the Create Auth Challenge Lambda Trigger in our solution:

exports.handler = async (event) => {

    const crypto = require('crypto')
    const aws = require('aws-sdk')
    
    let verificationCode = ""; 
    //Only called once after SRP_A and PASSWORD_VERIFIER challenges. Hence session.length == 2
    if (event.request.session.length === 2) {

        verificationCode = crypto.randomBytes(3).toString('hex');
        const mailRequest = {
            Source: 'no-reply@wildrydes.com', //ID Configured in SES as the source email id
            Destination: {
                ToAddresses: [
                    event.request.userAttributes["email"]
                ]
            },
            Message: {
                Subject: {
                    Data: 'WildRydes-OTP'
                },
                Body: {
                    Text: {
                        Data: 'Your password for secure login is ' + verificationCode
                    }
                }
            }
        }

        const ses = new aws.SES();
        await ses.sendEmail(mailRequest).promise();
      
    } else {
        //if the user makes a mistake, we utilize the verification code from the previous session so that the user can retry.
        const previousChallenge = event.request.session.slice(-1)[0];
        verificationCode = previousChallenge.challengeMetadata;
    }

    //add to privateChallengeParameters. This will be used by verify auth lambda.
    console.log(verificationCode)
    event.response.privateChallengeParameters = {
        "verificationCode": verificationCode
    };

    //add it to session, so its available during the next invocation.
    event.response.challengeMetadata = verificationCode;

    return event;

};

Figure 3: Create Auth Challenge Lambda Trigger

Verify Auth Challenge Lambda Trigger

This Lambda trigger validates that the challenge response provided by the client (challengeAnswer parameter) matches the expected response. The expected response from the user is contained in the privateChallengeParameters values that are returned by the Create Auth Challenge Lambda trigger. It then sets the answerCorrect attribute to true if the user successfully completed the challenge, or false otherwise. Figure 4 below shows the entire lambda code for the Verify Auth Challenge Lambda Trigger in our solution:

exports.handler = async (event) => {
    const expectedAnswer = event.request.privateChallengeParameters["verificationCode"];
    if (event.request.challengeAnswer === expectedAnswer) {
        event.response.answerCorrect = true;

    } else {
        event.response.answerCorrect = false;

    }
    return event;
};

Figure 4: Verify Auth Challenge Lambda Trigger

Walkthrough

In this section, you will go through the steps to deploy, customize, and then test the Wild Rydes application for successfully extending Cognito with email based OTP.

Solution Setup

  1. Step 0: Deploy and run the AWS Identity: Using Cognito for serverless consumer apps workshop. Follow the detailed step by step instructions in the workshop. We recommend that you at least complete and test all steps up to the completion of Module 2 of the workshop. This will provide you with an end to end deployment of using Cognito authentication with API Gateway based authorization for a single page React JS application.
  2. Step 1: Add a new authenticationFlowType property with the value of CUSTOM_AUTH in the Auth section of the amplify-config.js file. This file can be found in the amazon-cognito-identity-management-workshop/website/src folder of your cloned repository. Here’s how our amplify-config.js file looks like with this parameter.

const awsConfig = {
    Auth: {
        identityPoolId: <your cognito identity pool id>,
        region: 'us-east-2', 
        userPoolId: <your cognito user pool id>, 
        authenticationFlowType: 'CUSTOM_AUTH',
        userPoolWebClientId: <your cognito user pool web client id> 
    },
    API: {
        endpoints: [
            {
                name: 'WildRydesAPI',
                endpoint: <Your AWS API Gateway Invoke URL>, 
                region: 'us-east-2' 
            }
        ]
    },
    Storage: {
        bucket: '', //example: 'wildrydesbackend-profilepicturesbucket-1wgssc97ekdph'
        region: '' // example: 'us-east-2'
    }
}

export default awsConfig;

Figure 5: amplify-config.js file with a new authenticationFlowType parameter

  1. Step 2: Edit the onSubmitVerification method of the SignIn.js file found in the amazon-cognito-identity-management-workshop/website/src/auth folder of your cloned repository. Replace Auth.ConfirmSignIn with Auth.sendCustomChallengeAnswer. Here’s how our SignIn.js file’s OnSubmitVerification method looks like after this modification.
 async onSubmitVerification(e) {
    e.preventDefault();
    try {
        const data = await Auth.sendCustomChallengeAnswer(
        this.state.userObject,
        this.state.code
        );
        console.log('Cognito User Data:', data);
        const session = await Auth.currentSession();
        // console.log('Cognito User Access Token:', session.getAccessToken().getJwtToken());
        console.log('Cognito User Identity Token:', session.getIdToken().getJwtToken());
        // console.log('Cognito User Refresh Token', session.getRefreshToken().getToken());
        this.setState({ stage: 0, email: '', password: '', code: '' });
        this.props.history.replace('/app');
    } catch (err) {
        alert(err.message);
        console.error('Auth.confirmSignIn(): ', err);
    }
  }

Figure 6: SignIn.js with the modified onSubmitVerification method

  1. Step 3: Using SES to send emails is quick and easy. Complete steps 2 and 3 here in this SES Quickstart to verify your email address with Amazon SES and test sending your first email. Before you can send email from your email address through SES, you need to show SES that you own the email address by verifying it.
    1. Modify the Create Auth Challenge Lambda trigger in Figure 3 with your verified email address from SES. We have it set to a dummy ‘no-reply@wildrydes.com’ email address as shown in Figure 7. You need to replace this email address with your verified email address from SES.
const mailRequest = {
            Source: 'no-reply@wildrydes.com', //ID Configured in SES as the source email id

Figure 7: Replace the dummy email address in the Create Auth Challenge Lambda trigger with your verified SES email address

  1. Step 4: Add the three Lambda triggers to the Cognito user pool of the Wild Rydes application. Together, these three triggers orchestrate your customized authentication flow for email based OTP using Amazon SES.
    1. Navigate to the Amazon Cognito console. Select Manage User Pools and select the WildRydes user pool. Select App clients from the left panel and click on Show Details on the right panel. Under the Auth Flows Configuration section, select the Enable Lambda trigger based custom authentication checkbox as well as the Enable SRP (secure remote password) protocol based authentication (ALLOW_USER_SRP_AUTH) checkbox. Save your changes by clicking on the Save app client changes box at the bottom of the screen.
    2. Create 3 Node.js Lambda functions from the code that we have supplied you – i.e. create 1 Lambda function each for each of the 3 triggers. If you are new to Lambda, follow the simple instructions here to build Node.js lambda functions from the console . The console creates a Lambda function with a single source file named index.js. Copy/paste the code in Figure 2 to replace the index.js file for your Lambda function that implements Define Auth Challenge Lambda trigger in in the built-in code editor. To save your changes, choose Save and then select Deploy.
      1. Follow these steps above to create new Lambda functions for the Create Auth Challenge Lambda and Verify Auth Challenge Lambda triggers as well. Copy/paste the code in Figure 3 and Figure 4 respectively to replace the index.js files for the corresponding lambda functions.
      2. To send SES emails with the lambda function, the attached Lambda execution role must have relevant SES permissions. Attach this permission policy that allows access to SES email sending actions to the Lambda execution role of the Create Auth Challenge Lambda. Follow the steps outlined here to create and modify execution roles.
    3. Let’s now add each of the 3 Lambda functions as Triggers in the Cognito User Pool. Navigate to the Amazon Cognito console. Select Manage User Pools and select the WildRydes user pool. Next, select Triggers on the left panel of the console for the WildRydes user pool.
      1. From the right panel, select Define Auth Challenge as a Trigger option and select the Lambda function created in Step 4b that corresponds to it.
      2. From the right panel, select Create Auth Challenge as a Trigger option and select the Lambda function created in Step 4b that corresponds to it
      3. From the right panel, select Verify Auth Challenge as a Trigger option and select the Lambda function created in Step 4b that corresponds to it

Test

Test the email-based OTP with a user that has already completed the sign-up process to the Wild Rydes application.

  1. Go back to the home page of the Wild Rydes application. Click on Giddy up to sign in. Enter the username/password of an existing signed-up user.

Wild Rydes landing page with "Giddy up!" button.

Figure 8: Sign in to the Wild Rydes application

Sign in page with username and password fields. Press "Let's Ryde" button to continue.

Figure 9: Enter username/password of a signed-up user

On successful authentication, you will now be prompted to enter an OTP. Check your SES verified email address inbox and look for an email with a subject of ‘WildRydes-OTP’

Enter MFA code page with username and verification code feilds. Press "Verify" button to continue.

Figure 10: Provide email based OTP for MFA (Multi Factor Authentication)

Once signed in, click anywhere on the map of Seattle to indicate a pickup location, then select the Request button to call your ride. You should be informed of your unicorn’s arrival momentarily.

Map with "request" button. Recieve "Requesting Unicorn" message, then "Your unicorn, Shadowfax will be with you in 30 seconds" then "Shadowfax has arrived" message.

Figure 11: Request your unicorn ride

Cleanup

After you complete the setup and test outlined in this blog post, you can clean up the resources you created to avoid incurring additional charges.

  1. Follow the detailed steps in Module 4 of the Wild Rydes workshop to clean up all resources.
  2. If you no longer need to use the SES verified email address then you can delete the email identity in Amazon SES.

Conclusion

Amazon Cognito currently only support SMS text messages or time-based one-time (TOTP) passwords as second factors in signing in your users. However it doesn’t provide out of the box support for the use of email based OTP as an MFA option. A key feature of Cognito user pools is the ability to customize authentication flows such as during user sign-up, confirmation, sign-in (authentication) with Lambda functions called Lambda triggers. In this blog post, we demonstrated how you can extend Amazon Cognito with email based OTP using SES and AWS Lambda triggers.

For more information on extending Cognito using Lambda triggers visit the Cognito documentation to customize user pool workflows with Lambda triggers. Also, as next steps we recommend that you complete the optional extension at the end of Module 1 of the Wild Rydes workshop. These demonstrate how to trigger AWS Lambda functions during user pool operations such as user sign-up, confirmation, and sign-in. You can add authentication challenges, migrate users, and customize verification messages.