Part Three(A): Role and Permission Based Authentication using JWT Access Token

Ermias Asmare
20 min readJun 10, 2024

--

Hello there, this is part two Mastering Express, Typescript, and MongoDB: A Comprehensive Guide to Building and Deploying Web Applications. in this part of the series we are gone talk about user authentication using JWT access token. if you have not check the previous part of the series check it out HERE, its essential for this part since most of error handling and middle-ware are configured in the previous part.

What is JWT access token: JWT (JSON Web Token) access token is a compact and self-contained token format used for securely transmitting information between parties as a JSON object. It is commonly used in web applications for authentication annd authorization purposes. The access token contains claims that represent user identity and additional metadata, such as the token expiration time. JWT tokens are digitally signed using a secret key or a public/private key pair, providing a means to verify the authenticity and integrity of the token. They are lightweight, stateless, and can be easily decoded to extract the contained information, making them suitable for use in distributed systems and APIs. Overall, JWT access tokens provide a secure and efficient method for verifying the identity of users and controlling access to resources within an application.

Okay now lets get to work, so first thing first let create the user model and interface. lets create a file called user.model.ts inside the src folder.

user.model.ts

import { Schema, model } from 'mongoose';
import { IUser } from '../interfaces/user.interface';
const userSchema = new Schema<IUser>({
password: {
type: String,
minlength: [6, 'Password should have at least 6 characters'],
required: [true, 'Password is required'],
select: false,
},
phoneNumber: {
type: String,
unique: true, // Assuming email should be unique
validate: {
validator: function(v: string) {
// Example: Validate if the phone number follows a specific format
return /\d{3}-\d{3}-\d{4}/.test(v);
},
message: props => `${props.value} is not a valid phone number!`
},
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true, // Assuming email should be unique
validate: {
validator: function(v: string) {
// Example: Validate if the email follows a specific format
return /\S+@\S+\.\S+/.test(v);
},
message: props => `${props.value} is not a valid email address!`
},
},
name: {
type: String,
required: [true, 'Name is required'],
},
isActive: {
type: Boolean,
default: false,
},
OTPCode: {
type: String,
select: false,
},
OTPCodeExpires: {
type: Number,
select: false,
},
passwordResetCode: {
type: String,
select: true,
},
role: {
type: Schema.Types.ObjectId,
ref: 'Role',
required: [true, 'Role is required'],
},
}, { timestamps: true });
export default model<IUser>('User', userSchema);

user.interface.ts

import { Document, Schema } from 'mongoose';
import { IRole } from './roles.interface';
export interface IUser extends Document {
password: string;
phoneNumber: string;
email: string;
name: string;
isActive: boolean;
OTPCode?: string;
OTPCodeExpires?: number;
passwordResetCode?: string;
role: IRole; // Reference to the Role model
}

Mongoose Model:

  • The Mongoose model defines the structure of a document within a MongoDB collection.
  • In this case, the User model specifies the fields and their properties that each user document in the database should have.
  • It utilizes Mongoose’s schema definition to enforce data validation, ensuring that the data stored in the database meets certain criteria.
  • The model also includes additional settings such as timestamps for automatic tracking of creation and modification dates.

TypeScript Interface:

  • The TypeScript interface, IUser, defines the structure of a user object in TypeScript.
  • It specifies the types of each property that a user object should have.
  • By using interfaces, you can ensure type safety in your TypeScript code, providing better code readability and reducing potential errors.
  • The IUser interface extends the Document interface provided by Mongoose, indicating that it represents a document in the MongoDB database.
  • The role property in the IUser interface is a reference to the IRole interface, indicating that it should be populated with data from the Role model.

Noticeably, the current user model lacks fields for personal information such as name, age, and address. To enhance security and adhere to best practices, it’s prudent to segregate this personal information from sensitive authentication data. This separation mitigates the risk of inadvertently exposing confidential details, like passwords or reset codes, to the frontend of our application.

By introducing a separate model dedicated to personal information, we can ensure that only non-sensitive data is accessible to the frontend. This approach bolsters security measures and streamlines data management practices.

In essence, this strategy not only safeguards sensitive authentication details but also promotes a more organized and efficient data architecture within our application.

so lets create a model called profile.model.ts in our model folder.

src/model/profile.model.ts

