User authentication and authorization in Node.js, Express.js app, using Typescript, Prisma, Zod and JWT

owo_frostyy_df9242c6be6f5

owo FROSTYY

Posted on June 9, 2024

User authentication and authorization in Node.js, Express.js app, using Typescript, Prisma, Zod and JWT

Hey everyone! In this article, we're going to learn how to authenticate and authorize users. We're going to use the following tools for the task (Disclaimer: you don't have to use these tools in your code; it's just that I find them convenient to use. Feel free to use whatever tools you like as long as you're comfortable with them):

-Node.js
-Express.js
-Typescript
-Prisma ORM
-Neon (a serverless postgresql database)
-Zod (for validations)
-Bcrypt (for password hashing)
-JWT (for token generation and verification)

We're going to get started by opening the new project folder:

mkdir your_project_name
cd your_project_name
npm init -y
Enter fullscreen mode Exit fullscreen mode

Now, let's install all the necessary dependencies, so that we don't have to jump back and forth from our code to the terminal:

npm i express dotenv cookie-parser bcrypt jsonwebtoken zod @prisma/client
Enter fullscreen mode Exit fullscreen mode

Next, we'll install required types for above mentioned dependencies (keep in mind those are always installed as dev dependencies):

npm i -D typescript ts-node nodemon @types/node @types/express @types/cookie-parser @types/bcrypt @types/jsonwebtoken prisma
Enter fullscreen mode Exit fullscreen mode

Finally, let's install the touch cli to create files without leaving the terminal because we're so lazy to create new files on our own:

npm i -g touch-cli
Enter fullscreen mode Exit fullscreen mode

Before closing the terminal type the following commands to create the source folder in the root directory and app.ts file inside of it:

mkdir src
cd src
touch app.ts
cd ..
Enter fullscreen mode Exit fullscreen mode

And we're done, yay! We can finally start coding, right?

Welp, no! We're not quite done yet because now we need to set up commands for running our application in package.json, create a .gitignore file, and list the files and folders that we don't want to upload to GitHub. If we want to bring it any closer to how it's done in real projects, we need to set up ESLint rules and Prettier config to ensure consistent code style, etc. Pretty overwhelming, right? So much for project setup, huh? Well, not to worry, my friend. I got you covered, as I'm going to focus solely on proper project setup in my upcoming article soon. Just you wait! For now, we'll skip that part and jump straight to the code. And we'll continue by setting up the server in our src/app.ts file:

import express from "express";
import type { Request, Response, NextFunction } from "express";
import cookieParser from "cookie-parser";


const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());

//Handling not existing routes

app.use((_req: Request, res: Response, _next: NextFunction) => {
  res.status(404).send("Route not found")
});

//Initialize the server
app.listen(3000, () => {
  console.log(`[server]: server is running at 
  http://localhost:3000/api`);
});

Enter fullscreen mode Exit fullscreen mode

Done! Now, let's move on to the prisma setup:
In the terminal, type the following commands:

npx prisma
npx prisma init
Enter fullscreen mode Exit fullscreen mode

Explanation for teapots:
The first command invokes the prisma cli and the second generates a new folder in the root directory of you project called prisma with prisma.schema file inside. It also generates .env file with the fake connection string for the database:

DATABASE_URL="postgresql://janedoe:mypassword@localhost:5432/mydb?schema=sample"
Enter fullscreen mode Exit fullscreen mode

Let's open the prisma.schema file generated by prisma cli, but before that, if you're using VSCode make sure to go to your editor extensions tab and download the following extension:

Prisma extension in VSCode

This essentially allows you to have colorful code in your prisma.schema file. Yeah, you just downloaded the extension just to unlock colorful text in your .schema file ¯\_(ツ)_/¯, let's gooo. And now, it should look like this:

prisma.schema file

Now, I'm not going to explain every detail here because, for starters, this article would become much longer if I did. Second, I think everything in this file is pretty much self-explanatory. If anything is unclear, just read the documentation; that's what it's there for, right? So, to keep things short, this is essentially your Prisma configuration file, where your database and provider are set up. In our case, the provider is "postgresql." If you want to use other relational or non-relational databases such as SQLite or MongoDB, you'll need to replace "postgresql" and specify the type of database you're using.

Going back to where we left off, this prisma.schema file can also be used to define our entities and their relationships. We will use this file for that purpose from now on. So, let's define our User entity and Roles (which we will use later for user authorization):

updated prisma.schema file with User model and Role enum

Done! Now, let's open the terminal and run our first migration:

npx prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

Install the prisma client:

npm install @prisma/client
Enter fullscreen mode Exit fullscreen mode

And we're ready to connect our database. Let's GOOOOᕙ( •̀ ᗜ •́ )ᕗ! Open your src folder and create new folder called config and in there create db.ts file:

cd src
mkdir config
cd config
touch db.ts
cd ../..
Enter fullscreen mode Exit fullscreen mode

And it's here that we will define the function which will connect us to the database:

import { PrismaClient } from "@prisma/client"

export const db = new PrismaClient()

