DEV Community

loading...
Cover image for Connecting existing users database to AWS Cognito: How to leverage Passwordless Authentication to use legacy database?

Connecting existing users database to AWS Cognito: How to leverage Passwordless Authentication to use legacy database?

_mikigraf profile image Miki Graf Originally published at Medium ・9 min read

There are two fundamental problems with passwords and with the way we use them today. No matter what UI welcomes you on the website and no matter how much work UX Designers put into products, we still are using the same way of user authentication as we did 10 or 20 years ago. The first step is for a user to visit your website and submit his username and password through a form. This is not secure, so developers came up with the idea of 2-factor-authentication. After submitting login credentials, the user gets a message via email or another means of communication and then he has to verify his ownership of this communication device by submitting provided security code through another form. This means, that as a user, you are left with two forms. Forms are not fun.

AWS Cognito makes it possible to create Custom Authentication Flow, that allows developers to design their own flows. This can be used for creating passwordless authentication or for connecting existing user database.
There are two scenarions, that are usually used with Custom Authentication Flow:

  1. Passwordless Authentication
  2. Authenticating users against already existing database

Our scenario was #2: we wanted to authenticate users against already existing database, that was hosted outside of AWS.

Why would you want to use existing database instead of migrating users to AWS Cognito?

Well, in our case we wanted to leverage AWS Amplify for user authentication during rapid prototyping. From my understanding, migrating users to AWS Cognito would require them to change their password and this is something, that was not wished, especially since requiring all of your customers to change their passwords may cause security concerns.

We wanted to use AWS Amplify with React.js for creating a prototype of an application. We have a mongoDB instance on mlab containing user data. Each user has a very simple structure:

With each user having username and hashed password.

The code presented in this blog post creates Custom Authentication Flow in AWS Cognito and connects to external database for user authentication. With very minimal changes, this code could be used for implementing passwordless authentication, that is based on user getting randomly generated token via email.

This implementation is based on the following blog post by AWS: https://aws.amazon.com/blogs/mobile/implementing-passwordless-email-authentication-with-amazon-cognito/ and reuses alot of code from this example https://github.com/aws-samples/amazon-cognito-passwordless-email-auth with the difference being, that we use React.js and connect to external database.

SAM Template

We create our infrastructure with AWS SAM, since it’s a native tools provided by AWS. We are able to reuse almost all of the code for this template from the original post.

We begin by installing SAM CLI from https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html

In the directory /infrastructure/ create template.yaml

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
  Amazon Cognito User Pool with Passwordless E-Mail Auth configured
Enter fullscreen mode Exit fullscreen mode

And setting up parameters

Parameters:
  UserPoolName:
    Type: String
    Description: The name you want the User Pool to be created with
    Default: 'UsingExistingDatabaseWithAWSCognito'
  DbConnectionString:
    Type: String
    Description: The e-mail address to send the secret login code from
    Default: "mongodb://<user>:<password>@<domain>:<port>/<database name>"
Enter fullscreen mode Exit fullscreen mode

UserPoolName is a variable containing the name for the user pool, that will be created by this template. DbConnectionString is a connection string to our existing MongoDB database.
First we need to create Cognito User Pool, which will hold user data after, so that we can leverage Amplify for easy user authentication.

Resources:
  UserPool:
    Type: "AWS::Cognito::UserPool"
    Properties:
      UserPoolName: !Ref UserPoolName
      Schema:
        - Name: name
          AttributeDataType: String
          Mutable: true
          Required: true
        - Name: email
          AttributeDataType: String
          Mutable: true
          Required: true
      Policies:
        PasswordPolicy:
          MinimumLength: 8
          RequireLowercase: false
          RequireNumbers: false
          RequireSymbols: false
          RequireUppercase: false
      UsernameAttributes:
        - email
      MfaConfiguration: "OFF"
      LambdaConfig:
        CreateAuthChallenge: !GetAtt CreateAuthChallenge.Arn
        DefineAuthChallenge: !GetAtt DefineAuthChallenge.Arn
        PreSignUp: !GetAtt PreSignUp.Arn
        VerifyAuthChallengeResponse: !GetAtt VerifyAuthChallengeResponse.Arn
Enter fullscreen mode Exit fullscreen mode

