Open In App

AWS Lambda and Amazon DynamoDB for Serverless Authentication

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.



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.

Create a DynamoDB Table

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.

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 —




[.] 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"
}

Setup Lambda Function

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

// 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”
}
}

// 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`,
});
}
};

// /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;

// /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;

// /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;

// /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;

// /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;

// /utils/builder.js file

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

module.exports.buildResponse = buildResponse;

Testing our Serverless Authentication API

{
"name": "Arindam Halder",
"username": "arindam369",
"password": "abc#123"
}



POST on /register route

User Registration Response

{
"username": "arindam369",
"password": "abc#123"
}



POST on /login route

User Login Response

{
"username": "arindam369",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFyaW5kYW0zNjkiLCJpYXQiOjE2OTgzMDg3OTksImV4cCI6MTY5ODMxMjM5OX0.Bmn90tlaFFr0Hmh0jXbtPPzlaWuEItMb61JaPM7cT-8"
}

POST on /verify route

User Verification Response

{
"username": "arindam369",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFyaW5kYW0zNjkiLCJpYXQiOjE2OTgzMDg3OTksImV4cCI6MTY5ODMxMjM5OX0.Bmn90tlaFFr0Hmh0jXbtPPzlaWuEItMb61JaPM7cT-8"
}

POST on /logout route

User Logout Response

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.


Article Tags :