Open In App

AWS Lambda and Amazon DynamoDB for Serverless Authentication

Last Updated : 12 Dec, 2023
Improve
Improve
Like Article
Like
Save
Share
Report

Serverless Authentication is a method of authenticating users in a cloud-based application without the need for traditional server management. It leverages services like AWS Lambda and Amazon DynamoDB to handle user authentication processes in a cost-effective and efficient manner. By using these serverless components, developers can create secure and responsive authentication systems that scale with the application’s demands. AWS Lambda is a powerful cloud computing service provided by AWS that allows you to run code without provisioning or managing servers. It operates on an event-driven model, meaning it executes functions in response to specific events or triggers, optimizing resource usage and cost efficiency. Amazon DynamoDB, on the other hand, is a highly scalable NoSQL database service also provided by AWS. It’s designed for applications that require fast and predictable performance at any scale. DynamoDB offers seamless scaling of read and write capacity to handle variable workloads, ensuring rapid and reliable data storage.

In this article, we will learn how to create a Serverless Authentication using AWS Lambda and DynamoDB.

Serverless Authentication using Lambda

How Authentication works?

When a user attempts to login into their account, the system prompts them to provide their credentials, which typically includes the username and password. The backend system then undertakes a critical verification process to ascertain the validity of these inputs. If, the provided credentials do not match any existing records, the system promptly communicates this to the user with a clear and concise message: “Wrong username or password”. If the provided credentials are correct, a string is generated i.e. called token which basically encapsulates the username of the user and gets stored in 2 places – a) in browser’s cookies and b) user’s tokens array in database. At the time of verification, whether the user is authenticated or not, we checks first, whether there is any specific token found in browser’s cookies or not. If found, then our backend service breaks the token and find the username encapsulated inside it. Now, it finds the user with that username in database. If no record found, then the user is not authenticated. Otherwise, it checks whether in the founded user’s tokens array, our token is present or not. If it is there, then the user is authenticated, otherwise not.

At the time of logout, we basically removes the token from both places: a) from browser’s cookies and b) the tokens array of the user. In this way, the authentication actually works.

Flow of Authentication Process

Create a DynamoDB Table

  • Login to your AWS account and go to DynamoDB service. Click upon the “Create Table” button to create a table “login-database”. For partition key, enter “username”.Creating DynamoDB Table
  • DynamoDB also offers a new option regarding the provisioning of read & write capacities: On-demand mode. Select the Table Settings as Customised Settings and then in Read Write capacity settings, select the capacity mode “On-demand”On Demand Capacity Mode
    • This mode is great if you have no idea about how much read and write capacity you will need. And you can of course always switch back to fixed limits, too. If you do know which limits make most sense for you, the traditional “in-advance” provisioning will be cheaper.
  • Click on the “Create Table” button at the end. Now, you can see your created table in the Tables tab.

Table-created

Create a Lambda Function

Now, we will create a lambda function for the backend of our API. The lambda function needs to handle the operations for sign up, sign in, sign out of users.

  • Login your AWS account and go to Lambda Service. Click upon the “Create Function” button to create a lambda function.
  • Select “Author from scratch” and write the function name as “login-api-lambda” and change the runtime to “Node.js 14.x”. Go with the default settings and hit “Create Function” at the end.create-lamda-function
  • Now, Lambda function is created. But, by default, no service has any permissions. So, we have to provide this lambda function some permissions to read/write access to DynamoDB. So, go to IAM console and go to Roles section. You can see some roles related to lambda function name. Open the role for your lambda function.
  • There you can find Attach Policies button inside Add Permissions. Click there and add the “AmazonDynamoDBFullAccess” policy.Adding "AmazonDynamoDBFullAccess" policy to Lambda
  • Now, lambda function is ready to access DynamoDB.

Create an API using API Gateway

Now, we will create our Login-API. We will use the API Gateway service. Let’s understand what we are gonna create, what will happen behind the scenes of the API —

