User authentication and authorization in Node.js, Express.js app, using Typescript, Prisma, Zod and JWT
owo FROSTYY
Posted on June 9, 2024
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
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
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
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
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 ..
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`);
});
Done! Now, let's move on to the prisma setup:
In the terminal, type the following commands:
npx prisma
npx prisma init
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"
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:
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:
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):
Done! Now, let's open the terminal and run our first migration:
npx prisma migrate dev --name init
Install the prisma client:
npm install @prisma/client
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 ../..
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();
}
}
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()
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"
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
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?
- Improved Testability
- Flexibility and Maintainability
- Enhanced Readability.
- 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 ../..
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 } });
}
}
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 ../..
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);
}
}
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 ../..
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);
}
}
}
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 ../..
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;
}
}
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 ../..
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;
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...
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...
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 ../..
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 };
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 }
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);
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 ../..
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>;
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);
}
}
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 ../..
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;
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 };
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 ../..
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);
}
}
Let's add the above mentioned environmental variables to our .env file. Open the terminal:
node
require("crypto").randomBytes(64).toString('hex')
(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"
Now we will implement the JwtService in our AuthService.ts
:
cd src/services
touch AuthService.ts
cd ../..
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: "" });
}
}
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 ../..
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);
}
}
}
Done! Now, let's define our auth routes:
cd src/routes
touch AuthRoutes.ts
cd ../..
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 };
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 ../..
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();
};
}
}
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 ../..
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;
};
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 };
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 };
Done! Let's test our api to check if everything is working properly and we can finally wrap this whole thing up.
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:
Set the route to http://localhost:3000/api/users
and the method to GET
and click on Send
:
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:
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.
Now, get the new token and try to access the same http://localhost:3000/api/users
route:
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):
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!
Posted on June 9, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.