Build End-to-End Authentication and Authorization system in NodeJS

Dharm JoshiDharm Joshi
Sep 09, 2024ยท
17 min read

Introduction

In day-to-day terms using every application or website that requires some of your information to store and then allows you to have your account access using a combination of password and another identifier like your Email-Id or your username set when you initially sign in to the application. This is the most common module in any software or SaaS.

Authentication Vs Authorization ๐Ÿ”’

I have often seen dev folks get confused or rather they see both Authentication and Authorization as synonyms to each other. In real terms, these terms have different meanings altogether.

Authentication: Authentication is a process that verifies that someone or something is who they say they are. Let's simplify this by an example.

Suppose you are registering on a social media site using your mail and a strong password. Now whenever you want to get access to your account on that specific social site, you will again enter the same credentials that you have entered upon registration. The system validates that request and tells the servers or backend system that this person is authentic and it can access his/her account. Thus server verifies you as a registered user of that social site. This process is known as authentication.

Authorization: Authorization is the security process that determines a user or service's level of access. It generally means that a user can have access to specific routes or modules so to say, in a specific application. The most common example in authorization is premium offerings by a Saas product.

If you already are an authentic person for a system, but it still requires a set of permissions to authorize you for that specific feature you are required to use on that system. Let's take an example to simplify things.

Let's suppose you have registered yourself in a SaaS product that allows you to sell your services just like Urban Company. Now to get your first client you need to verify yourself with some of your business documents like GST No, MSME certificate and other things. Until you don't upload and verify your business documents, you are not authorised as a seller on that platform. This process of getting a certain level of access after verifying your additional information, after the authentication process is Authorization.

Let's Code Together ๐Ÿ‘จโ€๐Ÿ’ป.

Now that we have gained some insights about Authentication and Authorization, let's dive into making those modules as APIs in NodeJS and do the practical implementation of these modules.

Authentication Module.

In this section, we will setup our whole project and we will see how to make an authentication module with NodeJs and ExpressJs with MongoDB database.

NodeJs is a JavaScript run-time that enables the system to run JavaScript on servers. It uses the V8 engine of Chrome to run and execute JavaScript on servers. You can download the latest NodeJs version for your local system from here.

Initialize project.

Let's initialise our NodeJs project to get started building this module.

npm init -y

With this above command executed in the root terminal of your project, you will have certain files present in your root folder like package.json and package-lock.json, which will be required to install any other dependencies from npm or yarn.

Now that we have initialised our project, it's time to install some of the libraries that will help develop this module. We can use both npm or yarn as a package manager, feel free to use whatever you want, In this example, we will use npm as a package manager.

npm i express mongoose jsonwebtoken bcrypt dotenv yup responsify-requests cors

By executing these above commands all the necessary dependencies will be installed in your project's root folder. Your package.json file will look something like this, with all these dependencies installed.

Now that all the dependencies are installed successfully, we need to make certain folder structure of our NodeJS application, so that it can be scaled properly and the code doesn't look mushy.

Here in this project, we are following something known as the "Service Controller Architecture" to separate our database queries and business logic in this application.

Controllers will call services and services will communicate with the database and get the necessary data from the database, and then the controller based on response by service will handle the responses which then be served to the end client.

Let us start by making the database models for our authentication module.

In the root folder, make a folder named "models" and inside this make two files, "tokenModel.js" and "userModel.js". It should look something like this...

Now let's make a MongoDB model for users.

Now, when we are working with user password, it is a great practice to encrypt that password and then put it into our database, so that if any data leak happens within our organization, our user's password is encrypted and hence secured for the cyber attacks on their specific accounts.

For this purpose, we will use something called as 'Pre Hooks' which is a provided feature of MongoDB. The function that we define in this hook, will get executed before saving any document into our MongoDB database. Thus in this hook, we will write our logic to hash the user's password.

For hashing the user's password, we will use bcrypt library which helps us to hash the user's password using a function named 'hash'. Here's the code for writing the hook and hashing the password using bcrypt library.

userModel.js file will look something like this.

const mongoose = require("mongoose");
const bcrypt = require('bcrypt');

const userSchema = new mongoose.Schema(
  {
    username: {
      type: String,
    },

    email: {
      type: String,
    },

    password: {
      type: String,
    },

    isDeleted: {
      type: Boolean,
      default: false,
    },
  },
  {
    timestamps: true,
  }
);

userSchema.pre("save", async function (next) {
    let genSalt = 10;
    let hashedPass = await bcrypt.hash(this.password, genSalt);
    this.password = hashedPass;
    next();
  });

