MERN Stack Authentication: Building a Secure System from Scratch (Part 2)

MERN Stack Authentication: Building a Secure System from Scratch (Part 2)

Token Refresh and Role-Based Authorization with MERN Stack

ยท

8 min read

Introduction

In Part 1, we thoroughly examined the potential security threats that may arise during the development of the authentication system and began creating our own system using the MERN stack. We implemented the core features: login, logout, and registration on the backend using Node.js and Express.

In this next section, we'll explore refreshing access tokens with secure HTTP-only cookies as well as dive into role-based authorization! So, why not hop in and let's kick things off ๐Ÿš€

By the way, if you haven't checked out Part 1 yet, I highly recommend giving it a read for a clearer understanding of what we're working on here ๐Ÿ˜

Access Token Refresh

To give you a brief reminder: access tokens have a short lifespan, while refresh tokens last significantly longer. If the access token we send with any request is expired or invalid, the frontend will send an API request containing an HTTP-only cookie with a refresh token each time the access token expires. This is done to replace the expired access token with a new one. When the access token is renewed, the user can use it to request data from the server.

It's time to create the controller responsible for renewing the access token. In this controller, we will parse the sent cookies to obtain the refresh token. Next, we will find the user associated with this refresh token (as we store the refresh token in the database) and issue a new access token, which will be sent back as a response.

// controllers/auth.ts

export const refreshToken = async (req: Request, res: Response, next: NextFunction) => {
    try {
        const cookies = req.cookies;
        if (!cookies?.jwt) {
            return res.status(401).json({ message: "No refresh token" });
        }

        const refreshToken = cookies.jwt;
        const foundUser = await User.findOne({ refreshToken });
        if (!foundUser) {
            return res.status(401).json({ message: "No user with given refresh token" });
        }

        jwt.verify(
            refreshToken,
            process.env.REFRESH_TOKEN_SECRET || "",
            (err: Error | null, decoded: any) => {
                if (err) {
                    return res.status(403).json({ message: err.message });
                }

                const accessToken = generateJWT(decoded.id, process.env.ACCESS_TOKEN_SECRET || "", "5s");
                res.status(201).json({ accessToken });
            }
        );
    } catch (error) {
        next(error);
    }
};

As you can see, the concept is quite similar at the beginning to the controllers we created earlier.

However, what does jwt.verify() do? This function decodes the refresh token, obtains the decoded payload if the signature is valid (i.e., secret refresh token), and generates a new access token. That's it! Now, we can easily refresh the token โœจ

Authentication Routes

Now that we have addressed the primary controllers for authentication, let's define the routes for clients to send requests to our application's endpoints.

To do this, we will create a separate folder called routes and a file named auth.ts within it:

// routes/auth.ts

import {
    login, register, logout, refreshToken
} from "../controllers/auth";
import { Router } from "express";
import multer from "multer";
// Multer to process the "multipart/form-data"
const upload = multer();

const router = Router();
router.post("/login", upload.none(), login);
router.post("/register", upload.none(), register);
router.get("/logout", upload.none(), logout);
router.get("/refresh", refreshToken);

export default router;

I assume that you know how routing works in Express, but if you don't, you should read this guide on routing.

Note that I'm using Multer here to enable the Express server to process requests containing "multipart/form-data". If you're unfamiliar with Multer, I recommend reading the documentation.

You can also read my thread where I briefly discuss this topic:

Alright, one minor aspect to address is opening our index.ts file and adding login routes to our app:

// index.ts

...
import authRoutes from "./routes/auth";
...
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors(corsOptions));
app.use(cookieParser());
app.use("/auth", authRoutes);

Our auth system is almost ready to rock! Nice job!

Authorization

Before jumping to the frontend, let's create some logic related to users who have signed up for the system. We want to ensure that only authenticated users can access the data on our website, and that certain parts of the data can be accessed exclusively by users with a special role, such as the admin role. Let's begin by blocking unauthenticated users ๐Ÿ”

Restricting Access for Unauthenticated Users

To achieve this, we'll develop a special middleware that will serve as verification of whether the user is authenticated. Specifically, it will determine if the user has supplied an access token; if not, the middleware will send an error message.

We'll create a folder called middleware and a file named authMiddleware.ts inside it.

// middleware/authMiddleware.ts

import jwt, { JwtPayload } from "jsonwebtoken";
import User, { IUser } from "../models/User";

export interface AuthRequest extends Request {
    user?: IUser | null;
}

export const authMiddleware = async (req: AuthRequest, res: Response, next: NextFunction) => {
    const authHeader = req.headers.authorization;
    if (!authHeader) {
        return res.status(401).json({ message: "Authorization header is required" });
    }
    const accessToken = authHeader.split(" ")[1];
    if (!accessToken) {
        return res.status(401).json({ message: "Authorization token is required" });
    }

    jwt.verify(
        accessToken,
        process.env.ACCESS_TOKEN_SECRET || "",
        async (err, decoded) => {
            if (err) {
                return res.status(403).json({ message: "Invalid token" });
            }
            req.user = await User.findById((decoded as JwtPayload)?.id).select("-password");
            next();
        }
    );
};