import { Schema, model } from 'mongoose';
import { IProfile } from '../interface/profile.inerface';
const profileSchema = new Schema<IProfile>({
firstName: {
type: String,
},
lastName: {
type: String,
},
age: {
type: Number,
min: [0, 'Age cannot be negative'],
},
addresses: {
type: String,
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
validate: {
validator: function(v: string) {
return /\S+@\S+\.\S+/.test(v);
},
message: props => `${props.value} is not a valid email address!`
},
},
phoneNumber: {
type: String,
unique: true,
validate: {
validator: function(v: string) {
return /\d{3}-\d{3}-\d{4}/.test(v);
},
message: props => `${props.value} is not a valid phone number!`
},
},
userId: {
type: Schema.Types.ObjectId,
ref: 'User',
required: [true, 'User ID is required'],
unique: true,
index: true,
},
}, { timestamps: true });
export default model<IProfile>('Profile', profileSchema);

src/interface/profile.interface.ts

// profile.interface.ts
import { Document, ObjectId, Schema } from 'mongoose';
export interface IProfile extends Document {
firstName?: string;
lastName?: string;
age?: number,
addresses?: string;
email: string,
phoneNumber?:string,
userId: Schema.Types.ObjectId;
}

Mongoose Model:

  • The Mongoose model named Profile defines the structure and behavior of profile documents within a MongoDB collection.
  • It specifies various fields such as firstName, lastName, age, addresses, email, phoneNumber, and userId.
  • Data validation rules are enforced through Mongoose schema, ensuring that the data stored in the database meets specified criteria.
  • Certain fields like email and phoneNumber are marked as required and unique, with custom validation functions to ensure data integrity.
  • The userId field is a reference to the User model, ensuring relational integrity between profiles and users.
  • The model includes timestamps for automatic tracking of creation and modification dates.

TypeScript Interface:

  • The TypeScript interface named IProfile provides type definitions for profile objects in TypeScript code.
  • It specifies the types of each property that a profile object should have.
  • Properties like firstName, lastName, age, addresses, email, phoneNumber, and userId are defined with their respective types.
  • The IProfile interface extends the Document interface provided by Mongoose, indicating that it represents a document in the MongoDB database.
  • The userId property is defined as a reference to the Schema.Types.ObjectId, indicating that it should hold the MongoDB ObjectId type.

Let’s create the model and typescript interface fro role,create a file “role.model.ts” inside the model folder.

src/model/role.model.ts

import { Schema, model } from 'mongoose';
import { IRole } from '../interface/roles.interface'

const roleSchema = new Schema<IRole>({
name: { type: String, required: true, unique: true, index: true }, // e.g., Cashier, Admin
permissions: [{ type: String, required: true }], // List of permissions, e.g., ['VIEW_ORDERS', 'MANAGE_INVENTORY']
grantAll: { type: Boolean, default: false },
}, { timestamps: true });
export default model<IRole>('Role', roleSchema);

and inside the interface folder create a file role.interface.ts

src/interface/role.interface.ts

// role.interface.ts
import mongoose, { Document, ObjectId, Schema } from "mongoose";
export interface IRole {
_id?:ObjectId,
name?: string;
permissions: [string];
grantAll?: boolean
}
export enum RoleInterface {
SUPER_ADMIN = 'SUPER_ADMIN',
ADMIN = 'ADMIN',
}

Mongoose Model:

  • The Mongoose model named Role defines the structure and behavior of role documents within a MongoDB collection.
  • It specifies two main fields: name and permissions.
  • name represents the name of the role (e.g., Cashier, Admin). It's marked as required, unique, and indexed for efficient querying.
  • permissions is an array of strings representing the permissions associated with the role. Each permission is a string value.
  • Additionally, there’s a grantAll field which indicates whether the role has all permissions by default. It defaults to false.
  • The model includes timestamps for automatic tracking of creation and modification dates.

TypeScript Interface:

  • The TypeScript interface named IRole provides type definitions for role objects in TypeScript code.
  • It specifies the types of each property that a role object should have.
  • Properties like _id, name, permissions, and grantAll are defined with their respective types.
  • _id is an optional field of type ObjectId, representing the MongoDB ObjectId.
  • name is optional and represents the name of the role.
  • permissions is an array of strings representing the permissions associated with the role.
  • grantAll is optional and represents whether the role has all permissions by default.
  • Additionally, there’s an enum named RoleInterface which defines predefined role names for convenience and type safety.

Next we ware going to work on validation for our model we are going to use zod and create schema to validatie the input coming from the frontend application.