const userModel = mongoose.model("users", userSchema);
module.exports = userModel;

Now similarly we will make a tokenModel.js file for saving our token which was created by a specific user.

In this token model, we need to give reference to the user model, to create a reference between the token model and the user model, as every token will have a user, who generated that token. The token model will look something like this...

const mongoose = require("mongoose");

const tokenSchema = new mongoose.Schema({
  userId: {
    type: mongoose.Schema.Types.ObjectId,
    ref: "users",
    required: true,
  },

  token: {
    type: String,
    required: true,
  },

  createdAt: {
    type: Date,
    default: Date.now,
    expires: 604800,
    required: true,
  },
});

const tokenModel = mongoose.model("tokens", tokenSchema);
module.exports = tokenModel;

Here you will see the "expires" attribute, this attribute is provided by MongoDB, which eventually deletes the token document, which is 7 days older ( 604800 seconds ). This will be in line with the JWT token expiration timeline, which we will be discussing in the later part of the article.

Now that we have created the models, let us create a file in the root folder named db.js which will help us to connect the MongoDB database of our local system.

Create a file named "db.js" in the root folder of the project. This file contains the database connection logic for connecting the local MongoDB database.

const mongoose = require("mongoose");
const { envConstants } = require("./constants/envConstants");

const connectDb = () => {
  mongoose
    .connect(envConstants.mongodbUrl)
    .then(() => {
      console.log("Database connected successfully");
    })
    .catch((error) => {
      console.log("Unable to connect the database, Try again ", error);
    });
};

module.exports = {
  connectDb,
};

As you can see the MongoDB connection URL is coming from the folder named constants. This folder will have all the necessary constants and response messages string which will be mapped to its corresponding key.

This will help us not to use "Magic Strings" in our project. Though there is no harm in using magic strings, it will impact the code readability of our project, and thus to increase the code readability of our project, we will use a key-value pair for every message we need to send back to the user in API response.

Create a folder named "constants" in the root folder and inside it we need to create two files, envConstants.js and messages.js, the folder structure will look something like this.

Now, inside the envContants.js file, we need to copy the constants that we will write in the .env file, envContants.js will look something like this...

// envConstants.js

require('dotenv').config();

const envConstants = {
    serverPort: process.env.PORT,
    mongodbUrl: process.env.MONGO_URL,
    jwtSecret: process.env.JWT_SECRET,
    jwtExpiresIn: process.env.JWT_EXPIRES_IN
}

module.exports = {
    envConstants
}

Here is an important note, I am using Node version 18 and thus need to have an extra library named dotenv for accessing the .env constants that we will define in the .env file. If you are using Node Version 20 or above, you can automatically be able to access the .env constants.

Let's define a .env file with some important constants in the root folder of our project.

// .env file

PORT=5000
JWT_SECRET='SLK12LSL%@NewSecretKey'
JWT_EXPIRES_IN='7d'
MONGO_URL="mongodb://127.0.0.1:27017/authmodule"

Now we are almost ready in our project to write the main logic for registration and login the user. For this let us make "controllers" and "services" named folders in our root directory of the project.

Inside controllers, make authController.js and inside services, make authService.js files respectively. The folder structure should look something like this...

Now let's make our first service for registering the user.

// authService.js -> register user function

const userModel = require("../models/userModel");
const tokenModel = require("../models/tokenModel");
const bcrypt = require("bcrypt");
const jwt = require("jsonwebtoken");
const { envConstants } = require("../constants/envConstants");

const registerUser = async (reqBody) => {
  try {
    let findDuplicateUser = await userModel.findOne({
      email: reqBody.email,
    });

    if (findDuplicateUser) {
      return 2; // User already exists.
    } else {
      const createUser = await userModel.create(reqBody);

      if (!createUser) {
        return 0; // Unable to create the user try again.
      } else {
        return 1; // User created successfully
      }
    }
  } catch (error) {
    console.log("error in register user service", error);
    throw new Error();
  }
};

In registering the user, we need to check for only one thing. Whether the user is already present in our database with a similar email or not. This step is necessary to not create duplicate users in our database, with similar email addresses, as email addresses must be unique for each user.

Now we will maintain a code structure of using try-and-catch blocks in both services and controllers so that effective errors can be handled by these blocks in the entire application.

Now let's complete this route by writing the controller logic for this above register service.

const { registerUser } = require("../services/authService");
const response = require("responsify-requests");
const { messages, status } = require("../constants/messages");

