Authentication and Authorization with JWTs in Node && Express.js

Mangabo Kolawole - Oct 24 '21 - - Dev Community

In this tutorial, we'll learn how to build an authentication system for a Nodejs & Express application using JWT.

We'll be working on the project of this tutorial Build an API using Node, Express, MongoDB, and Docker . You can find the code source for this tutorial here.

What is Authentication and Authorization?

Simply, authentication is the process of verifying the identity of someone.

Authorization is the process of verifying what data the user can have access to.

And authorization only occurs when you've been authenticated. Then, the system will grant you access to the files you need.

Setup the project

First of all, clone the project.

git clone https://github.com/koladev32/node-docker-tutorial.git
Enter fullscreen mode Exit fullscreen mode

Once it's done, go inside the project and run.

yarn install
Enter fullscreen mode Exit fullscreen mode

Start the project using :

yarn start
Enter fullscreen mode Exit fullscreen mode

Inside the root of the project, create a .env file.

// .env
JWT_SECRET_KEY=)a(s3eihu+iir-_3@##ha$r$d4p5%!%e1==#b5jwif)z&kmm@7
Enter fullscreen mode Exit fullscreen mode

You can easily generate a new value for this secret key online here.

Creating the User model

Let's create the User model. But first, we need to define a type for this model.

// src/types/user.ts
import { Document } from "mongoose";

export interface IUser extends Document {
  username: string;
  password: string;
  isAdmin: boolean;
}
Enter fullscreen mode Exit fullscreen mode

Great, then we can write the User model.

// src/models/user.ts

import { IUser } from "../types/user";
import { model, Schema } from "mongoose";

const userSchema: Schema = new Schema(
  {
    username: {
      type: String,
      required: true,
      unique: true,
    },
    password: {
      type: String,
      required: true,
    },
    isAdmin: {
      type: Boolean,
      required: false,
      default: false,
    },
  },
  { timestamps: true }
);

export default model<IUser>("user", userSchema);
Enter fullscreen mode Exit fullscreen mode

The User model is created. We can go and start writing the Login and Register controllers.

Registration

Go to the controllers directory and create a new directory users which will contain a new index.ts file.

Let write the registerUser controller.

// src/controllers/users/index.ts

import { Response, Request } from "express";
import { IUser } from "../../types/user";
import User from "../../models/user"
const bcrypt = require("bcrypt");
const jwt = require("jsonwebtoken");
let refreshTokens: string[] = [];

const registerUser = async (
  req: Request,
  res: Response
): Promise<e.Response<any, Record<string, any>>> => {
  try {
    const { username, password } = req.body;
    if (!(username && password)) {
      return res.status(400).send("All inputs are required");
    }

    // Checking if the user already exists

    const oldUser = await User.findOne({ username });

    if (oldUser) {
      return res.status(400).send("User Already Exist. Please Login");
    }

    const user: IUser = new User({
      username: username,
    });

    const salt = await bcrypt.genSalt(10);
    // now we set user password to hashed password
    user.password = await bcrypt.hash(password, salt);

    user.save().then((doc) => {
      // Generating Access and refresh token
      const token = jwt.sign(
        { user_id: doc._id, username: username },
        process.env.JWT_SECRET_KEY,
        {
          expiresIn: "5min",
        }
      );

      const refreshToken = jwt.sign(
        { user_id: doc._id, username: username },
        process.env.JWT_SECRET_KEY
      );

      refreshTokens.push(refreshToken);

      return res.status(201).json({
        user: doc,
        token: token,
        refresh: refreshToken,
      });
    });

    return res.status(400).send("Unable to create user");
  } catch (error) {
    throw error;
  }
};

export {registerUser};
Enter fullscreen mode Exit fullscreen mode

What are we doing here?

  • Check that the required fields have been provided
  • Check that there is no existing user with the same username
  • Creating the user and encrypting the password
  • Generating refresh and access tokens
  • Send responses

But why a refresh and an access token?

When the token expires, the intuitive way to claim a new access token will be to log in again. But this is not effective at all for the experience of possible users.
Then instead of login in again, the client can claim a new access token by making a request with the refresh token obtained at login or registration.
We'll write the routes for this later.

Now, let's add this controller to the routes and register the new routes in our application.


// src/routes/index.ts

import { Router } from "express";
import {
  getMenus,
  addMenu,
  updateMenu,
  deleteMenu,
  retrieveMenu,
} from "../controllers/menus";
import {
  registerUser
} from "../controllers/users";

const menuRoutes: Router = Router();

const userRoutes: Router = Router();

// Menu Routes

menuRoutes.get("/menu", getMenus);
menuRoutes.post("/menu", addMenu);
menuRoutes.put("/menu/:id", updateMenu);
menuRoutes.delete("/menu/:id", deleteMenu);
menuRoutes.get("/menu/:id", retrieveMenu);

// User Routes

userRoutes.post("/user/register", registerUser);

export { menuRoutes, userRoutes };
Enter fullscreen mode Exit fullscreen mode

And inside the app.ts file, let's use the new route.

// src/app.ts

import { menuRoutes, userRoutes } from "./routes";
...
app.use(cors());
app.use(express.json());

app.use(userRoutes);
...
Enter fullscreen mode Exit fullscreen mode

The endpoint is available at localhost:4000/user/register.

Register Insomnia

Login

Inside the index.ts file of users controllers, let's write the login function.

// src/controllers/users/index.ts

const loginUser = async (
  req: Request,
  res: Response
): Promise<e.Response<any, Record<string, any>>> => {
  try {
    const { username, password } = req.body;
    if (!(username && password)) {
      return res.status(400).send("All inputs are required");
    }

    // Checking if the user exists

    const user: IUser | null = await User.findOne({ username });

    if (user && (await bcrypt.compare(password, user.password))) {
      // Create token
      const token = jwt.sign(
        { user_id: user._id, username: username },
        process.env.JWT_SECRET_KEY,
        {
          expiresIn: "5min",
        }
      );

      const refreshToken = jwt.sign(
        { user_id: user._id, username: username },
        process.env.JWT_SECRET_KEY
      );

      refreshTokens.push(refreshToken);

      // user
      return res.status(200).json({
        user: user,
        token: token,
        refresh: refreshToken,
      });
    }

    return res.status(400).send("Invalid Credentials");
  } catch (error) {
    throw error;
  }
};

export { registerUser, loginUser };
Enter fullscreen mode Exit fullscreen mode

So what are we doing here?

  • Check that the required fields have been provided
  • Check that the user exists
  • Compare the password and create new tokens if everything is right
  • Then send responses

If these validations are not done, we send error messages as well.

Add it to the routes and log in using https://localhost:4500/user/login.

// src/routes/index.ts

...
userRoutes.post("/user/login", loginUser);
...
Enter fullscreen mode Exit fullscreen mode

Protecting the Menu resources

Ah great. The Login endpoint is done, the registering endpoint also is done. But the resources are not protected.
You can still access them and because we need to write a middleware.

A middleware is a function that is used to that acts as a bridge between a request and a function to execute the requests.

Create a new directory named middleware inside src and create a file index.ts.

Great, let's write our middleware.

// src/middleware/index.ts

import e, { Response, Request, NextFunction } from "express";
import { IUser } from "../types/user";

const jwt = require("jsonwebtoken");

const authenticateJWT = async (
  req: Request,
  res: Response,
  next: NextFunction
): Promise<e.Response<any, Record<string, any>>> => {
  const authHeader = req.headers.authorization;

  if (authHeader) {
    const [header, token] = authHeader.split(" ");

    if (!(header && token)) {
      return res.status(401).send("Authentication credentials are required.");
    }

    jwt.verify(token, process.env.JWT_SECRET_KEY, (err: Error, user: IUser) => {
      if (err) {
        return res.sendStatus(403);
      }

      req.user = user;
      next();
    });
  }
  return res.sendStatus(401);
};

export default authenticateJWT;
Enter fullscreen mode Exit fullscreen mode

What are we doing here?

  • Making sure there are authorization headers. We actually want the values of this header to this format: 'Bearer Token'.
  • Verifying the token and then creating a new key with user as value. req.user = user
  • And finally using next() to execute the next function.

Now, let's use the middleware in our application.

// src/app.ts

import authenticateJWT from "./middleware";
...

app.use(userRoutes);

app.use(authenticateJWT);

app.use(menuRoutes);
...
Enter fullscreen mode Exit fullscreen mode

Did you notice something? The middleware is placed after the userRoutes and before menuRoutes.
Well, going like this, node & express will understand that the userRoutes are not protected and also that all the routes after the authenticateJWT will require an access token.

To test this, make a GET request to http://localhost:4000/menus without authorization header. You'll receive a 401 error.
Then use the access token from your previous login and add it to the authorization header.
You should retrieve the menus.

Refresh token

It's time now to write the refresh token controller.

// src/controllers/users/index.ts

const retrieveToken = async (
  req: Request,
  res: Response
): Promise<e.Response<any, Record<string, any>>> => {
  try {
    const { refresh } = req.body;
    if (!refresh) {
      return res.status(400).send("A refresh token is required");
    }

    if (!refreshTokens.includes(refresh)) {
      return res.status(403).send("Refresh Invalid. Please login.");
    }

    jwt.verify(
      refresh,
      process.env.JWT_SECRET_KEY,
      (err: Error, user: IUser) => {
        if (err) {
          return res.sendStatus(403);
        }

        const token = jwt.sign(
          { user_id: user._id, username: user.username },
          ")a(s3eihu+iir-_3@##ha$r$d4p5%!%e1==#b5jwif)z&kmm@7",
          {
            expiresIn: "5min",
          }
        );

        return res.status(201).send({
          token: token,
        });
      }
    );

    return res.status(400).send("Invalid Credentials");
  } catch (error) {
    throw error;
  }
};
Enter fullscreen mode Exit fullscreen mode

So what are we doing here?

  • Making sure that the refresh token exists in the body
  • Making sure that the refresh token exists in the memory of the server
  • And finally verifying the refresh token then sending a new access token.

Add this new controller to the userRoutes.

// src/routes/index.ts
...
userRoutes.post("/user/refresh", retrieveToken);
...
Enter fullscreen mode Exit fullscreen mode

You can hit http://localhost:4000/user/refresh to retrieve a new access token.

Insomnia refreshing token

Logout

But there is a problem. If the refresh token is stolen from the user, someone can use it to generate as many new tokens as they'd like. Let's invalidate this.

// src/controllers/users/index.ts
...
const logoutUser = async (
  req: Request,
  res: Response
): Promise<e.Response<any, Record<string, any>>> => {
  try {
    const { refresh } = req.body;
    refreshTokens = refreshTokens.filter((token) => refresh !== token);

    return res.status(200).send("Logout successful");
  } catch (error) {
    throw error;
  }
};

export { registerUser, loginUser, retrieveToken, logoutUser };
Enter fullscreen mode Exit fullscreen mode

And a new route to log out.

// src/routes/index.ts

import {
  loginUser,
  logoutUser,
  registerUser,
  retrieveToken,
} from "../controllers/users";
...
userRoutes.post("user/logout", logoutUser);
...
Enter fullscreen mode Exit fullscreen mode

You can hit http://localhost:4000/user/logout to invalidate the token.

And voilà, we're done. 🥳

Conclusion

In this article, we've learned how to build an authentication system for our Node & Express application using JWT.

And as every article can be made better so your suggestion or questions are welcome in the comment section. 😉

Check the code of this tutorial here.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player