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:
- Passwordless Authentication
- 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
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>"
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
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
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
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
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;
};
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;
};
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
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
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
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;
}
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
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
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;
};
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
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
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": {}
}
and run
npm run bd
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
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;
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'));
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;
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;
And deploy the frontend with:
amplify init
amplify add hosting
amplify push
amplify publish
You can find the code on Github:
https://github.com/spejss/Passwordless-Authentication-with-React-and-AWS-Amplify
Top comments (0)