const registerUserController = async (req, res) => {
  try {
    const registerService = await registerUser(req.body);
    if (registerService == 0) {
      return response(
        res,
        {},
        0,
        messages.USER_REGISTER_FAILURE,
        status.BAD_REQUEST
      );
    } else if (registerService == 2) {
      return response(
        res,
        {},
        0,
        messages.USER_ALREADY_REGISTERED,
        status.BAD_REQUEST
      );
    } else {
      return response(
        res,
        {},
        1,
        messages.USER_REGISTER_SUCCESS,
        status.SUCCESS
      );
    }
  } catch (error) {
    console.log("error in register controller ", error);
    return response(res, {}, 0, messages.INTERNAL_SERVER_ERROR);
  }
};

The library responsify-requests is a simple one-function library I created while at one of my organizations. It is a response builder for our API with different status strings. Look at the library documentation here.

Now as we have created our first logic of registering the user in our application, let us look at the routing logic of this application.

For routing, let us create a routes folder in our root project directory and inside it, we need one more folder named "auth", and a file index.js. Inside this auth folder, we will make an authRoutes.js file. The folder structure will look something like this...

Here the index.js file in the routes folder will act as a wrapper for all the routes we are going to define inside the auth folder.

Now before moving forward, let us discuss about a very important concept called "Middlewares".

Middlewares ๐Ÿš€๐Ÿš€

Middleware is nothing but a block of code or a function that will execute before our main controller for the route is executed. As the name suggests, it will be the middle step of our route before hitting the main controller logic.

Middleware is essential for validating the body, for authorization of a user for that specific path, for uploading a media or a file using some external libraries on specific routes, etc...

In this article, we will look into validating the body which is passed by the user, is correct as per our defined terms or not. For this purpose of defining the rules of the request body, we will use a library called as Yup. Yup library helps us give some predefined methods, for creating rules for our request body. Let's build these body validation schemas using Yup.

For this, let us create another folder named validationSchemas in our root project directory, and inside this folder, create a file named authSchemas.js. The project structure will look something like this...

Inside this authSchema.js file, let us define certain request body rules for our register user route.

// authSchema.js

const Yup = require("yup");

const registerUserSchema = Yup.object({
  body: Yup.object({
    email: Yup.string().email("Invalid Email").required("Email is required"),
    username: Yup.string()
      .min(3)
      .max(25)
      .matches(
        /^[a-zA-Z0-9_]+$/,
        "No special characters allowed, expect an underscore"
      )
      .required("Username is required"),
    password: Yup.string()
      .min(8, "Password must be at least 8 characters")
      .max(20, "Maximum 20 characters allowed")
      .matches(
        /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#\$%\^&\*])(?=.{8,})/,
        "Must Contain One Uppercase, One Lowercase, One Number and One Special Character"
      )
      .required("Password is required"),
  }),
});

module.exports = {
  registerUserSchema,
};

Now that we have defined our request body validation schema, let us write a middleware to validate this schema with the request body and throw a validation error if any rules of the request body don't match the defined rules of the above-mentioned schema.

For writing middleware, let us make a folder named middlewares in our project's root directory, and inside this folder make a file named "validateBody.js" which will have the logic to validate the request body with the given schema.

//validateBody.js

const response = require("responsify-requests");
const { status } = require("../constants/messages");

const validateBody = (schema) => async (req, res, next) => {
  try {
    await schema.validate({
      body: req.body
    });
    return next();
  } catch (err) {
    return response(res, {}, 0, err.errors[0], status.BAD_REQUEST);
  }
};

module.exports = {
  validateBody,
};

The error messages will be provided by the Yup schema, as per the defined rules for that particular key and value pair.

Now that we have successfully made our first middleware, let's see how we can use it in the express routes before our controller gets executed. For this, inside the authRoutes.js file in the auth folder, we need to do something like this...

//   /route/auth/authRoute.js

const express = require("express");
const router = express.Router();
const {
  registerUserController,
} = require("../../controllers/authController");
const { validateBody } = require("../../middlewares/validateBody");
const { registerUserSchema } = require("../../validationSchemas/authSchema");

router.post("/register", validateBody(registerUserSchema) , registerUserController);

module.exports = router;

As you can see, we have executed validateBody middleware, before the registerUserController gets executed in route, this is necessary to validate the body before some business logic gets executed.

Now we need to import the router of this authRoute.js file to the outer index.js file in the route folder, to expose that route to the main index.js file.