export async function connectToDB() {
  try {
     await db.$connect();
     console.log("[database]: connected!");
   } catch (err) {
     console.log("[database]: connection error: ", err);
     await db.$disconnect();
   }
}
Enter fullscreen mode Exit fullscreen mode

We are going to call this function whenever we run our application server. So, open the src/app.ts file and make the following changes:

//the rest of the code...
const initializeApp = async () => {
try {
   app.listen(3000, () => {
    console.log(`[server]: server is running at http://localhost:3000/api`);
   });
   await connectToDB();
 } catch (err) {
   console.error(err);
   process.exit(1);
 } 
}

initializeApp()
Enter fullscreen mode Exit fullscreen mode

Next, let's test the connection. BUT before that, we MUST replace the fake database connection string provided by Prisma in our .env file. For this, you'll have to go to the official Neon website. There, all you have to do is sign up, create a new project with a free plan, and get the newly generated connection string for your serverless database. Finally, replace the fake connection string with the one generated for you by Neon:

DATABASE_URL="postgresql://your:credentials@localhost:5432/db_name?schema=sample"
Enter fullscreen mode Exit fullscreen mode

And, we're good to go! Open the terminal, run npm run dev, and see if you're getting the following messages:

[database]: connected!
[server]: server is running at http://localhost:3000/api
Enter fullscreen mode Exit fullscreen mode

If you're not receiving these messages, then you definitely missed something. Retrace your steps to see if you skipped any steps or made any mistakes. Once everything is working correctly, we can proceed.

Before we return to coding, let me briefly introduce a couple of programming concepts that we will implement in this project later down the line.

Firstly, in our project, we're going to separate our logic into the following layers: repositories, services, and controllers. This pattern is commonly referred to as the Service Layer Pattern or Service-Oriented Architecture (SOA). This approach is widely used in designing enterprise applications, particularly for web applications and APIs. Here's what each layer is responsible for:

Repository (bottom layer): The repository serves as a data access layer, essentially providing a class through which you can access the database and perform necessary queries.

Service (middle layer): The service layer handles the business logic. It receives data from the request, processes it, and if you need to save something to the database or retrieve it, you do so by using your repository. This is achieved via dependency injection.

Controller (top layer): The controller handles requests and responses. It receives the request and passes the data to the service layer. Again, how do we access the service layer? Via dependency injection! The service processes the provided data, and if it returns something, you extract it and send an appropriate response to the client.

As for dependency injection, this is esentiallly a technique that aims to facilitate separation of concerns, which leads to loosely coupled programs. Why do we need it?

  1. Improved Testability
  2. Flexibility and Maintainability
  3. Enhanced Readability.
  4. Code Reusability

If you need a deeper explanation of these concepts, I'll write a separate article for that, just let me know in the comments.

Now that we have familiarized ourselves with these concepts, it'll be much easier for us to grasp what we're going to be doing in the next steps.

Step 1: Build the Layers
Let's build up our layers from the bottom to the top. We'll start by creating the repository layer. Open the terminal and type the following:

cd src
mkdir repositories
cd repositories
touch UserRepository.ts
cd ../..
Enter fullscreen mode Exit fullscreen mode

Close the terminal, open the UserRepository.ts file that you have just created, and write the following:

import { db } from "../config/db";
import type { Prisma } from "@prisma/client";

export default class UserRepository {
    private readonly db;
    constructor() {
        this.db = db;
    }
    async getAll() {
        return await this.db.user.findMany();
    }
    async getById(id: string) {
        return await this.db.user.findUnique({ where: { id } });
    }
    async getByKey(key: keyof Prisma.UserWhereInput, value: Prisma.UserWhereInput[keyof Prisma.UserWhereInput]) {
        return await this.db.user.findFirst({ where: { [key]: value } });
    }

    async create(data: Prisma.UserCreateInput) {
        return await this.db.user.create({ data });
    }

    async update(id: string, data: Prisma.UserUpdateInput) {
        return await this.db.user.update({ where: { id }, data });
    }
    async delete(id: string) {
        return await this.db.user.delete({ where: { id } });
    }
}

Enter fullscreen mode Exit fullscreen mode

As you can see, there is no business logic involved here, just plain and simple database queries. Let's move on to the next layer: the service layer. Open the terminal and paste the following commands:

cd src
mkdir services
cd services
touch UserService.ts
cd ../..
Enter fullscreen mode Exit fullscreen mode

Done! Open the UserService.ts file and paste this:

import type { Prisma } from "@prisma/client";
import bcrypt from "bcrypt";
import UserRepository from "../repositories/UserRepository";

const userRepository = new UserRepository();

export default class UserService {
  private userRepository: UserRepository;
  constructor() {
    //this is how dependency injection is done
    //we're injecting UserRepository into UserService
    this.userRepository = userRepository;
  }
  async getAll() {
    return await this.userRepository.getAll();
  }
  async getById(id: string) {
    const user = await this.userRepository.getById(id);
    if (!user) throw new Error("user not found");
    return user;
  }
  async getByKey(key: keyof Prisma.UserWhereInput, value: Prisma.UserWhereInput[typeof key]) {
    return await this.userRepository.getByKey(key, value);
  }
  async create(data: any) {
    const hashedPassword = await bcrypt.hash(data.password, 10);
    return await this.userRepository.create({ ...data, password: hashedPassword });
  }
  async update(id: string, data: any) {
    await this.getById(id);
    if (data.password)
      data.password = await bcrypt.hash(data.password, 10);
    return await this.userRepository.update(id, data);
  }
  async delete(id: string) {
    await this.getById(id);
    await this.userRepository.delete(id);
  }
}