Alright, let's break down what happens here.

  1. We first check for the presence of an authorization header within the request, as well as the presence of a token inside this header. Keep in mind that the request header "Authorization" typically contains a value in the format of "Bearer your_access_token".

  2. Similar to the process of refreshing the token, we decode the token passed within the Authorization header, which is the access token. Then we identify the user associated with the value of the decoded payload. If the user is found, we pass the object representing the user as part of the request and call the next() function to proceed.

Alright, this middleware helps us keep our routes safe from users who aren't logged in. Now, let's protect the routes from users who lack the appropriate role to access them.

Role-Based Authorization

We'll create a specialized controller to retrieve the list of all users, but we'll make it accessible only to admins. Keep in mind that our authorization system is simple, as we only have two types of roles: "user" and "admin".

First, we need to create this controller. We'll do this by creating a new file called users.ts inside the controllers folder:

// controllers/users.ts

export const getAllUsers = async (req: Request, res: Response) => {
    const users = await User.find();
    res.status(200).json(users);
};

It's quite simple, right? We're just fetching all the users from the database. ๐Ÿ˜Š

Next, let's create another middleware to make sure the user has the right role to access specific routes. This way, we'll keep our routes safe from unauthenticated (not logged in) or unauthorized (not having the right role) users.

Let's create a file called verifyRolesMiddleware.ts in the same folder:

// middleware/verifyRolesMiddleware.ts

import { AuthRequest } from "./authMiddleware";

export const verifyRolesMiddleware = (roles: string[]) => (req: AuthRequest, res: Response, next: NextFunction) => {
    if (req.user && roles.includes(req.user.role)) {
        next();
    } else {
        res.status(401).json({ message: "Unauthorized"});
    }
};

In this code, one of the arguments being passed is an array of user roles that are allowed to access the specified route (in this case, it's just "admin"). We then check if the array of allowed roles includes the role of the given user. It's important to note that we can access this information from the request, as the user object was previously attached in the first middleware, authMiddleware.ts.

Alright, that's it! Now it's time to combine the controller responsible for retrieving all users and newly created middleware. We'll create new routes inside routes/users.ts:

// routes/users.ts

import {
    getAllUsers
} from "../controllers/users";
import { Router } from "express";
import { authMiddleware } from "../middleware/authMiddleware";
import { verifyRolesMiddleware } from "../middleware/verifyRolesMiddleware";

const router = Router();
router.get("/", authMiddleware, verifyRolesMiddleware(["admin"]), getAllUsers);

export default router;

As you can see, in addition to calling getAllUsers, we're also invoking the middlewares to protect the route. Note that I am passing an array of roles allowed to access this specific route, i.e., ["admin"].

Similarly, as with the routes related to authentication, we need to add the routes related to users to the Express app inside index.ts:

// index.ts

app.use("/users", usersRoutes)

Error Handling

Alright, we have almost everything we need to jump into frontend development. However, I'd like to add a small yet essential feature.

To prevent unexpected errors on the backend, let's create a special middleware that will catch any errors that may occur and send an appropriate response to the user. So, let's create errorMiddleware.ts inside the middleware folder.

// middleware/errorMiddleware.ts

export const errorMiddleware = (error: Error, req: Request, res: Response, next: NextFunction) => {
    if (error instanceof Error) {
        return res.status(500).json({ message: error.message });
    } else {
        return res.status(500).json({ message: "Something went wrong" });
    }
};

It's important to add this middleware after setting up all the routes, so we can catch any errors that might happen in those routes.

// index.ts

app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors(corsOptions));
app.use(cookieParser());
app.use("/users", usersRoutes);
app.use("/auth", authRoutes);
app.use(errorMiddleware);

Wrap Up

Alright, we've now reached the end of Part 2 of this tutorial! ๐ŸŽ‰ You've done a fantastic job, and we've got our entire authentication system up and running. Since we don't have a frontend yet, go ahead and have some fun testing it with Postman.

In Part 3, we'll start building the frontend to make our app truly full-stack. We'll be using React as well as Redux for state management and Material UI for styling. It's going to be a super exciting part, so make sure you don't miss it! ๐Ÿ˜„

As usual, if you encounter any difficulties along the way, you can refer to the complete source code available on my GitHub repository. If you have any questions or need assistance, don't hesitate to reach out to me. Feel free to contact me, even if it's not related to this project ๐Ÿ˜ƒ

If you enjoyed this article, remember to follow me on Twitter, where I share daily updates. And feel free to connect with me on LinkedIn.

ย