//        /route/index.js

const express = require("express");
const router = express.Router();
const authRoutes = require('./auth/authRoutes');

router.use("/auth", authRoutes);

module.exports = router;

Now that we have completed our whole route of registering the user into our application, let us make the main index.js file, where our main server will be listening on some pre-defined port.

For this, create an index.js file in the project's root directory and code it this way...

// index.js

const express = require("express");
const cors = require("cors");
const { envConstants } = require("./constants/envConstants");
const { connectDb } = require("./db");
const indexRouter = require('./routes/index')
const app = express();

app.use(express.json()); // An body parser 
app.use(cors()); // to avoid cors error.
app.use("/api", indexRouter); // Router wrapper from /route/index.js

app.listen(envConstants.serverPort, async () => {
  await connectDb(); // connect to database
  console.log(`Server is listening on ${envConstants.serverPort}`);
});

As our register user route is completed and working, let's jump into making our login route logic, which will be slightly different from the registering user logic.

For this, first let's define a login service logic into our services folder inside authService.js file.

//        services/authService.js 

const userModel = require("../models/userModel");
const tokenModel = require("../models/tokenModel");
const bcrypt = require("bcrypt");
const jwt = require("jsonwebtoken");
const { envConstants } = require("../constants/envConstants");

const loginUser = async (reqBody) => {
  try {
    let findUser = await userModel.findOne({
      email: reqBody.email,
    });

    if (!findUser) {
      return 0; // Invalid credentials
    } else {
      let checkPass = await bcrypt.compare(reqBody.password, findUser.password);

      if (!checkPass) {
        return 0; // Invalid credentials
      } else {
        const generateToken = jwt.sign(
          {
            userId: findUser._id,
            userEmail: findUser.email,
          },
          envConstants.jwtSecret,
          {
            expiresIn: envConstants.jwtExpiresIn,
          }
        );

        if (generateToken) {
          await tokenModel.create({
            token: generateToken,
            userId: findUser._id,
          });

          return { ...findUser._doc, token: generateToken };
        } else {
          return 2; // Unable to login, Please try again
        }
      }
    }
  } catch (error) {
    console.log("error in login user service ", error);
    throw new Error();
  }
};

module.exports = {
  loginUser,
};

Here, let's understand the whole logic and error handling in this login service method.

  1. Check if the user exists in the db or not using an email address.

  2. If the user doesn't exist Goto step - 8

  3. Compare the user-entered password and user document password using bcrypt's compare method.

  4. If the password doesn't match Goto step - 8

  5. Generate jwt token having payload of userId and userEmail.

  6. If the token is not generated properly, Throw an error.

  7. Create a token document in the token collection with an already created token in step 5.

  8. Throw an invalid credentials error to the controller.

Here an important thing to mention that , on user not found scenario and on invalid password scenario, we are passing the same error of "Invalid Credentials". This is a best practice often followed in industry, to hide the actual cause of an error on login screens.

Now let's make a controller for our login service, in the authController.js file situated in the controllers folder.

//        controllers/authController.js 

const { loginUser } = require("../services/authService");
const response = require("responsify-requests");
const { messages, status } = require("../constants/messages");

const loginUserController = async (req, res) => {
  try {
    const loginService = await loginUser(req.body);

    if (loginService == 0) {
      return response(
        res,
        {},
        0,
        messages.INVALID_CREDENTIALS,
        status.UNAUTHORIZED
      );
    } else if (loginService == 2) {
      return response(
        res,
        {},
        0,
        messages.USER_LOGIN_FAILURE,
        status.BAD_REQUEST
      );
    } else {
      return response(
        res,
        loginService,
        1,
        messages.USER_LOGIN_SUCCESS,
        status.SUCCESS
      );
    }
  } catch (error) {
    console.log("error in login controller ", error);
    return response(res, {}, 0, messages.INTERNAL_SERVER_ERROR);
  }
};

Here as we have defined our service and controller logic for the login user, let's now define the validation schema for the login request body.

//      validationSchemas/authSchema.js

const Yup = require("yup");

const loginUserSchema = Yup.object({
  body: Yup.object({
    email: Yup.string().required("Email is required"),
    password: Yup.string().required("Password is required"),
  }),
});

module.exports = {
    loginUserSchema,
};

Now let's add login route in authRoute.js file to expose login controller on a specific route of our authentication module.

//       routes/auth/authRoutes.js