Custom Authentication Flow allows to assign lambda functions to a set of pre-defined Cognito Triggers. A list of possible triggers is available on https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools-working-with-aws-lambda-triggers.html

We also have to define a client for our user pool, so that we can use it to access this user pool with Custom Authentication Flow:

UserPoolClient:
    Type: "AWS::Cognito::UserPoolClient"
    Properties:
      ClientName: auth-with-existing-db
      GenerateSecret: false
      UserPoolId: !Ref UserPool
      ExplicitAuthFlows:
        - CUSTOM_AUTH_FLOW_ONLY
Enter fullscreen mode Exit fullscreen mode

Now we have a user pool, that references lambda functions, but we haven’t created any yet!

Let’s add just right before the definition of user pool, definitions for lambdas.

PreSignUp:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: lambda-triggers/00-pre-sign-up/
      Handler: pre-sign-up.handler
      Runtime: nodejs10.x
Enter fullscreen mode Exit fullscreen mode

PreSignUp is a function, that will mark user and his email address as confirmed. We also need to add invocation permission, so that the user pool can trigger this lambda.

PreSignUpInvocationPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt PreSignUp.Arn
      Principal: cognito-idp.amazonaws.com
      SourceArn: !GetAtt UserPool.Arn
Enter fullscreen mode Exit fullscreen mode

In /infrastructure/lambda-triggers/00-pre-sign-up/pre-sign-up.js you can add the following code, which will auto confirm user and his email address.

module.exports.handler = async event => {
    event.response.autoConfirmUser = true;
    event.response.autoVerifyEmail = true;
    return event;
};
Enter fullscreen mode Exit fullscreen mode

Viola, our first custom handler for Cognito triggers is done.

Define Auth Challenge Lambda

In /infrastructure/lambda-triggers/01-define-auth-challenge add a new file called define-auth-challenge.js and add this code:

module.exports.handler = async event => {
    if (event.request.session &&
        event.request.session.length >= 3 &&
        event.request.session.slice(-1)[0].challengeResult === false) {
        // The user provided a wrong answer 3 times; fail auth
        event.response.issueTokens = false;
        event.response.failAuthentication = true;
    } else if (event.request.session &&
        event.request.session.length &&
        event.request.session.slice(-1)[0].challengeResult === true) {
        // The user provided the right answer; succeed auth
        event.response.issueTokens = true;
        event.response.failAuthentication = false;
    } else {
        // The user did not provide a correct answer yet; present challenge
        event.response.issueTokens = false;
        event.response.failAuthentication = false;
        event.response.challengeName = 'CUSTOM_CHALLENGE';
    }

    return event;
};
Enter fullscreen mode Exit fullscreen mode

We check if the user provided the right answer, wrong answer or havent provided any answer yet. By this we define the flow of the authentication.

In template.yaml add right before the definition of UserPool:

Resources:
# Defines Authentication Challenge
# Checks if user is already authenticated etc.
# And decides on the next step
  DefineAuthChallenge:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: lambda-triggers/01-define-auth-challenge/
      Handler: define-auth-challenge.handler
      Runtime: nodejs10.x
Enter fullscreen mode Exit fullscreen mode

And right after the definition of UserPool add:

DefineAuthChallengeInvocationPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt DefineAuthChallenge.Arn
      Principal: cognito-idp.amazonaws.com
      SourceArn: !GetAtt UserPool.Arn
Enter fullscreen mode Exit fullscreen mode

Create Auth Challenge

This is where our implementation differs on the backend side from the original post. Initialize new project and install dependencies:

npm init
npm install --save mongoose
Enter fullscreen mode Exit fullscreen mode

And create create-auth-challenge.js with following code:


const mongoose = require('mongoose');