We will create an API that will have the following routes:
/register POST to create an account for the user (Sign Up)
/login POST to login into the user account (Login)
/verify POST to verify whether the user is authenticated or not
/logout POST to logout user from their account
Our backend logic (lambda) is created earlier. We just have to create these API routes and
connect those with our lambda function.



To achieve this, follow the steps below —

  • Login to your AWS account and go to API Gateway Service. Click upon the “Create API” button and choose REST API (not Private) and click upon the “build” button to create a AWS REST API.

Creating REST API using API Gateway Service

  • You can see 3 types of API Endpoints —
    • Regional: It is intended for the clients in the same region. When clients are running in the same regions i.e. API is intended to serve a small number of clients with high demands, Regional API reduces connection overhead.
    • Edge Optimized: It is best for geographically distributed clients. API requests are routed to the nearest CloudFront Point of Presence(POP).
    • Private: It is an API endpoint that can only be accessed from my Amazon Virtual Private Cloud (VPC).
  • Let’s create an API of Regional Endpoint. Now our API is created. We just have to define all the HTTP methods our API will react to and all the resources our API will use.Creating API using API Gateway
  • Now create a resource providing resource name & resource path. Here, we are creating a resource with name “register” and path “register”.Creating resource: "register"
  • Now we have to define the methods (GET, POST etc) we want to handle. So, click on “Create Method” and select the method you want to define. I am selecting POST to register user in database. Now there are some API Integration Types (which kind of action you want to execute whenever a request hits this resource). We will select Lambda Function and from all the lambda functions, we have to choose that lambda function we created earlier i.e. “login-api-lambda”. Now, save it.Creating method: POST in "register" resource
  • Now, inside the Integration Request, go to Mapping Template and check “When there are no template defined(recommended)” and create a mapping template with “application/json‘ and generate template using “Method Request Passthrough”. You can replace all commands with the code given in the mapping template below & save the code. Yeah! POST request on /register route will now be handled by our lambda function.
  • Similarly, create the following API Resources and Methods —
    • POST in /login: This is same as previous, just at the body template, use the code given in the mapping template below & save the code.
    • POST in /verify: This is same as previous, just at the body template, use the code given in the mapping template below & save the code.
    • POST in /logout: This is completely same as POST in /verify.

XML




[.] Mapping Template for POST method on /register route:
  
#set($inputRoot = $input.path('$'))
{
  "httpMethod": "$context.httpMethod",
  "username" : "$inputRoot.username",
  "name" : "$inputRoot.name",
  "password" : "$inputRoot.password",
  "path": "$context.resourcePath"
}
  
  
[.] Mapping Template for POST method on /login route:
  
#set($inputRoot = $input.path('$'))
{
  "httpMethod": "$context.httpMethod",
  "username" : "$inputRoot.username",
  "password" : "$inputRoot.password",
  "path": "$context.resourcePath"
}
  
  
[.] Mapping Template for POST method on /verify route:
  
#set($inputRoot = $input.path('$'))
{
  "httpMethod": "$context.httpMethod",
  "username" : "$inputRoot.username",
  "token" : "$inputRoot.token",
  "path": "$context.resourcePath"
}


  • Finally, all the resources & methods are ready to be used. We just need to deploy our API now. Just click upon the “Deploy API” button. Fill the stage name, description as given below and click on “Deploy”.
    deploying-api

    Deploying our created API

  • Congrats!! login-api is now live.

Setup Lambda Function