first create the middleware for zod validation

import { Request, Response, NextFunction } from "express";
import { AnyZodObject } from "zod";

const validateSchema =
(schema: AnyZodObject) =>
(req: Request, res: Response, next: NextFunction) => {
try {
schema.parse({
body: req.body,
query: req.query,
params: req.params,
});
next();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const message = e.errors.map((err: any) => err.message)
return res.status(400).json({ message: message.join(','), sucess: false });
}
};
export default validateSchema;

src/validation/user.validation.ts

import { object, string, array, TypeOf } from "zod";
export const createUserSchema = object({
body: object({
email: string({ required_error: "Email is required" }).email("Invalid email format"),
password: string({ required_error: "Password is required" }).min(6, "Password must be at least 6 characters"),
name: string({ required_error: "Name is required" }),
phoneNumber: string().regex(/\d{3}-\d{3}-\d{4}/, "Invalid phone number format").optional(),
role: string({ required_error: "Role is required" }),
}),
});
export type CreateUserInput = TypeOf<typeof createUserSchema>["body"];

src/validation/profile.validation.ts

import { object, string, number, Schema } from "zod";
export const createProfileSchema = object({
body: object({
firstName: string().optional(),
lastName: string().optional(),
age: number().min(0, { message: "Age cannot be negative" }).optional(),
addresses: string().optional(),
email: string({ required_error: "Email is required" }).email("Invalid email format"),
phoneNumber: string().regex(/\d{3}-\d{3}-\d{4}/, { message: "Invalid phone number format" }).optional(),
userId: string({ required_error: "User ID is required" }),
}),
});

src/validation/role.validation.ts

import { object, string, boolean, array, Schema } from "zod";
export const createRoleSchema = object({
body: object({
name: string({ required_error: "Name is required" }),
permissions: array(string({ required_error: "At least one permission is required" })),
grantAll: boolean().optional(),
}),
});

Okay now we have handled model, interface and validation. lets start working on middle ware and error handling. In the previous part of the series we have worked on error handling, but we have not created an error handler for unauthenticated users.

import CustomAPIError, { ErrorCode } from "./custom.errors";
class UnAuthenticatedError extends CustomAPIError {
statusCode: number;
constructor(message: string, errorCode: ErrorCode) {
super(message, errorCode, 404, null);
this.statusCode = 401;
}
}
export default UnAuthenticatedError;

the class UnAuthenticatedError, inheriting from the CustomAPIError class. It sets the status code to 401 (Unauthorized) and allows customization of the error message through its constructor. By exporting UnAuthenticatedError as the default export, it facilitates the consistent handling of authentication errors within the application.

lets add a custom code for unAuthenticated error on custom.error.ts

class CustomAPIError extends Error {
message: string
errorCode: ErrorCode
statusCode: number
error: any
constructor(message: string, errorCode: ErrorCode, statusCode: number, error: any) {
super(message);
this.message = message
this.errorCode = errorCode
this.statusCode = statusCode
this.error = error
}
}
export enum ErrorCode {
NOT_FOUND = 1001,
ALREADY_EXST = 1002,
FORBIDDEN = 1003,
INTERNAL_SERVER = 1005,
TOO_MANY_REQUEST = 1006,
BAD_REQUEST= 1007,
UNAUTHENTICATED_USER= 1008,
}
export default CustomAPIError;

Okay next lets work on the service function, for user, role and profile model.

src/services/user.services.ts

import { FilterQuery, QueryOptions, Schema, UpdateQuery } from "mongoose";
import { IUser } from "../interface/user.interface";
import UserModel from "../model/user.model";
import roleModel from "../model/role.model";
export async function findAllUsers() {
return await UserModel.find()
}
export async function findUserById(id: string) {
return await UserModel.findById(id)
}

export const findExtendedUsers = async (userId: string) => {
return await UserModel.findById(userId)
.populate('role')
.exec();
};
export async function findUserByEmail(email: string) {
return await UserModel.findOne({ email: email });
}

export async function findUserByPhone(phoneNumber: string) {
return await UserModel.findOne({ phoneNumber: phoneNumber });
}
export async function findUser(
query: FilterQuery<IUser>,
options: QueryOptions = { lean: true }
): Promise<IUser | null> {
return await UserModel.findOne(query, {}, options);
}