module.exports.handler = async event => {
    const connectionString = process.env.DB_CONNECTION_STRING

    try {
        mongoose.connect(connectionString);
    } catch(err) {

    }
    const { Schema } = mongoose;
    const userSchema = new Schema({
        username: {
            type: String,
            required: true
        },
        password: {
            type: String,
            required: true
        }
    });

    mongoose.models = {}
    const userModel = mongoose.model('User', userSchema);

    let password;

    if(!event.request.session || !event.request.session.length) {
        // new session, so fetch password from the db
        const username = event.request.userAttributes.email;
        const user = await userModel.findOne({ "username": username});
        password = user.password;
    } else {
        // There's an existing session. Don't generate new digits but
        // re-use the code from the current session. This allows the user to
        // make a mistake when keying in the code and to then retry, rather
        // the needing to e-mail the user an all new code again.    
        const previousChallenge = event.request.session.slice(-1)[0];
        password = previousChallenge.challengeMetadata.match(/PASSWORD-(\d*)/)[1];
    }

    // This is sent back to the client app
    event.response.publicChallengeParameters = { username: event.request.userAttributes.email };

    // Add the secret login code to the private challenge parameters
    // so it can be verified by the "Verify Auth Challenge Response" trigger
    event.response.privateChallengeParameters = { password };

    // Add the secret login code to the session so it is available
    // in a next invocation of the "Create Auth Challenge" trigger
    event.response.challengeMetadata = `PASSWORD-${password}`;

    mongoose.connection.close()
    return event;

}
Enter fullscreen mode Exit fullscreen mode

And define this lambda in template.yaml right before UserPool:

# Fetches password from existing user database
# And adds it to the event object,
# So that the next lambda can verify the response
  CreateAuthChallenge:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: lambda-triggers/02-create-auth-challenge/
      Handler: create-auth-challenge.handler
      Runtime: nodejs10.x
      Environment:
        Variables:
          DB_CONNECTION_STRING: !Ref DbConnectionString
Enter fullscreen mode Exit fullscreen mode

Don't forget to add invocation persmissions right after UserPool:

CreateAuthChallengeInvocationPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt CreateAuthChallenge.Arn
      Principal: cognito-idp.amazonaws.com
      SourceArn: !GetAtt UserPool.Arn
Enter fullscreen mode Exit fullscreen mode

VerifyAuthChallenge Lambda

Last lambda will compare hashed user input for password with password hash fetched from the database.

Create new file verify-auth-challenge-response.js in infrastructure/lambda-triggers/03-verify-auth-challenge/ and add this code:

const md5 = require('md5');

module.exports.handler = async event => {
    const expectedAnswer = event.request.privateChallengeParameters.password; 
    if (md5(event.request.challengeAnswer) === expectedAnswer) {
        event.response.answerCorrect = true;
    } else {
        event.response.answerCorrect = false;
    }
    return event;
};
Enter fullscreen mode Exit fullscreen mode

Add it in template.yaml before UserPool:

# Compares provided answer with password provided
# By CreateAuthChallenge lambda in the previous call
  VerifyAuthChallengeResponse:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: lambda-triggers/03-verify-auth-challenge/
      Handler: verify-auth-challenge-response.handler
      Runtime: nodejs10.x
Enter fullscreen mode Exit fullscreen mode

And after UserPool:

VerifyAuthChallengeResponseInvocationPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt VerifyAuthChallengeResponse.Arn
      Principal: cognito-idp.amazonaws.com
      SourceArn: !GetAtt UserPool.Arn
Enter fullscreen mode Exit fullscreen mode

And done! Now we have setup our backend for custom authentication flow, that will fetch user password hash from the database and compare it to the hashed input.

Deployment

In the infrastructure/ directory create package.json:

{
    "name": "cognito-email-auth-backend",
    "version": "1.0.0",
    "description": "This is a sample template for cognito-sam - Below is a brief explanation of what we have generated for you:",
    "main": "index.js",
    "scripts": {
      "test": "echo \"Error: no test specified\" && exit 1",
      "postinstall": "cd ./lambda-triggers/create-auth-challenge && npm i && cd -",
      "package": "sam package --template-file template.yaml --output-template-file packaged.yaml --s3-bucket ${S3_BUCKET_NAME}",
      "deploy": "sam deploy --template-file packaged.yaml --capabilities CAPABILITY_IAM --stack-name ${STACK_NAME} --parameter-overrides UserPoolName=${USER_POOL_NAME}",
      "check-env": "if [ -e ${S3_BUCKET_NAME} ] || [ -e ${USER_POOL_NAME} ] || [ -e ${STACK_NAME} ]  ]; then exit 1; fi",
      "bd": "npm run check-env && npm run package && npm run deploy",
      "publish": "npm run package && sam publish -t packaged.yaml --region us-east-1"
    },
    "keywords": [],
    "author": "",
    "license": "MIT",
    "dependencies": {
      "aws-sdk": "^2.382.0"
    },
    "devDependencies": {}
  }