const express = require("express");
const router = express.Router();
const {
  loginUserController,
} = require("../../controllers/authController");
const { validateBody } = require("../../middlewares/validateBody");
const { loginUserSchema } = require("../../validationSchemas/authSchema");

router.post("/login", validateBody(loginUserSchema) , loginUserController);

module.exports = router;

Here our authentication module gets completed with register and login routes setup and working with tokenization system using JWT tokens.

Authorization Module.

Now as our authentication module with register and login is completed, let us focus on the authorization module which will have its whole logic in a single middleware.

This middleware will authorize the user with its jwt token which user will get on login, and pass in the headers of the specific request which requires the user to login to access the route.

Let's make that authorization middleware and see its logic using pseudo code.

//         middlewares/authorizeRequest.js

const jwt = require("jsonwebtoken");
const tokenModel = require("../models/tokenModel");
const { envConstants } = require("../constants/envConstants");
const userModel = require("../models/userModel");
const { response } = require("responsify-requests");
const { messages, status } = require("../constants/messages");

const authenticateUser = async (req, res, next) => {
  try {
    let getBearerToken = req.headers["authorization"];

    if (typeof getBearerToken !== "undefined") {
      let splitToken = getBearerToken.split(" ");
      let token = splitToken[1];

      let findTokenInDb = await tokenModel.findOne({ token: token });
      if (!findTokenInDb) {
        return response(
          res,
          {},
          0,
          messages.TOKEN_EXPIRED,
          status.UNAUTHORIZED
        );
      } else {
        const verifyToken = await jwt.verify(token, envConstants.jwtSecret);
        if (verifyToken && verifyToken.userId) {
          let findUser = await userModel.findById(verifyToken.userId);

          if (!findUser) {
            return response(
              res,
              {},
              0,
              messages.USER_NOT_FOUND,
              status.UNAUTHORIZED
            );
          } else {
            req.userId = findUser._id;
            req.userEmail = findUser.email;
            next();
          }
        } else {
          return response(
            res,
            {},
            0,
            messages.USER_NOT_AUTHORIZED,
            status.UNAUTHORIZED
          );
        }
      }
    } else {
      return response(
        res,
        {},
        0,
        messages.USER_NOT_AUTHORIZED,
        status.UNAUTHORIZED
      );
    }
  } catch (error) {
    return response(res, {}, 0, messages.INTERNAL_SERVER_ERROR);
  }
};

module.exports = {
  authenticateUser,
};

Let's understand this middleware function using pseudo code.

  1. Get user authentication token from authentication header

  2. If the user token is not found, throw an error.

  3. Otherwise, Find a token from the token collection.

  4. If the Token is not found, then throw the error of Token Expired.

  5. Otherwise, Decode the token using the JWT sign method and get user details.

  6. From User details find the user from the user's collection.

  7. If the user is not found, then throw an error.

  8. Otherwise, Pass the user ID and user Email in the request object and call the next() function to execute the next function in the route.

Now, once we use this authorization middleware in any route, it will first check for the token, then verify the user's identity by querying the database and thus authorize the user for that specific route.

We have completed our authentication and authorization module development using NodeJS, ExpressJS and MongoDB.๐Ÿš€๐Ÿš€๐Ÿฅณ

Conclusion

So, folks, we have come to the end of this article. This article implements very precise, industry-level code practices for developing NodeJs Authentication and Authorization modules.

You can access the whole code from this GitHub repo.

If you have any doubts or concerns, feel free to reach me at dharmjoshi01@gmail.com.

Thanks for reading this article. ๐Ÿ˜Š

44
Subscribe to my newsletter

Read articles from Dharm Joshi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Dharm Joshi
Dharm Joshi

๐Ÿ‘‹ Hi, I'm Dharm Joshi, a passionate Full-Stack Engineer with over 2 years of experience in the tech industry. My expertise lies in modern JavaScript frameworks and libraries such as ReactJS โš›๏ธ, NodeJS ๐ŸŸข, ExpressJS, and Meteor.js โ˜„๏ธ. On the backend, I work extensively with databases like MongoDB ๐Ÿƒ and PostgreSQL ๐Ÿ˜, utilizing ORMs like TypeORM and Mongoose ๐Ÿฆ for efficient data handling. In addition to development, I'm well-versed in cloud technologies โ˜๏ธ, including Docker ๐Ÿณ, Kubernetes ๐Ÿ› ๏ธ, and AWS โ˜๏ธ, where Iโ€™ve successfully deployed scalable MERN stack applications to AWS EC2 instances. I aim to continuously evolve my skills, building robust and performant web applications.