Enter fullscreen mode Exit fullscreen mode

In this code snippet we're going to temporarily use built-in Error constructor, but we will replace it with our own custom error later on. On to the controller layer!

cd src
mkdir controllers
cd controllers
touch UserController.ts
cd ../..
Enter fullscreen mode Exit fullscreen mode

Open the UserController.ts file:

import type { NextFunction, Request, Response } from "express";
import UserService from "../services/UserService";

const userService = new UserService();
export default class UserController {
  private readonly userService: UserService;
  constructor() {
    this.userService = userService;
  }
  async getAll(_req: Request, res: Response, next: NextFunction) {
    try {
      const users = await this.userService.getAll();
      res.status(200).json({ users });
    } catch (err) {
      next(err);
    }
  }
  async getById(req: Request, res: Response, next: NextFunction) {
    try {
      const user = await this.userService.getById(req.params.id);
      res.status(200).json({ user });
    } catch (err) {
      next(err);
    }
  }
  async create(req: Request, res: Response, next: NextFunction) {
    try {
      const user = await this.userService.create(req.body);
      res.status(201).json({ user });
    } catch (err) {
      next(err);
    }
  }
  async update(req: Request, res: Response, next: NextFunction) {
    try {
      const user = await this.userService.update(req.params.id, req.body);
      res.status(200).json({ user });
    } catch (err) {
      next(err);
    }
  }
  async delete(req: Request, res: Response, next: NextFunction) {
    try {
      await this.userService.delete(req.params.id);
      res.sendStatus(204);
    } catch (err) {
      next(err);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice that we're not implementing getByKey method from the service since we created that method only to use it inside of our application logic.

Step 2: Error Handling
We'll start by creating our own HttpExceptions:

cd src
mkdir utils
cd utils
touch HttpExceptions.ts
cd ../..
Enter fullscreen mode Exit fullscreen mode

Open the HttpExceptions.ts file and paste this:

export abstract class CustomError extends Error {
  abstract readonly statusCode: number;
  abstract readonly errors: string[];
  abstract readonly isLogging: boolean;
  constructor(message: string) {
    super(message);
    Object.setPrototypeOf(this, CustomError.prototype);
  }
}

export class HttpException extends CustomError {
  readonly _statusCode: number;
  readonly _isLogging: boolean;
  constructor(
    statusCode = 500,
    message = "Something went wrong",
    isLogging = false
  ) {
    super(message);
    this._statusCode = statusCode;
    this._isLogging = isLogging;
    Object.setPrototypeOf(this, HttpException.prototype);
  }
  get errors() {
    return [this.message];
  }
  get statusCode() {
    return this._statusCode;
  }
  get isLogging() {
    return this._isLogging;
  }
}

export class HttpValidationExceptions extends CustomError {
  readonly _statusCode = 400;
  readonly _isLogging: boolean;
  readonly _errors: string[];
  constructor(errors = ["Bad Request"], isLogging = false) {
    super("Bad Request");
    this._errors = errors;
    this._isLogging = isLogging;
    Object.setPrototypeOf(this, HttpValidationExceptions.prototype);
  }
  get errors() {
    return this._errors;
  }
  get statusCode() {
    return this._statusCode;
  }
  get isLogging() {
    return this._isLogging;
  }
}

Enter fullscreen mode Exit fullscreen mode

So, first, we've built a CustomError absctract class which we then used to create our own HttpExceptions errors: one for common errors and other is for validation errors.

Next, let's create an ErrorHandler middleware that will catch these errors and send them in the proper format:

cd src
mkdir middlewares
cd middlewares
touch ErrorHandler.ts
cd ../..
Enter fullscreen mode Exit fullscreen mode

Open the ErrorHandler.ts:

import type { Request, Response, NextFunction } from "express";
import { CustomError } from "../utils/HttpExceptions";
import { Prisma } from "@prisma/client";

const ErrorFactory = (err: Error, res: Response) => {
  if (err instanceof CustomError) {
    const { statusCode, stack, isLogging, errors } = err;
    if (isLogging) {
      const logMessage = JSON.stringify({ statusCode, errors, stack }, null, 2);
      console.log(logMessage);
    }
    return res.status(statusCode).send({ errors });
  }
  if (err instanceof Prisma.PrismaClientKnownRequestError) {
    console.log(JSON.stringify(err, null, 2));
    return res.status(400).send({ errors: [{ message: "Bad Request" }] });
  }
  return null;
};

const ErrorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => {
  const handledError = ErrorFactory(err, res);
  if (!handledError) {
    console.log(JSON.stringify(`Unhandled error: ${err}`, null, 2));
    return res
      .status(500)
      .send({ errors: [{ message: "Internal server error" }] });
  }
};

export default ErrorHandler;

Enter fullscreen mode Exit fullscreen mode

So, our ErrorHandler middleware uses ErrorFactory to handle various error types, including Prisma errors. If error isn't handled by ErrorFactory, then we send Internal server error with status code of 500.

Let's use this middleware globally. Go to src/app.ts

import ErrorHandler from "./middlewares/ErrorHandler"
import { HttpException } from "./utils/HttpExceptions"

//the rest of the code...
//Handling not existing routes
app.use((_req: Request, _res: Response, next: NextFunction) => {
  next(new HttpException(404, "Route not found"));
});
//Error handling
app.use(ErrorHandler);

const initializeApp = async () => {
//the rest of the code...
Enter fullscreen mode Exit fullscreen mode

Now we can use our own custtom error class, so let's implement it inside of the UserService.ts:

//the rest of the code...
async getById(id: string) {
    const user = await this.userRepository.getById(id);
    if (!user) throw new HttpException(404, "User not found");
    return user;
  }
//the rest of the code...
Enter fullscreen mode Exit fullscreen mode

And with that, we can create our user routes. Open the terminal and paste the following:

cd src
mkdir routes
cd routes
touch AppRoutes.ts
touch UserRoutes.ts
cd ../..
Enter fullscreen mode Exit fullscreen mode

In the UserRoutes.ts file:

import { Router } from "express";
import UserController from "../controllers/UserController";

const userController = new UserController();
const router = Router()

router
  .get("/", userController.getAll.bind(userController))
  .get("/:id", userController.getById.bind(userController))
  .post("/", userController.create.bind(userController))
  .patch("/:id", userController.update.bind(userController))
  .delete(
    "/:id", userController.delete.bind(userController)
  );

export { router as UserRoutes };
Enter fullscreen mode Exit fullscreen mode

Next in the AppRoutes.ts file:

import { Router } from "express";
import { UserRoutes } from "./UserRoutes";

const router = Router();
router.use("/users", UserRoutes);

export { router as AppRoutes }
Enter fullscreen mode Exit fullscreen mode

Let's import and use AppRoutes inside of our src/app.ts file:

import { AppRoutes } from "./routes/AppRoutes"
//Routes
app.use("/api", AppRoutes);
//Handling not existing routes
app.use((_req: Request, _res: Response, next: NextFunction) => {
  next(new HttpException(404, "Route not found"));
});
//Error handling
app.use(ErrorHandler);

Enter fullscreen mode Exit fullscreen mode

Step 3: Validation

Now that we have user routes and error handling middleware, we can start implementing validations. Let's define our validation schemas using Zod. Open the terminal:

cd src
mkdir validations
cd validations
touch UserValidations.ts
cd ../..
Enter fullscreen mode Exit fullscreen mode

Inside of UserValidations.ts:

import { Role } from "@prisma/client";
import { z } from "zod";

const phoneRegex = new RegExp(/^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/);

export const createUserSchema = z.object({
  fname: z.string().min(1, { message: "Must contain at least 1 character" }),
  lname: z.string().min(1, { message: "Must contain at least 1 character" }),
  phone: z.string().regex(phoneRegex, "Must be a valid phone number"),
  email: z.string().email({ message: "Must be a valid email address" }),
  password: z.string().min(6, { message: "Must be at least 6 characters long" }),
  roles: z.array(z.enum([Role.ADMIN, Role.MANAGER, Role.USER])).optional(),
  refreshToken: z.string().optional(),
});

export type CreateUserInput = z.infer<typeof createUserSchema>;

export const updateUserSchema = createUserSchema.partial();

export type UpdateUserInput = z.infer<typeof updateUserSchema>;

export const registerUserSchema = createUserSchema.omit({
  roles: true,
  refreshToken: true,
});

export type RegisterUserInput = z.infer<typeof registerUserSchema>;

export const loginUserSchema = registerUserSchema.omit({ fname: true, lname: true }).extend({
  phone: z.string().regex(phoneRegex, "Must be a valid phone number").optional(),
  email: z.string().email({ message: "Must be a valid email address" }).optional(),
});

export type LoginUserInput = z.infer<typeof loginUserSchema>;
Enter fullscreen mode Exit fullscreen mode

Implement the types at UserService.ts:

import type { Prisma } from "@prisma/client";
import bcrypt from "bcrypt";
import UserRepository from "../repositories/UserRepository";
import { HttpException } from "../utils/HttpExceptions";
import type { CreateUserInput, UpdateUserInput } from "../validations/UserValidations";

const userRepository = new UserRepository();

export default class UserService {
  private userRepository: UserRepository;
  constructor() {
    this.userRepository = userRepository;
  }
  async getAll() {
    return await this.userRepository.getAll();
  }
  async getById(id: string) {
    const user = await this.userRepository.getById(id);
    if (!user) throw new HttpException(404, "User not found");
    return user;
  }
  async getByKey(key: keyof Prisma.UserWhereInput, value: Prisma.UserWhereInput[typeof key]) {
    return await this.userRepository.getByKey(key, value);
  }
  async create(data: CreateUserInput) {
    const hashedPassword = await bcrypt.hash(data.password, 10);
    return await this.userRepository.create({ ...data, password: hashedPassword });
  }
  async update(id: string, data: UpdateUserInput) {
    await this.getById(id);
    if (data.password)
      data.password = await bcrypt.hash(data.password, 10);
    return await this.userRepository.update(id, data);
  }
  async delete(id: string) {
    await this.getById(id);
    await this.userRepository.delete(id);
  }
}

Enter fullscreen mode Exit fullscreen mode

We didn't define zod schemas just to infer types from them. Let's use them to validate requests. Open the terminal:

cd src/middlewares
touch ValidateRequest.ts
cd ../..
Enter fullscreen mode Exit fullscreen mode

Inside of ValidateRequest.ts:

import type { Request, Response, NextFunction } from "express";
import { type z, ZodError } from "zod";
import { HttpValidationExceptions } from "../utils/HttpExceptions";

const ValidateRequest = (validationSchema: z.Schema) => {
  return (req: Request, _res: Response, next: NextFunction) => {
    try {
      validationSchema.parse(req.body);
      next();
    } catch (err) {
      if (err instanceof ZodError) {
        const errorMessages = err.errors.map(
        (error) => `${error.path.join(".")} is 
        ${error.message.toLowerCase()}`);
        next(new HttpValidationExceptions(errorMessages));
      }
    }
  };
};
export default ValidateRequest;

Enter fullscreen mode Exit fullscreen mode

Usage example at UserRoutes.ts:

//the rest of the code...
router
  .get("/", userController.getAll.bind(userController))
  .get("/:id", userController.getById.bind(userController))
  .post("/", ValidateRequest(createUserSchema), userController.create.bind(userController))
  .patch("/:id", ValidateRequest(updateUserSchema), userController.update.bind(userController))
  .delete(
    "/:id",
    authMiddleware.verifyPermissions("delete"),
    userController.delete.bind(userController)
  );

export { router as UserRoutes };
Enter fullscreen mode Exit fullscreen mode

Step 4: Repeat
Now that we have setup our UserController, UserRoutes, UserServices, validations, and error handling we will repeat the same process for our Auth module with its routes, services, controllers and middlewares. It's going to be much easier this time, trust me. Let's start by creating service for JWT.

cd src/services
touch JwtService.ts
cd ../..
Enter fullscreen mode Exit fullscreen mode

At JwtService.ts:

import jwt from "jsonwebtoken";
import { HttpException } from "../utils/HttpExceptions";
import dotenv from "dotenv"

dotenv.config();

export type AuthTokens = {
  accessToken: string;
  refreshToken: string;
};

const { ACCESS_TOKEN_SECRET, ACCESS_TOKEN_EXPIRY, REFRESH_TOKEN_SECRET, REFRESH_TOKEN_EXPIRY } = process.env as { [key: string]: string };

export class JwtService {
  genAuthTokens(payload: object): AuthTokens {
    const accessToken = this.sign(payload, ACCESS_TOKEN_SECRET, {
      expiresIn: ACCESS_TOKEN_EXPIRY,
    });
    const refreshToken = this.sign(payload, REFRESH_TOKEN_SECRET, {
      expiresIn: REFRESH_TOKEN_EXPIRY,
    });
    return { accessToken, refreshToken };
  }
  async verify(token: string, secret: string): Promise<jwt.JwtPayload> {
    const decoded: jwt.JwtPayload = await new Promise((resolve, reject) => {
      jwt.verify(token, secret, (err, decoded) => {
        if (err) reject(new HttpException(403, "Forbidden"));
        else resolve(decoded as jwt.JwtPayload);
      });
    });
    return decoded;
  }
  sign(payload: object, secret: string, options?: jwt.SignOptions): string {
    return jwt.sign(payload, secret, options);
  }
}

Enter fullscreen mode Exit fullscreen mode

Let's add the above mentioned environmental variables to our .env file. Open the terminal:

node
require("crypto").randomBytes(64).toString('hex')
Enter fullscreen mode Exit fullscreen mode

(To exit the node, press Ctrl + C in the terminal)

These commands will generate a random set of letters and numbers which we'll use as a secret for our jwt token. Run the second command again and generate another secret. Now, we have two. Let's use one for access token secret, and the other for refresh. Open the .env file and paste the following:

/* other env variables... */
/* paste the generated access secret key here */
ACCESS_TOKEN_SECRET="your_secret"
/* You can change it to hours, e.g: '1h' */
ACCESS_TOKEN_EXPIRY="60s"
/* paste the generated refresh secret key here */
REFRESH_TOKEN_SECRET="your_secret"
/* You can change it to hours, e.g: '1h' */
REFRESH_TOKEN_EXPIRY="1d"
Enter fullscreen mode Exit fullscreen mode

Now we will implement the JwtService in our AuthService.ts:

cd src/services
touch AuthService.ts
cd ../..
Enter fullscreen mode Exit fullscreen mode

Open AuthService.ts:

import type { Role } from "@prisma/client";
import bcrypt from "bcrypt";
import UserService from "./UserService";
import { JwtService, type AuthTokens } from "./JWTService";
import type { LoginUserInput, RegisterUserInput } from "../validations/UserValidations";
import { HttpException } from "../utils/HttpExceptions";
import dotenv from "dotenv";

dotenv.config()

const userService = new UserService();
const jwtService = new JwtService();

export default class AuthService {
  private readonly userService: UserService;
  private readonly jwtService: JwtService;
  constructor() {
    this.userService = userService;
    this.jwtService = jwtService;
  }
  async login(data: LoginUserInput): Promise<AuthTokens> {
    let user;
    if (data.phone) user = await this.userService.getByKey("phone", data.phone);
    else user = await this.userService.getByKey("email", data.email);
    if (!user || !(await bcrypt.compare(data.password, user.password)))
      throw new HttpException(400, "Wrong credentials");
    const { email, roles } = user;
    const { accessToken, refreshToken } = this.jwtService.genAuthTokens({ email, roles });
    await this.userService.update(user.id, { refreshToken });
    return { accessToken, refreshToken };
  }
  async register(data: RegisterUserInput): Promise<AuthTokens> {
    const newUser = await this.userService.create(data);
    const { email, roles } = newUser;
    const { accessToken, refreshToken } = this.jwtService.genAuthTokens({ email, roles });
    await this.userService.update(newUser.id, { refreshToken });
    return { accessToken, refreshToken };
  }
  async refresh(refreshToken: string): Promise<{ accessToken: string }> {
    const user = await this.userService.getByKey("refreshToken", refreshToken);
    if (!user) throw new HttpException(403, "Forbidden");
    const decoded = await this.jwtService.verify(
      refreshToken,
      process.env.REFRESH_TOKEN_SECRET as string
    );
    const isRolesMatch = user.roles.every((role: Role) => decoded.roles.includes(role));
    if (decoded.email !== user.email || !isRolesMatch)
      throw new HttpException(403, "Forbidden");
    const { accessToken } = this.jwtService.genAuthTokens({ email: user.email, roles: user.roles });
    return { accessToken };
  }
  async logout(refreshToken: string) {
    const user = await this.userService.getByKey("refreshToken", refreshToken);
    if (user) return await this.userService.update(user.id, { refreshToken: "" });
  }
}

Enter fullscreen mode Exit fullscreen mode

So, what is going on here? In our login logic, we are first checking whether the user is trying to login via his phone number or email. If we fail to find a user or if we do, but his password doesn't match with the one that we have found, we throw an error, simple as that. If the user's credentials match then we can generate new access and refresh tokens for him. We update the user by saving a new refresh token, which will come in handy when implementing the AuthMiddleware and refresh method. Next, the registration logic: we create a new user using our UserService. This is what I was referring to as "Code reusability". See, how we don't even have to write the logic for creating a new user again from scratch. We can simply inject our UserService class inside of the AuthService and reuse some of it's methods to keep the code simpler and shorter. As for the refresh logic, we receive the refresh token which will be extracted from req.cookies in AuthController, how you might ask? Well, we're going to store it there when login or registering a new user. So, we extract it from there and check if the user with this refresh token does exist in our database. If he doesn't we throw an error. If he does we decode the refresh token and extract the payload from it. The payload is the user roles and email that we have encrypted to our tokens when generating them. So now, we're checking if the email and roles from the payload match with one's from the user we have found via the refresh token. If they do, then it's the same user who's sending a request for a "refresh" or a new access token. Logout logic: in our AuthController we will check if the refresh token set in cookies is still there. If it is, we will send it to our AuthService, where we will find a user with the same refresh token and will set his refresh token to an empty string.

Now we can go ahead and create the AuthController and implement our auth logic there:

cd src/controllers
touch AuthController.ts
cd ../..
Enter fullscreen mode Exit fullscreen mode

Open AuthController.ts:

import type { Request, Response, NextFunction, CookieOptions } from "express";
import AuthService from "../services/AuthService";

const COOKIE_OPTIONS: CookieOptions = {
  httpOnly: true,
  maxAge: 24 * 60 * 60 * 1000,
  sameSite: "none",
  secure: process.env.NODE_ENV === "production",
};

const authService = new AuthService();

export default class AuthController {
  private readonly authService: AuthService;
  constructor() {
    this.authService = authService;
  }
  async login(req: Request, res: Response, next: NextFunction) {
    try {
      const { accessToken, refreshToken } = await this.authService.login(req.body);
      res
        .cookie("jwt", refreshToken, COOKIE_OPTIONS)
        .status(200)
        .send({ accessToken });
    } catch (err) {
      next(err);
    }
  }
  async register(req: Request, res: Response, next: NextFunction) {
    try {
      const { accessToken, refreshToken } = await this.authService.register(req.body);
      res
        .cookie("jwt", refreshToken, COOKIE_OPTIONS)
        .status(201)
        .send({ accessToken });
    } catch (err) {
      next(err);
    }
  }
  async refresh(req: Request, res: Response, next: NextFunction) {
    try {
      const { accessToken } = await this.authService.refresh(req.cookies.jwt);
      res.status(200).send({ accessToken });
    } catch (err) {
      next(err);
    }
  }
  async logout(req: Request, res: Response, next: NextFunction) {
    try {
      const refreshToken = req.cookies.jwt;
      if (!refreshToken) {
        res.sendStatus(204);
        return;
      }
      const user = await this.authService.logout(refreshToken);
      if (user) {
        res.clearCookie("jwt", COOKIE_OPTIONS).sendStatus(204);
        return;
      }
      res.clearCookie("jwt", COOKIE_OPTIONS).sendStatus(204);
    } catch (err) {
      next(err);
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Done! Now, let's define our auth routes:

cd src/routes
touch AuthRoutes.ts
cd ../..
Enter fullscreen mode Exit fullscreen mode

Open AuthRoutes.ts:

import { Router } from "express";
import AuthController from "../controllers/AuthController";
import ValidateRequest from "../middlewares/ValidateRequest";
import { loginUserSchema, registerUserSchema } from "../validations/UserValidations";

const router = Router();
const authController = new AuthController();

router
  .post("/login", ValidateRequest(loginUserSchema), authController.login.bind(authController))
  .post(
    "/register",
    ValidateRequest(registerUserSchema),
    authController.register.bind(authController)
  )
  .post("/refresh", authController.refresh.bind(authController))
  .post("/logout", authController.logout.bind(authController));

export { router as AuthRoutes };
Enter fullscreen mode Exit fullscreen mode

Done! Now, let's define the Auth middleware to protect our routes from being entered by unauthorized intruders:

cd src/middlewares
touch Auth.ts
cd ../..
Enter fullscreen mode Exit fullscreen mode

Open Auth.ts:

import type { Request, Response, NextFunction } from "express";
import { HttpException, HttpStatusCodes } from "../utils/HttpExceptions";
import jwt from "jsonwebtoken";
import configuration from "../config/configuration";
import type { Role } from "@prisma/client";
import { getPermissionsByRoles } from "../config/permissions";
import { JwtService } from "../services/JWTService";

export interface AuthRequest extends Request {
  user?: {
    email: string;
    roles: Role[];
  };
}


export default class Auth {
  constructor() {}
  async verifyToken(req: AuthRequest, _res: Response, next: NextFunction): Promise<void> {
  try {
    const { authorization } = req.headers;
    if (!authorization) throw new HttpException(401, "Unauthorized");
    const [type, token] = authorization.split(" ");
    if (type !== "Bearer")
      throw new HttpException(401, "Unauthorized");
    const decoded = await new JwtService.verify(token, ACCESS_TOKEN_SECRET);
    req.user = decoded as { email: string; roles: Role[] };
    next();
   } catch (err) {
      next(err)
   }
 }
  verifyRoles(allowedRoles: Role[]) {
    return (req: AuthRequest, _res: Response, next: NextFunction): void => {
      if (!req.user || !req.user?.roles)
        throw new HttpException(403, "Forbidden");
      const hasRoles = req.user.roles.some((role) => allowedRoles.includes(role));
      if (!hasRoles) throw new HttpException(403, "Forbidden");
      next();
    };
  }
  verifyPermissions(permission: string) {
    return (req: AuthRequest, _res: Response, next: NextFunction): void => {
      if (!req.user || !req.user?.roles)
        throw new HttpException(403, "Forbidden");
      const userPermissions = getPermissionsByRoles(req.user.roles);
      if (!userPermissions || !userPermissions.includes(permission))
        throw new HttpException(403, `You are forbidden to ${permission}`);
      next();
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's break it down: The authentication middleware executes before the request reaches the controller, acting as a guard to protect routes from unauthorized access. For instance, the verifyToken middleware checks if the user has signed in or signed up, as in both cases, the user receives an access token. This token is sent to the frontend and stored, typically in a cookie, session storage, or local storage, and is included in the request headers as an authorization token, e.g., "Bearer access_token" or sometimes "Token access_token". Whenever a user tries to access a protected route or resource, their request must include this token in the authorization header.

The verifyToken middleware verifies and decodes this token. If the request lacks the token, it is likely that the user is not logged in or has not signed up. If the token is present, it is verified and decoded. If the token is valid (not expired and of the correct type), the user is granted access to the route. Otherwise, a "Forbidden" error is sent. Finally, the middleware sets request.user to the decoded payload (email, roles) extracted from the token during the verification process. verifyRoles Middleware
Next up, we have the verifyRoles middleware. As the name implies, this middleware verifies the roles of the user attempting to access a route. It takes an array of allowed roles and checks if the user has any of these roles. If the user has the required roles, they are granted access to the route; otherwise, they are forbidden.

How does verifyRoles work?

This middleware runs after the verifyToken middleware, which sets req.user to the decoded payload from the access token. The payload contains the user's email and roles.
The middleware checks if the user's roles include at least one role from the allowed roles array. If at least one role matches, the user is authorized to proceed. Otherwise, access is denied.

Lastly, we have the verifyPermissions middleware. Much like verifyRoles, this middleware relies on the information provided in the access token and accesses user roles. Its purpose however, is to verify whether the user has roles that grant the specified permission.

How does verifyPermissions work?

Like verifyRoles, this middleware operates after the verifyToken middleware.
The "allowed permission" is passed as an argument to verifyPermissions. The middleware extracts the user's roles from req.user and uses a function getPermissionsByRoles (which we haven't yet defined but we will in a moment) to determine the permissions associated with these roles.
If the user's permissions include the specified permission, they are permitted to access the route. Otherwise, access is denied.

To sum up, these middlewares enhance security by ensuring that users have the appropriate roles and permissions to access specific routes or perform certain actions.

To define the permissions and getPermissionsByRoles function do the following:

cd src/config
touch permissions.ts
cd ../..
Enter fullscreen mode Exit fullscreen mode

Open permissions.ts:

import { Role } from "@prisma/client";

type Permissions = {
  [key: string]: {
    [key: string]: string;
  };
};

export const permissions: Permissions = {
  basic: {
    read: "read",
    create: "create",
    update: "update",
    delete: "delete",
  },
};

const userPermissions = [permissions.basic.read];
const managerPermissions = [...userPermissions, permissions.basic.create, permissions.basic.update];
const adminPermissions = [...managerPermissions, permissions.basic.delete];

const permissionsByRole = {
  [Role.USER]: userPermissions,
  [Role.MANAGER]: managerPermissions,
  [Role.ADMIN]: adminPermissions,
};

export const getPermissionsByRoles = (roles: Role[]) => {
  const permissionsSet = new Set<string>();
  roles.forEach((role) => {
    permissionsByRole[role].forEach((permission) => {
      permissionsSet.add(permission);
    });
  });
  const permissions = Array.from(permissionsSet);
  if (permissions.length === 0) return null;
  return permissions;
};

Enter fullscreen mode Exit fullscreen mode

For now, we will keep the permissions simple and straightforward and store them as basic permissions. You can always define your own permissions by expanding the permissions object. Currently, our users are only allowed to "read", while managers and admins can perform any actions except managers can not "delete".

To showcase the use of our Auth middleware let's go ahead and use it to protect the UserRoutes.
At AppRoutes.ts:

import { Router } from "express";
import { UserRoutes } from "./UserRoutes";
import { AuthRoutes } from "./AuthRoutes";
import Auth from "../middlewares/Auth";
import { Role } from "@prisma/client";

const router = Router();
const authMiddleware = new Auth();
router.use(
  "/users",
  authMiddleware.verifyToken,
  authMiddleware.verifyRoles([Role.MANAGER, Role.ADMIN]),
  UserRoutes
);
router.use("/auth", AuthRoutes);
export { router as AppRoutes };
Enter fullscreen mode Exit fullscreen mode

At UserRoutes.ts:

import { Router } from "express";
import UserController from "../controllers/UserController";
import ValidateRequest from "../middlewares/ValidateRequest";
import { createUserSchema, updateUserSchema } from "../validations/UserValidations";
import Auth from "../middlewares/Auth";

const userController = new UserController();
const authMiddleware = new Auth();
const router = Router();

router
  .get("/", userController.getAll.bind(userController))
  .get("/:id", userController.getById.bind(userController))
  .post("/", ValidateRequest(createUserSchema), userController.create.bind(userController))
  .patch("/:id", ValidateRequest(updateUserSchema), userController.update.bind(userController))
  .delete(
    "/:id",
    authMiddleware.verifyPermissions("delete"),
    userController.delete.bind(userController)
  );

export { router as UserRoutes };
Enter fullscreen mode Exit fullscreen mode

Done! Let's test our api to check if everything is working properly and we can finally wrap this whole thing up.

Postman testing of auth/register route

We have just registered a new user! Now let's take this access token and set it in authorization headers. Click on "Headers" tab if you're also testing the api in Postman and set the authorization header as shown in image below:

Setting authorization headers

Set the route to http://localhost:3000/api/users and the method to GET and click on Send:

Our Api sent us error

And we got an error, as we were supposed to since the expiry date of our access token is set to 60 seconds and I'm pretty sure that by the time all of you guys reading this article got to the point of pasting the token in headers and clicked on send, the minute has long passed. So, try it again following the same instructions or simply go to your .env file and prolong the expiry date of your access token. Let's try it again:

The Forbidden error again

We're still getting an error. That is because we only allow admins or managers to access the user routes. Let's temporarily loosen our security and comment out the role verification.

Comment verifyRoles middleware applied on user routes

Now, get the new token and try to access the same http://localhost:3000/api/users route:

The route returned our users

Copy any user's id and change the url as follows http://localhost:3000/api/users/your_user_id and change the method to DELETE, then Send (don't forget to refresh your token):

Delete failed since our user is neither manager nor admin

There you go. Our "verifyPermissions" middleware has worked properly. Since our user is not an admin he isn't permitted to delete other users. You can now uncomment the verifyRoles middleware at AppRoutes.ts.

Congratulations! We have implemented user authentication and authorization in our backend express api. This was a long read for sure, but I hope you guys enjoyed it. If you did, pls be sure to leave a like and sub for more content and if you have any suggestions or complaints regarding the code or implementations then please make sure to inform me in the comments below. Good luck on your programming journey! Bye!

💖 💪 🙅 🚩
owo_frostyy_df9242c6be6f5
owo FROSTYY

Posted on June 9, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related