export async function createUser(userData: Partial<IUser>) {
try {
const result = await UserModel.create(userData);
return { data: result, success: true };
} catch (error) {
return { data: null, success: false, error };
}
}

export async function updateUserById(
id: string,
update: UpdateQuery<IUser>,
options: QueryOptions = { new: true }
) {
try {
const result = await UserModel.findByIdAndUpdate(id, update, options);
return { data: result, success: true };
} catch (error) {
return { data: null, success: false, error };
}
}
export async function deleteUserById(id: string) {
return await UserModel.deleteOne({ _id: id });
}

This code appears to be a set of functions related to user management using Mongoose, which is an Object Data Modeling (ODM) library for MongoDB and Node.js. Here’s a summarized explanation of each function:

  1. findAllUsers(): Retrieves all users from the database.
  2. findUserById(id: string): Finds a user by their ID.
  3. findExtendedUsers(userId: string): Finds a user by ID and populates the 'role' field from another model (roleModel) associated with the user.
  4. findUserByEmail(email: string): Finds a user by their email address.
  5. findUserByPhone(phoneNumber: string): Finds a user by their phone number.
  6. findUser(query: FilterQuery<IUser>, options: QueryOptions): Finds a user based on the provided query and options. The query can include filter conditions based on the IUser interface.
  7. createUser(userData: Partial<IUser>): Creates a new user with the provided user data.
  8. updateUserById(id: string, update: UpdateQuery<IUser>, options: QueryOptions): Updates an existing user by ID with the provided update data.
  9. deleteUserById(id: string): Deletes a user by their ID from the database.

Overall, these functions provide basic CRUD (Create, Read, Update, Delete) operations for managing user data in a MongoDB database using Mongoose.

src/services/profile.services.ts

import { FilterQuery, QueryOptions, UpdateQuery } from "mongoose";
import { IProfile } from "../interface/profile.inerface";
import ProfileModel from "../model/profile.model";

export async function getAllProfiles() {
return await ProfileModel.find().populate({
path: 'userId',
select: "-password -OTPCode -OTPCodeExpires -passwordResetCode -business",
})
.exec();
}
export async function findProfileById(id: string) {
return await ProfileModel.findById(id).populate({
path: 'userId',
select: "-password -OTPCode -OTPCodeExpires -passwordResetCode -business",
})
.exec();
}
export async function findProfile(
query: FilterQuery<IProfile>,
options: QueryOptions = { lean: true }
): Promise<IProfile | null> {
return await ProfileModel.findOne(query, {}, options);
}
export async function createProfile(profileData: Partial<IProfile>) {
try {
const result = await ProfileModel.create(profileData);
return { data: result, success: true };
} catch (error) {
return { data: null, success: false, error };
}
}
export async function deleteProfileById(id: string) {
return await ProfileModel.deleteOne({ _id: id });
}
export async function updateProfileById(
id: string,
update: UpdateQuery<IProfile>,
options: QueryOptions = { new: true }
) {
try {
const result = await ProfileModel.findByIdAndUpdate(id, update, options);
return { data: result, success: true };
} catch (error) {
return { data: null, success: false, error };
}
}

This code defines functions for managing user profiles using Mongoose. Here’s a brief explanation of each function:

  1. getAllProfiles(): Retrieves all user profiles from the database, populating the 'userId' field with related user data while excluding sensitive information like password and OTP codes.
  2. findProfileById(id: string): Finds a user profile by its ID, populating the 'userId' field similarly to getAllProfiles().
  3. findProfile(query: FilterQuery<IProfile>, options: QueryOptions): Finds a user profile based on the provided query and options. The query can include filter conditions based on the IProfile interface.
  4. createProfile(profileData: Partial<IProfile>): Creates a new user profile with the provided profile data.
  5. deleteProfileById(id: string): Deletes a user profile by its ID from the database.
  6. updateProfileById(id: string, update: UpdateQuery<IProfile>, options: QueryOptions): Updates an existing user profile by ID with the provided update data.

These functions offer basic CRUD operations for managing user profiles, similar to the functions for managing users in the previous code snippet. They allow for creating, reading, updating, and deleting user profile data stored in a MongoDB database using Mongoose.

src/services/role.services.ts

import { FilterQuery, QueryOptions, UpdateQuery } from "mongoose";
import {IRole} from "../interface/roles.interface";
import RoleModel from "../model/role.model";

