Authentication & Authorization

Mastering Authentication and Authorization: A Comprehensive Guide for Node.js Projects

Authentication and authorization are two vital information security processes that administrators use to protect systems and information. Authentication verifies the identity of a user or service, and authorization determines their access rights. Both are required when dealing with access to any sort of sensitive data assets. Without both, you risk exposing information via a breach or unauthorized access, ultimately resulting in bad press, customer loss and potential regulatory fines. Cyberattacks often result from broken authentication and broken access control.

Technology Used

Dependencies:

package.json

{
  "name": "nodejs-auth",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "dev": "nodemon index.js"
  },
  "author": "Soumitra Saha",
  "license": "MIT",
  "dependencies": {
    "bcryptjs": "^2.4.3",
    "cookie-parser": "^1.4.6",
    "dotenv": "^16.1.3",
    "express": "^4.18.2",
    "jsonwebtoken": "^9.0.0",
    "mongoose": "^7.2.2",
    "nodemon": "^2.0.22"
  }
}

Always prefer documentation for any npm packages before installing (version/use/code example etc..).

Folder/File Structure [Best Practices]

(MVC Architecture)

├───config
├───middleware
├───controllers
├───models
├───routes
├───.env (.env.local etc..)
├───index.js(server.js)
└───app.js

The first task before moving anything else is to setup our .env and DB Connection .

Before, Moving forward let's first create the .env and database.config.js

├───config
|    └───database.config.js
|

database.config.js

const mongoose = require("mongoose");

const { MONGO_URL } = process.env;

// Mongoose Connection Option: mongoose.connect('<connection-string>', <options>, <callback>);

exports.connect = () => {
  mongoose.connect(MONGO_URL, {
      useNewUrlParser: true,
      useUnifiedTopology: true,
    })
    .then(console.log("DB Connection with a success"))
    .catch((err) => {
      console.log("DB Connection error");
      console.log(err);
      process.exit(1);
    });
};

.env.sample

PORT = <on which you want to run the server>
MONGO_URL = <mongoDB connection url>
TOKEN_CODE = <salt value>

“We will talk about TOKEN_CODE later. It is used in JWT.”

Salt Value : In the context of cryptography and password hashing, a salt value is a random or unique string of characters that is added to a password before the hashing process. The salt value is used to increase the security of hashed passwords.

After setting up these two files, we can move forward with our other backend services. But before that, we have to initialize our .env and db in the topmost file (generally, it will be app.js)

As we are going to start talking about app.js let’s understand what it is and why it is the topmost file in the hierarchy.

The app.js file is of significant importance in a backend Node.js project. It serves as the entry point for the application, where all the necessary configurations, middleware, and routes are set up. This file is responsible for initializing the Express framework and defining the server's behavior. It handles requests and responses, enables routing for different endpoints, and connects with databases or other external services. Additionally, app.js facilitates the management of error handling, logging, and other global functionalities.

app.js

require("dotenv").config(); // Loads environment variables from .env
require("./config/database").connect(); // Stablish DB connection
const express = require("express"); // Import the express framework
const jwt = require("jsonwebtoken"); // Imports the JWT Library
const bcrypt = require("bcryptjs"); // Imports the bcrypt library for password hashing.
const cookieParser = require("cookie-parser"); // middleware for parsing cookies
const userRoute = require("./routes/user.route"); // Importing the routes.
const { TOKEN_CODE } = process.env; 

// Creating app:
const app = express();

// Middleware:
// Here, you have to use CORS as well for connection frontend to backend. But, CORS is not disscussed in this blog. If anyone wants then I will write a full articlae on CORS in depth.
// For Now, I will just give the syntax: `app.use(cors());`