Enter fullscreen mode Exit fullscreen mode

and run

npm run bd
Enter fullscreen mode Exit fullscreen mode

Frontend with React and Amplify

Create new React app and install the dependencies:

npx create-react-app client
npm install --save aws-amplify aws-amplify-react element-react react-router-dom
Enter fullscreen mode Exit fullscreen mode

In the src directory create new file called aws-exports.js

const awsmobile = {
"aws_project_region": "eu-central-1",
"aws_cognito_region": "eu-central-1",
"aws_user_pools_id": "<add id of your existing user pool created by running template.yaml>",
"aws_user_pools_web_client_id": "<add id of your client for cognito created by running template.yaml>",
};
export default awsmobile;
Enter fullscreen mode Exit fullscreen mode

The values can be found in the AWS Console in AWS Cognito User Pool.

Initialize Amplify in client/src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import Amplify from 'aws-amplify'
import aws_exports from './aws-exports'

Amplify.configure(aws_exports);

ReactDOM.render(<App />, document.getElementById('root'));
Enter fullscreen mode Exit fullscreen mode

Modify App.js

import React from 'react';
import './App.css';
import { Auth } from 'aws-amplify';
import { Form, Button, Input } from "element-react";
import PasswordInput from './components/passwordInput';

class App extends React.Component {
  state = {
    email: "",
    isLogged: false,
    thisUser: null
  };

  handleEmailInput = async event => {
    event.preventDefault();
    try {
      const thisUser = await Auth.signIn(this.state.email);
      this.setState({
        thisUser: thisUser,
        isLogged: true
      });
    } catch(e) {
      console.log(e);
      setTimeout( () => window.location.reload(), 2000)
    }
  }

  render() {
    const { email, isLogged, thisUser } = this.state;
    return (
      <div className="App">
        { /* login */ }
        <div>
          <Form className="login-form">
            <Form.Item label="email">
              <Input type="text" icon="user" placeholder="Email" onChange={email => this.setState({email})} />
            </Form.Item>
            <Form.Item>
              <Button type="primary" disabled={!email} onClick={this.handleEmailInput}>Sign In</Button>
            </Form.Item>
           {isLogged && <PasswordInput email={thisUser}/>}
          </Form>
        </div>
      </div>
    );
  };
}

export default App;
Enter fullscreen mode Exit fullscreen mode

And create new PasswordInput component in client/src/components/passwordInput.js:

import React from 'react';
import { Form, Button, Input } from "element-react";
import { Auth } from 'aws-amplify';


class PasswordInput extends React.Component {
constructor(props) {
    super();
    this.state = { 
        password: '',
        Auth: false
    }
}

handlePasswordInput = async event => {
    event.preventDefault();
    try {
       await Auth.sendCustomChallengeAnswer(this.props.email, this.state.password);
       this.isAuth();
    } catch(e) {
        console.log(e);
    }
};

isAuth = async () => {
    try {
        await Auth.currentSession();
        this.setState({ Auth: true });
    } catch(e) {
        console.log(e);
    }
;}

renderSuccess = () => {
    if (this.state.Auth) {
        return <h1>You are logged in!</h1>
      }
};

render() {   
    const { password } = this.state; 
 return (
      <div> 
        {this.renderSuccess()}
        <Form.Item label="password">
        <Input type="text" icon="user" placeholder="password" onChange={password => this.setState({password})} />
      </Form.Item>
      <Form.Item>
        <Button type="primary" disabled={!password} onClick={this.handlePasswordInput}>Sign In</Button>
      </Form.Item>
      </div>
    )
 }
}

export default PasswordInput;
Enter fullscreen mode Exit fullscreen mode

And deploy the frontend with:

amplify init
amplify add hosting
amplify push
amplify publish
Enter fullscreen mode Exit fullscreen mode

You can find the code on Github:
https://github.com/spejss/Passwordless-Authentication-with-React-and-AWS-Amplify

Discussion (0)

pic
Editor guide