Previously, we created the Lambda function, now we have to handle the operations required in serverless authentication. Follow the steps below:

  • Open a folder in your local computer and look at the directory structure given below.
    lambda-function-setup

    Setup Lambda Function

  • Create a package.json file in the root directory with the following content.

    Node

    // package.json file

    {
    “name”: “login-backend”,
    “version”: “1.0.0”,
    “description”: “”,
    “main”: “index.js”,
    “scripts”: {
    “test”: “echo \”Error: no test specified\” && exit 1″
    },
    “author”: “arindam369”,
    “license”: “ISC”,
    “dependencies”: {
    “bcryptjs”: “^2.4.3”,
    “jsonwebtoken”: “^9.0.2”
    }
    }

    • Create index.js file in the root directory with the following content
    • Create a “services” directory and inside that directory create 4 files – register.js, login.js, verify.js, logout.js with the following contents.
    • Create a “utils” directory and inside that directory create 2 files – auth.js, builder.js with the following contents.

    Node

    // index.js file

    const registerService = require(“./services/register”);
    const loginService = require(“./services/login”);
    const verifyService = require(“./services/verify”);
    const logoutService = require(“./services/logout”);
    const builder = require(“./utils/builder”);

    exports.handler = async (event) => {
    if (event.path === “/register” && event.httpMethod === “POST”) { // handling user’s sign up process
    const response = await registerService.register(
    event.name,
    event.username,
    event.password
    );
    return builder.buildResponse(200, response);
    } else if (event.path === “/login” && event.httpMethod === “POST”) { // handling user’s sign in process
    const response = await loginService.login(event.username, event.password);
    return builder.buildResponse(200, response);
    } else if (event.path === “/verify” && event.httpMethod === “POST”) { // handling user’s auth-verification process
    const response = await verifyService.verify(event.username, event.token);
    return builder.buildResponse(200, response);
    } else if (event.path === “/logout” && event.httpMethod === “POST”) { // handling user’s signout process
    const response = await logoutService.logout(event.username, event.token);
    return builder.buildResponse(200, response);
    } else {
    return builder.buildResponse(400, {
    message: `${event.httpMethod} is not allowed in ${event.path} route`,
    });
    }
    };

    Node

    // /services/register.js file

    const builder = require(“../utils/builder”);
    const bcrypt = require(“bcryptjs”);
    const AWS = require(“aws-sdk”);
    const dynamoDB = new AWS.DynamoDB.DocumentClient({
    region: ‘us-east-1’,
    apiVersion: ‘2012-08-10’,
    });
    const SALT_ROUND = 8;

    // register user using name, username, password
    async function register(name, username, password){
    if(!name || !username || !password){
    return builder.buildResponse(400, {message: “Missing required fields”});
    }

    const foundUser = await getUser(username);
    if(foundUser && foundUser.username){
    return builder.buildResponse(400, {message: “User already exists”});
    }

    const hashedPass = bcrypt.hashSync(password.trim(), SALT_ROUND);
    const newUser = {
    name,
    username,
    password: hashedPass
    };
    const saveUserResponse = await saveUser(newUser);
    if(!saveUserResponse) return builder.buildResponse(400, {message: “Server Error: Please try again later”});
    return builder.buildResponse(200, {message: “User registered successfully”});
    }

    // retrieve user data via username from DynamoDB
    const getUser = async (username)=>{
    const params = {
    Key: {
    username: username
    },
    TableName: “login-database”
    }

    return await dynamoDB.get(params).promise().then((response)=>{
    return response.Item;
    }).catch((err)=>{
    return err;
    })
    }

    // save user in DynamoDB
    const saveUser = async (user)=>{
    const params = {
    Item: user,
    TableName: “login-database”
    }
    return await dynamoDB.put(params).promise().then(()=>{
    return true;
    }).catch((err)=>{
    return err;
    })
    }

    module.exports.register = register;

    Node

    // /services/login.js file

    const builder = require(“../utils/builder”);
    const bcrypt = require(“bcryptjs”);
    const auth = require(“../utils/auth”);
    const AWS = require(“aws-sdk”);
    const dynamoDB = new AWS.DynamoDB.DocumentClient({
    region: ‘us-east-1’,
    apiVersion: ‘2012-08-10’,
    });
    const SALT_ROUND = 8;

    async function login(username, password){
    if(!username || !password){
    return builder.buildResponse(400, {message: “Missing required fields”});
    }

    const foundUser = await getUser(username);
    if(!foundUser || !foundUser.username){ // user doesn’t exist in database
    return builder.buildResponse(400, {message: “User doesn’t exist”});
    }
    if(!bcrypt.compareSync(password, foundUser.password)){ // password doesn’t match with the existing password
    return builder.buildResponse(403, {message: “Wrong Password”});
    }

    const token = auth.generateToken(foundUser.username); // generate token encapsulating username
    const tokenArray = foundUser.tokens || [];
    tokenArray.push(token); // store the generated token in the database
    const params = {
    Key: {
    username: username
    },
    UpdateExpression: `set tokens = :value`,
    ExpressionAttributeValues: {
    “:value”: tokenArray
    },
    TableName: “login-database”,
    ReturnValues: “UPDATED_NEW”
    };

    const response = {
    username: foundUser.username,
    name: foundUser.name,
    token: token
    };

    return await dynamoDB.update(params).promise().then(()=>{
    return builder.buildResponse(200, {message: “User logged in successfully”, response});
    }).catch((err)=>{
    return builder.buildResponse(400, {message: err});
    })
    }

    // retrieve user data via username from DynamoDB
    const getUser = async (username)=>{
    const params = {
    Key: {
    username: username
    },
    TableName: “login-database”
    }

    return await dynamoDB.get(params).promise().then((response)=>{
    return response.Item;
    }).catch((err)=>{
    return err;
    })
    }

    module.exports.login = login;

    Node

    // /services/verify.js file

    const auth = require(“../utils/auth”);
    const builder = require(“../utils/builder”);
    const AWS = require(“aws-sdk”);
    const dynamoDB = new AWS.DynamoDB.DocumentClient({
    region: ‘us-east-1’,
    apiVersion: ‘2012-08-10’,
    });

    async function verify(username, token){
    if(!username || !token) return builder.buildResponse(400, {
    verified: false,
    message: “Missing required fields”
    });

    // verify the validation of the current token
    const verificationResponse = auth.verifyToken(username, token);
    if(!verificationResponse.verified) return builder.buildResponse(400, verificationResponse);

    const foundUser = await getUser(username);
    if(!foundUser || !foundUser.username || !foundUser.tokens) return builder.buildResponse(400, {verified: false, message: “Missing Fields detected”});
    if(!foundUser.tokens.includes(token)) return builder.buildResponse(400, {verified: false, message: “User is not authenticated”});

    return builder.buildResponse(200, {
    verified: true,
    message: “success”,
    username,
    token
    });
    }

    // retrieve user data via username from DynamoDB
    const getUser = async (username)=>{
    const params = {
    Key: {
    username: username
    },
    TableName: “login-database”
    }

    return await dynamoDB.get(params).promise().then((response)=>{
    return response.Item;
    }).catch((err)=>{
    return err;
    })
    }

    module.exports.verify = verify;

    Node

    // /services/logout.js file

    const builder = require(“../utils/builder”);
    const bcrypt = require(“bcryptjs”);
    const auth = require(“../utils/auth”);
    const AWS = require(“aws-sdk”);
    const dynamoDB = new AWS.DynamoDB.DocumentClient({
    region: ‘us-east-1’,
    apiVersion: ‘2012-08-10’,
    });

    async function logout(username, token){
    if(!username || !token){
    return builder.buildResponse(400, {message: “Missing required fields”});
    }

    const foundUser = await getUser(username);
    if(!foundUser || !foundUser.username){ // user doesn’t exist in database
    return builder.buildResponse(400, {message: “User doesn’t exist”});
    }

    // user is already logged out
    if(!foundUser.tokens || !foundUser.tokens.includes(token)){
    return builder.buildResponse(400, {message: “User is not authenticated”});
    }
    // remove the current token from the database
    const tokenArray = foundUser.tokens.filter(currToken => currToken !== token);

    const params = {
    Key: {
    username: username
    },
    UpdateExpression: `set tokens = :value`,
    ExpressionAttributeValues: {
    “:value”: tokenArray
    },
    TableName: “login-database”,
    ReturnValues: “UPDATED_NEW”
    };

    return await dynamoDB.update(params).promise().then(()=>{
    return builder.buildResponse(200, {message: “Logged out successfully”});
    }).catch((err)=>{
    return builder.buildResponse(400, {message: err});
    })
    }

    // retrieve user data via username from DynamoDB
    const getUser = async (username)=>{
    const params = {
    Key: {
    username: username
    },
    TableName: “login-database”
    }

    return await dynamoDB.get(params).promise().then((response)=>{
    return response.Item;
    }).catch((err)=>{
    return err;
    })
    }

    module.exports.logout = logout;

    Node

    // /utils/auth.js file

    const jwt = require(“jsonwebtoken”);

    // generate a token encapsulating the username (expires after 1 hour)
    const generateToken = (username)=>{
    if(!username) return null;

    return jwt.sign({username}, process.env.JWT_SECRET, {
    expiresIn: ‘1h’
    })
    }

    // verify the validation of the current token
    const verifyToken = (username, token)=>{
    return jwt.verify(token, process.env.JWT_SECRET, (error, response)=>{
    if(error) return {verified: false, message: “Invalid Token”};
    if(response.username !== username) return {verified: false, message: “Invalid User”};
    return {verified: true, message: “User is verified”};
    })
    }

    module.exports.generateToken = generateToken;
    module.exports.verifyToken = verifyToken;

    Node

    // /utils/builder.js file

    // generate response
    const buildResponse = (statusCode, data)=>{
    return {
    statusCode: statusCode,
    headers: { “Content-Type”: “application/json” },
    body: data,
    };
    }

    module.exports.buildResponse = buildResponse;

    • Now, open any terminal (where we can write npm commands) and write there “npm install” to install necessary dependencies for the project.
    • Now create a zip file selecting the root files and folders and upload the zip file in lambda function. Now, your Lambda function is ready to handle the API requests and send necessary responses.

    Testing our Serverless Authentication API

    • Make a POST request on /register route with the following JSON:
    {
    "name": "Arindam Halder",
    "username": "arindam369",
    "password": "abc#123"
    }



    testing-post-route

    POST on /register route

    registration-response

    User Registration Response

    • Make a POST request on /login route with the following JSON:
    {
    "username": "arindam369",
    "password": "abc#123"
    }



    testing-login-route

    POST on /login route

    login-response

    User Login Response

    • Make a POST request on /verify route with the following JSON:
    {
    "username": "arindam369",
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFyaW5kYW0zNjkiLCJpYXQiOjE2OTgzMDg3OTksImV4cCI6MTY5ODMxMjM5OX0.Bmn90tlaFFr0Hmh0jXbtPPzlaWuEItMb61JaPM7cT-8"
    }

    testing-verify-route

    POST on /verify route

    verify-response

    User Verification Response

    • Make a POST request on /logout route with the following JSON:
    {
    "username": "arindam369",
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFyaW5kYW0zNjkiLCJpYXQiOjE2OTgzMDg3OTksImV4cCI6MTY5ODMxMjM5OX0.Bmn90tlaFFr0Hmh0jXbtPPzlaWuEItMb61JaPM7cT-8"
    }

    testing-logout

    POST on /logout route

    logout-response

    User Logout Response

    dynamodb-tabledata

    DynamoDB table data JSON view (after testing the operations)

    FAQs On AWS Lambda and Amazon DynamoDB for Serverless Authentication :

    1. How can I securely store user passwords in DynamoDB?

    It’s crucial to hash passwords before storing them in DynamoDB. Use a strong cryptographic hash function like bcrypt to securely hash passwords. This ensures that even if the database is compromised, the actual passwords are not exposed.

    2. How can I handle authentication failures or invalid login attempts?

    You can create a Lambda function to handle authentication. If a user provides incorrect credentials, the function should respond with an authentication failure message. It’s also a good practice to implement rate limiting or CAPTCHA to prevent brute force attacks.

    3. What are best practices for securing AWS Lambda functions handling authentication?

    Ensure that your Lambda functions have appropriate IAM roles and permissions. Use environment variables or AWS Systems Manager Parameter Store to securely store sensitive information like API keys or tokens. Implement proper error handling and logging to monitor and respond to any unexpected behavior.



    Like Article
    Suggest improvement
    Share your thoughts in the comments

    Similar Reads