export async function getAllRoles() {
return await RoleModel.find();
}
export async function findRoleById(id: string) {
return await RoleModel.findById(id);
}
export async function findRoleByName(name: string) {
return await RoleModel.findOne({ name: name } );
}
export async function findRole(
query: FilterQuery<IRole>,
options: QueryOptions = { lean: true }
): Promise<IRole | null> {
return await RoleModel.findOne(query, {}, options);
}
export async function createRole(roleData: Partial<IRole>) {
try {
const result = await RoleModel.create(roleData);
return { data: result, success: true };
} catch (error) {
return { data: null, success: false, error };
}
}
export async function deleteRoleById(id: string) {
return await RoleModel.deleteOne({ _id: id });
}
export async function updateRoleById(
id: string,
update: UpdateQuery<IRole>,
options: QueryOptions = { new: true }
) {
try {
const result = await RoleModel.findByIdAndUpdate(id, update, options);
return { data: result, success: true };
} catch (error) {
return { data: null, success: false, error };
}
}

This code snippet provides functions for managing roles using Mongoose. Here’s a breakdown of each function:

  1. getAllRoles(): Retrieves all roles from the database.
  2. findRoleById(id: string): Finds a role by its ID.
  3. findRole(query: FilterQuery<IRole>, options: QueryOptions): Finds a role based on the provided query and options. The query can include filter conditions based on the IRole interface.
  4. createRole(roleData: Partial<IRole>): Creates a new role with the provided role data.
  5. deleteRoleById(id: string): Deletes a role by its ID from the database.
  6. updateRoleById(id: string, update: UpdateQuery<IRole>, options: QueryOptions): Updates an existing role by ID with the provided update data.

These functions facilitate basic CRUD operations for managing roles stored in a MongoDB database using Mongoose. They allow for creating, reading, updating, and deleting role data.

Okay lest get started working on authenticating the user, lets first install JWT packge.

npm i jsonwebtoken

lets first start working on the authentication middleware, then lets create a file called “authJWT.middleware.ts” inside the “middleware ” folder.

import { NextFunction, Request, Response } from "express";

This line imports necessary types from the Express framework for handling HTTP requests and responses.

import jwt from "jsonwebtoken";

This line imports the `jsonwebtoken` library for generating and verifying JSON Web Tokens (JWTs).

import UnAuthenticatedError from "../errors/unauthenticated.errors";
import ForbiddenError from "../errors/forbidden.errors";
import { ErrorCode } from "../errors/custom.errors";

These lines import custom error classes and an error code enumeration from error files.

import { validateEnv } from "../config/env.config";
import { findExtendedUsers } from "../services/user.services";
import NotFoundError from "../errors/notFound.errors";
import { IRole } from "../interface/roles.interface";
import { IUser } from "../interface/user.interface";

These lines import environment validation, user service functions, error classes, and interfaces related to user roles and user data.

import { extractTokenfromHeader } from "../utils/util";

This line imports a utility function `extractTokenfromHeader` from a util file. lets go to util file, and create the extractTokenfromHeader function.

src/utils/util.ts

import { Request } from "express";
export const extractTokenfromHeader = (req: Request) => {
const authHeader = req.headers.authorization || req.headers.Authorization as string;
if (!authHeader?.startsWith("Bearer ")) {
return false
}
return authHeader.split(" ")[1];
}

okay lets get back to the authJWT.middleware.ts file and continue

export interface UserDataType {
userId: string;
permission?: IRole["permissions"]
role?: IRole
}

This interface defines the shape of the user data, including userId, user permissions, and user role.

export interface IUserMessage<TParams = any, TQuery = any, TBody = any> extends Request<TParams, TQuery, TBody> {
userData: UserDataType;
}

This interface extends the Express Request interface and adds a `userData` property of type `UserDataType`.

type ExtendedUser = IUser & {
permission?: IRole
}

This line defines a type `ExtendedUser` which extends the `IUser` interface and adds an optional `permission` property of type `IRole`.