app.use(express.json()); // for parsing application/json
app.use(express.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded
app.use(cookieParser()); // for parsing cookies

// Routes:
app.use("/", userRoute); // Comming form routes -> user.route.

module.exports = app; // Exporting

index.js

const app = require('./app'); // Importing the 'app' module
const { PORT } = process.env; // Retrieving the value of the 'PORT' environment variable

app.listen(PORT, () => {
    console.log("listening on port " + PORT); // Starting the server and logging the port number
});

Routes

Now, let's discuss the routes and set them up. To ensure easy comprehension, I will be using basic naming conventions and route paths. However, it's important to note that in a production environment, routes are not typically written in this manner. In the upcoming article, "Best Practices for Creating Routes," I will delve into this topic in greater detail.

What are routes?

Routes define the endpoints or URLs that clients can access to interact with the server. Routes determine how incoming requests are handled and processed, enabling the server to respond with the appropriate data or perform specific actions based on the request type and parameters.

user.route.js

├───routes
|    └───user.route.js
|
// Import the required modules
const express = require("express"); // Import the Express framework
const {home, register, login, profile} = require("../controllers/user.controller"); // Import the home and register functions from the user.controller module
const auth = require("./middleware/auth"); // Importing Custom Middleware:

// Create a router instance
const router = express.Router();

// Define routes and their corresponding handlers

router.get("/", home); // Handle GET requests to the root URL ("/") by calling the home function from user.controller

router.post("/register", register); // Handle POST requests to the "/register" URL by calling the register function from user.controller

router.post("/login", auth, login); // Handle POST requests to the '/login' route by calling the login function from user.controller

// [VVI] And, Most Important here `auth` is a middleware (Custom). We will take later.

// Export the router for external usage
module.exports = router;

Controllers

Now, let's delve deeper into the controllers, and afterwards, we will discuss the models, specifically focusing on schema design.

What is Controllers?

Controllers are modules or components that handle the business logic and operations for specific routes or endpoints. They act as intermediaries between the routes and the underlying data or services. Controllers receive requests from routes, process the data, interact with the database or other external resources, and prepare the appropriate response to send back to the client. They help keep the codebase organized by separating the handling of HTTP requests from the actual business logic, promoting modularity and maintainability.

├───controllers
|    └───user.controller.js
|

user.controller.js

const User = require("../models/user.schema"); // User Schema 
// Logic || Business Logic

// Controller for Home Route.
exports.home = (req, res) => {
  res.status(200).send("Welcome to Home");
};

// Controller for Registration
exports.register = async (req, res) => {
    try {
        // 1. Collect all information
        const { firstname, lastname, email, password } = req.body;
        // 2. validate the data, if exists:
        if (!(email && password && firstname && lastname)) {
            res.status(400).send("All fields are required");
        }
        // 3. Find the user into DB whether it exists or not
        const existingUser = await User.findOne({
            email: email,
            password: password,
        });

        if (existingUser) {
            res.status(400).send("User already exists");
        }

        // 4. Encrypt the password [V.V.I Step ⭐⭐⭐⭐⭐]
        const myEncryptPassword = await bcrypt.hash(password, 10);
        // 5. Create a new entry and save it in DB:
        const userData = await User.create({
            firstname,
            lastname,
            email,
            password: myEncryptPassword,
        });

        // 6. create a token to send it to user [Using JWT Token ⭐⭐⭐⭐⭐]
        const token = jwt.sign(
            {
                id: userData._id,
                email,
            },
            TOKEN_CODE,
            { expiresIn: "2h" }
        );

        userData.token = token;
        userData.password = undefined; // I don't want to send the password to the user to login or authorization.

        res.status(200).json(userData);
    } catch (error) {
        console.log(error);
        console.log("Error Occurred");
    }
};

// Controller for login
exports.login = async (req, res) => {
    try {
        // 1. Collect information from frontend
        const { email, password } = req.body;

        // 2. validate
        if (!(email && password)) {
            res.status(400).send("Missing email or password");
        }

        // 3. check user in database
        const user = await User.findOne({ email });
        // If user is not found

        // 4. match the password
        if (user && (await bcrypt.compare(password, user.password))) {
            // bcrypt directly encrypt the password and compare it with the password stored in the database.
            const token = jwt.sign({ id: user._id, email }, TOKEN_CODE, {
                expiresIn: "2h",
            });

            user.password = undefined;
            user.token = token;

            // Creating a Cookie:
            const options = {
                expires: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
                httpOnly: true,
            };

            // Sending the response:
            res.status(200).cookie("token", token, options).json({
                success: true,
                token,
                user,
            });
        }
        res.status(200).send("Email and password is incorrect");

        // 5. create token and send to DB:
    } catch (error) {
        console.log("Error about to login or authorization");
        console.log(error);
    }
};

I can talk about controllers extensively, but since this article focuses on authentication and authorization, I will skip detailed explanations of each controller. However, I have provided clear comments at each step, making it easy to understand the process. The code is organized in a step-by-step format, such as "Step 1: Do this" and "Step 2: Do that," facilitating comprehension.

I have already written an article explaining the uses of JWT (JSON Web Tokens). If anyone is interested, they can refer to the article for a detailed understanding of JWT. As for bcrypt, I will make an effort to provide an explanation and usage examples in a separate article. You can find the link to the JWT article here:

[JWT A Comprehensive Guide to Secure Authentication a and Authorization].

Middleware

Before diving into schema design, let's first complete the authentication implementation. Afterwards, we can proceed to explore the user schema and its associated functionalities.

What is Middleware?

Middleware acts as a bridge between incoming requests and outgoing responses. It consists of functions or modules that intercept and process requests, performing various tasks such as authentication, logging, data validation, and error handling. Middleware functions have access to the request and response objects, allowing them to modify or enhance them as needed. They can be applied globally or to specific routes, providing flexibility in controlling the flow of request handling. By modularizing request processing and enabling code reuse, middleware improves code organization, scalability, and maintainability, while enhancing the functionality, security, and performance.

auth.js

├───middleware
|    └───auth.js
|
const jwt = require("jsonwebtoken");
const { TOKEN_CODE } = process.env;
const User = require("../models/user.schema"); // Assuming the User model represents your database schema

const auth = async (req, res, next) => {
    console.log("Cookie: ", req.cookies);
    const { token } = req.cookies;

    if (!token) {
        return res.status(400).send("Token is missing");
    }

    try {
        const decode = jwt.verify(token, TOKEN_CODE);
        // console.log(decode);
        req.user = decode;

        const userId = decode.id; // Getting the stored 'id' property from the token

        // Query the database to retrieve the user based on the ID
        const user = await User.findById(userId);

        user.then((user) => { 
            if (!user) { 
                return res.status(404).send("User not found"); 
            } 
            req.user = user; // Store the user object in the request for further use 
            next(); 
        })
        .catch((err) => { 
            res.status(500).send("Error querying the database"); 
        });
    } catch (error) {
        res.status(400).send(error.message);
    }

    return next();
}

module.exports = auth;

In a Node.js middleware or route handler, next() is a function that is used to pass control to the next middleware function or route handler in the sequence. It allows the request-response cycle to continue processing, moving from one middleware to another or reaching the final route handler for generating a response to the client.

Models

In a Node.js project, models represent the logical abstraction of data entities and define their structure, behavior, and relationships. Models encapsulate the data access and manipulation logic, including database interactions and data validation. They provide a standardized way to define and handle data, ensuring consistency and integrity throughout the application. Models often utilize ORM libraries to simplify database operations and enable seamless integration with different database systems. By utilizing models, developers can establish a clear separation of concerns, improve code organization, and facilitate the management of data entities within their Node.js applications, ultimately enhancing maintainability, scalability, and data integrity.

user.schema.js

├───models
|    └───user.schema.js
|
const mongoose = require("mongoose");
const { Schema } = mongoose;

const userSchema = new Schema({
  firstname: {
    type: String,
    default: null,
  },
  lastname: {
    type: String,
    default: null,
  },
  email: {
    type: String,
    unique: true,
    required: [true, "Please enter a valid email"],
  },
  password: {
    type: String,
  },
  token: {
    type: String,
  },
});

module.exports = mongoose.model("user", userSchema);

In conclusion, this article has covered the essential aspects of authentication and authorization. By now, you should have a solid understanding of these concepts and their implementation in Node.js projects. If you found this article helpful, please show your support by liking, commenting, and following me on LinkedIn, Twitter and Hashnode. Stay tuned for more informative content and updates. Thank you!

And don't forget to subscribe to my newsletter! I publish new articles every Tuesday, packed with valuable insights and information. Stay updated and receive fresh content directly in your inbox.

My LinkedIn: SOUMITRA SAHA | LinkedIn

My Twitter: @SoumitraSaha100