export const AuthJWT = (
req: IUserMessage,
res: Response,
next: NextFunction
) => {

This line defines a middleware function `AuthJWT` which takes a request (`req`), response (`res`), and next middleware function (`next`).

try {
const jwtconfig = validateEnv()?.jwtconfig
const token = extractTokenfromHeader(req)
if (!token) throw new UnAuthenticatedError("Provide token", ErrorCode.TOKEN_NOT_FOUND);

Here, the code tries to retrieve JWT configuration from the environment, extracts the JWT token from the request header using the utility function, and throws an error if the token is not present.

jwt.verify(token, jwtconfig?.accessSecret, async (err, decoded) => {
if (err) return next(new ForbiddenError("Token expires", ErrorCode?.TOKEN_EXPIRE));
const decodeData = decoded as UserDataType;
const userWithPermission = await findExtendedUsers(decodeData?.userId)
if (!userWithPermission) throw new NotFoundError("User not found", ErrorCode.NOT_FOUND)
req.userData = {
userId: decodeData?.userId,
permission: userWithPermission?.role?.permissions,
role: userWithPermission.role
}
next();
});

This block verifies the JWT token using the access secret from the JWT configuration, handles errors if the token is invalid or expired, retrieves user data including permissions using the decoded user ID, sets `userData` property on the request object, and calls the next middleware function.

} catch (err) {
throw new UnAuthenticatedError("Provide token", ErrorCode.TOKEN_NOT_FOUND);
}
};

This block catches any errors thrown during token verification or user data retrieval and throws an authentication error if the token is not provided or invalid.

authJWT.middleware.ts

import { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";
import UnAuthenticatedError from "../errors/unauthenticated.errors";
import ForbiddenError from "../errors/forbidden.errors";
import { ErrorCode } from "../errors/custom.errors";
import { validateEnv } from "../config/env.config";
import { findExtendedUsers } from "../services/user.services";
import NotFoundError from "../errors/notFound.errors";
import { IRole } from "../interface/roles.interface";
import { IUser } from "../interface/user.interface";
import { extractTokenfromHeader } from "../utils/util";

export interface UserDataType {
userId: string;
permission?: IRole["permissions"]
role?: IRole
}
export interface IUserMessage<TParams = any, TQuery = any, TBody = any> extends Request<TParams, TQuery, TBody> {
userData: UserDataType;
}
type ExtendedUser = IUser & {
permission?: IRole
}
export const AuthJWT = (
req: IUserMessage,
res: Response,
next: NextFunction
) => {
try {
const jwtconfig = validateEnv()?.jwtconfig
const token = extractTokenfromHeader(req)
if (!token) throw new UnAuthenticatedError("Provide token", ErrorCode.TOKEN_NOT_FOUND);
jwt.verify(token, jwtconfig?.accessSecret, async (err, decoded) => {
if (err) return next(new ForbiddenError("Token expires", ErrorCode?.TOKEN_EXPIRE));
const decodeData = decoded as UserDataType;
const userWithPermission = await findExtendedUsers(decodeData?.userId)
if (!userWithPermission) throw new NotFoundError("User not found", ErrorCode.NOT_FOUND)
req.userData = {
userId: decodeData?.userId,
permission: userWithPermission?.role?.permissions
}
next();
});
} catch (err) {
throw new UnAuthenticatedError("Provide token", ErrorCode.TOKEN_NOT_FOUND);
}
};

okay lets add the enviroment variables to our .env, zod validation schema and env validation.

.env

JWT="YOUR_JWT_ACCESS_TOKEN"
JWT_REFRESH="YOUR_JWT_REFRESH_TOKEN"

src/config/env.config.ts

import dotenv from 'dotenv';
import { EnvConfig, envSchema } from '../validation/env.validation';
import { ZodError } from 'zod';
dotenv.config();

export const validateEnv = () => {
try {
const envVars: EnvConfig = envSchema.parse(process.env);
return {
port: +envVars.PORT,
env: envVars.NODE_ENV,
MONGO_DB_URI: envVars.MONGO_DB_URI,
jwtconfig: {
accessSecret: envVars.JWT
refreshaccessSecret: envVars.JWT_REFRESH,
}
};
} catch (error) {
let message = undefined;
if (error instanceof ZodError) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
message = error.errors;
console.error('Validation failed:', error.errors);
} else {
// message = error;
console.error('Error parsing environment variables:', error);
}
}
};

src/validation/env.validation.ts

import { z } from 'zod';

export const envSchema = z.object({
PORT: z.string({ required_error: "Port number is required" }),
NODE_ENV: z.enum(['development', "production", "test"]),
MONGO_DB_URI: z.string({ required_error: "Db url is required" }),
JWT: z.string({ required_error: "JWT is required" }),
JWT_REFREH:z.string({ required_error: "JWT Refresh is required" })
});
export type EnvConfig = z.infer<typeof envSchema>;

This middleware function is responsible for authenticating and authorizing users based on the JWT token provided in the request header. It verifies the token, retrieves user data, and attaches it to the request object for further processing in subsequent middleware functions.

Next lets install some necessary packages to handle authentication functions listed above:

npm i bcryptjs nodemailer ejs
  • bcryptjs is a JavaScript library for hashing passwords. It’s a pure JavaScript implementation of the bcrypt hashing algorithm, which is designed to be computationally expensive to crack, providing robust security for storing passwords.
  • nodemailer is a module for Node.js applications to easily send emails. It supports various email transport methods, including SMTP, and can be used for sending plain text or HTML emails.
  • EJS (Embedded JavaScript) is a simple templating language that lets you generate HTML markup with plain JavaScript. It is often used in Node.js applications to render HTML pages on the server side before sending them to the client. EJS allows you to embed JavaScript code within your HTML files to dynamically generate content based on your application’s data

As we all know email is one of the main ways of authentication since they are unique, can verify the user registering to out website, can be used as as a means of communication with the user, based on the project or website you have. so we will work on sending email using nodemailer and EJS template, since we are going to use this feature on the registration, emailVerification, password reseting and more.

lets create a file called “sendMail.ts” inside the utils folder. and add the code below.

src/utils/sendMail.ts

import nodemailer from "nodemailer";
import path from "path";
import ejs from "ejs";
interface MailOptions {
email: string;
subject: string;
template: string;
data: Record<string, any>;
}
  • Imports: The code imports necessary modules:
  • nodemailer: A module for sending emails.
  • path: A module for working with file paths.
  • ejs: A templating engine for generating HTML markup.
  • MailOptions Interface: An interface named MailOptions is defined, specifying the structure of the options object expected by the sendMail function. It includes properties such as email, subject, template, and data.
import nodemailer from "nodemailer";
import ejs from "ejs";
import { validateEnv } from "../config/env.config"
interface MailOptions {
email: string | string[];
subject: string;
template: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: Record<string, any>;
}

export const sendMail = async (options: MailOptions): Promise<void> => {
try {
const transporter = nodemailer.createTransport({
host: validateEnv().smtp.host,
port: validateEnv().smtp.port,
service: validateEnv().smtp.service,
secure: true,
auth: {
user: validateEnv().smtp.mail ,
pass: validateEnv().smtp.password,
},
});
// Verify the connection to the SMTP server
await transporter.verify();
const { email, subject, template, data } = options;
const paths=`./src/mails/${template}`
console.log({ paths });
// Render the EJS template
const html = await ejs.renderFile(paths, data);
const mailOption = {
from: validateEnv().smtp.mail,
to: email,
subject,
html,
};
// Send the email
const info = await transporter.sendMail(mailOption);
console.log("Email sent: " + info.response);
} catch (error) {
console.error("Error sending email:", error.message);
}
};

This code defines a function, `sendMail`, which sends an email using `nodemailer` for SMTP transport and `ejs` for rendering HTML templates. Here’s a summarized explanation:

Key Components

  1. Imports and Environment Validation:
  • `nodemailer`: For sending emails.
  • `ejs`: For rendering HTML email templates.
  • `validateEnv`: A function to validate and retrieve environment configuration settings.

2. MailOptions Interface: Defines the shape of the options object passed to the `sendMail` function:

  • `email`: The recipient(s) email address.
  • `subject`: The email subject.
  • `template`: The name of the EJS template to be used.
  • `data`: Data to be passed to the EJS template for rendering.

Function `sendMail`
1. SMTP Transport Configuration: Creates a transporter object using `nodemailer.createTransport` with SMTP configuration (host, port, service, secure, authentication) obtained from environment variables via `validateEnv`.

2. SMTP Connection Verification: Calls `transporter.verify()` to ensure the connection to the SMTP server is established.

3. Extract and Log Options:

  • Destructures `email`, `subject`, `template`, and `data` from the options object.
  • Constructs the path to the EJS template and logs it.

4. Render EJS Template:

  • Uses `ejs.renderFile` to render the EJS template with the provided data into HTML.

5. Mail Options Configuration:

  • Defines `mailOption` with the sender’s email (from environment variables), recipient email, subject, and the rendered HTML content.

6. Send Email:

  • Uses `transporter.sendMail` to send the email with the configured options.
  • Logs the response if the email is successfully sent.

7. Error Handling:

  • Catches and logs any errors that occur during the process.

okay lets put our enviroment variables on

.env

MONGO_DB_URI=
PORT=5000
NODE_ENV=development
JWT=""
JWT_REFRESH=""
SMTP_HOST=
SMTP_PORT=
SMTP_SERVICE=
SMTP_MAIL=
SMTP_PASSWORD=

src/validation/env.validation.ts

import { z } from 'zod';

export const envSchema = z.object({
PORT: z.string({ required_error: "Port number is required" }),
NODE_ENV: z.enum(['development', "production", "test"]),
MONGO_DB_URI: z.string({ required_error: "Db url is required" }),
JWT: z.string({ required_error: "JWT is required" }),
JWT_REFRESH:z.string({ required_error: "JWT Refres is required" }),
SMTP_HOST: z.string().min(1, { message: "SMTP_HOST is required" }),
SMTP_PORT: z.string().min(1, { message: "SMTP_PORT is required" }),
SMTP_SERVICE: z.string().min(1, { message: "SMTP_SERVICE is required" }),
SMTP_MAIL: z.string().min(1, { message: "SMTP_MAIL is required" }),
SMTP_PASSWORD: z.string().min(1, { message: "SMTP_PASSWORD is required" }),
});
export type EnvConfig = z.infer<typeof envSchema>;

src/config/env.config.ts

import dotenv from 'dotenv';
import { EnvConfig, envSchema } from '../validation/env.validation';
import { ZodError } from 'zod';
dotenv.config();

export const validateEnv = () => {
try {
const envVars: EnvConfig = envSchema.parse(process.env);
return {
port: +envVars.PORT,
env: envVars.NODE_ENV,
MONGO_DB_URI: envVars.MONGO_DB_URI,
jwtconfig: {
accessSecret: envVars.JWT,
refreshaccessSecret: envVars.JWT_REFRESH,
},
smtp: {
host: envVars.SMTP_HOST,
port: envVars.SMTP_PORT,
service: envVars.SMTP_SERVICE,
mail: envVars.SMTP_MAIL,
password: envVars.SMTP_PASSWORD,
},
};
} catch (error) {
let message = undefined;
if (error instanceof ZodError) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
message = error.errors;
console.error('Validation failed:', error.errors);
} else {
// message = error;
console.error('Error parsing environment variables:', error);
}
}
};

now that we have our sendMail function, lets create our first EJS template called “emailVerification.mails.ejs”, in a folder called mails inside the src “src” folder.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Verification Code</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.container {
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 600px;
}
.email-header {
background-color: #28a745;
color: #ffffff;
padding: 10px;
border-radius: 8px 8px 0 0;
text-align: center;
font-size: 24px;
font-weight: bold;
margin-bottom: 20px;
}
.footer {
background-color: #28a745;
color: #ffffff;
padding: 10px;
border-radius: 0 0 8px 8px;
text-align: center;
font-size: 14px;
margin-top: 20px;
}
h2 {
color: #333;
font-size: 20px;
margin: 0;
}
p {
color: #666;
text-align: left;
}
.verification-code {
font-size: 24px;
font-weight: bold;
color: #007bff;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="email-header">
Email Verification Code
</div>
<p>Dear <%= user %></p>
<p>Your email verification code is: <%= code %></p>
<p>Please use this code to complete the verification process. If you did not request this code, please ignore this email.</p>
<p>Thank you,</p>
<p>The SQUARE PLATFORM</p>
<div class="footer">
&copy; <%= new Date().getFullYear() %>. All rights reserved.
</div>
</div>
</body>
</html>

next we need a function to generate unique code for users, lets create a function “generateRandom6DigitString” in our “util.ts” file inside the util folder. add the code below.

import { Request } from "express";
export const extractTokenfromHeader = (req: Request) => {
const authHeader = req.headers.authorization || req.headers.Authorization as string;
if (!authHeader?.startsWith("Bearer ")) {
return false
}
return authHeader.split(" ")[1];
}
export function generateRandom6DigitString() {
const random6DigitNumber = Math.floor(100000 + Math.random() * 900000);
return String(random6DigitNumber);
}

Okay we have finished the setup to start working on, the authentication functionalities such as register, login, forgotPassword, resetPassword, ChangePassword, Email Verification, and logout. which is gone be covered on the next part of the series.

PART THREE(B): Role and Permission Based Authentication using JWT Access Token Here

Thank you

Ermias Asmare

--

--