An MVC Express Typescript App with CRUD operations, including File Upload and proper Authentication and Authorization using JWT
In this Article, we'll delve into the process of creating a RESTful API that implements CRUD operations using JWT for authorization
A basic CRUD api has options to CREATE / READ / UPDATE and DELETE items , along with Authentication and Authorisation in place , so as to ensure that the app transactions are secure
š NOTE : This APP has not been created using the best testing practices ( i.e TDD or BDD ) , but we'll mitigate this issue later by creating a similar app , where WE'LL BE FOLLOWING TDD / BDD practices , and buiding it using the principles of CEAN ARCHITECTURE
An Overview Of The Project :
- ā The project is an e-commerce app that lets the ADMIN add products to the backend (The product has some text details and a picture )
- ā Sign up: Users can register by creating a new account using an
email address
andpassword
.- ā Authentication: Registered users can
login
andlogout
.- ā Only
admins
are allowed to create products- ā Protected user profile: Only registered users can view individual user details after signing in.
18. ā We'll be using JWT to provide Authorization / Authentication in the app
The API we'll be creating :
Let's first talk about the USER MODEL
- A person who registers on the app can either have the role of a
user
or anadmin
. - An admin has all permissions but a user is limited to only
viewing products
and theirown information
import { Document, Schema, model } from 'mongoose';
// Define the interface for the User document
const enum Role {
ADMIN = 'admin',
USER = 'user',
}
export interface IUser extends Document {
username: string;
email: string;
password: string;
role: Role;
}
// Declare the Schema of the Mongo model
const userSchema = new Schema<IUser>(
{
username: {
type: String,
required: true,
index: true,
},
email: {
type: String,
required: true,
unique: true,
},
password: {
type: String,
required: true,
},
role: {
type: String,
enum: [Role.ADMIN, Role.USER],
default: Role.USER,
},
},
{
timestamps: true,
}
);
// Export the model
export default model<IUser>('User', userSchema);
So in the above
userSchema
, we define theusername
,password
for a user to register , along with arole
that's set touser
by defaultThe roles are set as
Enums
deliberately , so as to avoid illegal assignment .
const enum Role {
ADMIN = 'admin',
USER = 'user',
}
Now , let's look at the registerUser
controller
Steps taken to register a user :
Step 1 : [+] validate the request and extract info
Step 2 : [+] check if user is in database already
Step 3: [+] hash password to encrypt
Step 4: [+] create user object
Setp 5: [+] save user to database
Setp 6 : [+] create access_token ( JWT )
Step 7 : [+] create refresh_token ( JWT )
Step 8 : [+] save refresh_token to db
Step 9 : [+] send json response to frontend ( with the newly created refresh_token and the access_token )
Step 10 :[+] send async exception to
next(err)
//registerUser.ts
import { NextFunction, Request, Response } from 'express';
import { NextFunction, Request, Response } from 'express';
import { REFRESH_TOKEN_SECRET } from '../../config';
import { RefreshToken, User } from '../../models';
import { IUser } from '../../models/UserModel';
import CustomErrorHandler from '../../services/CustomErrorHandler';
import EncryptionService from '../../services/EncryptionService';
import JwtService from '../../services/JwtService';
import { RegisterUserRequest, RegisterUserResponse, Role } from '../../types';
import { registerSchema } from '../../validation';
type Tokens = {
access_token: string;
refresh_token: string;
};
const registerUser = async (
req: Request<RegisterUserRequest>,
res: Response<RegisterUserResponse, Tokens>,
next: NextFunction
): Promise<void> => {
try {
// [+] validate the request and extract info
const { username, email, password } = await registerSchema.parseAsync(
req.body
);
//[+] check if user is in database already
const userExists = await User.exists({ email });
if (userExists)
return next(
CustomErrorHandler.alreadyExists('This email is already taken')
);
//[+] hash password to encrypt
const hashedPassword = await EncryptionService.getHashedToken(password);
//[+] create user object
const userInfo = {
username,
email,
password: hashedPassword,
role: Role.ADMIN,
};
//[+] save user to database
const user: IUser = await User.create(userInfo);
//[+] create access_token
const access_token = await JwtService.sign({
_id: user._id as string,
role: user.role,
});
//[+] create refresh_token
const refresh_token = await JwtService.sign(
{
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
_id: user.id,
role: user.role,
},
'1y',
REFRESH_TOKEN_SECRET
);
//[+] save refresh_token to db
await RefreshToken.create({ token: refresh_token });
// [+] send response
const tokens: Tokens = { access_token, refresh_token };
res.json(tokens);
} catch (err) {
next(err);
}
};
export default {
registerUser,
};
Let's turn our attention to the multiple imports we have at the top of the resgisterUser
code ( p.s : this is a bad sign , as the number of imports shows how coupled our code is with other modules )
import { NextFunction, Request, Response } from 'express';
import { REFRESH_TOKEN_SECRET } from '../../config';
import { RefreshToken, User } from '../../models';
import { IUser } from '../../models/UserModel';
import CustomErrorHandler from '../../services/CustomErrorHandler';
import EncryptionService from '../../services/EncryptionService';
import JwtService from '../../services/JwtService';
import { RegisterUserRequest, RegisterUserResponse, Role } from '../../types';
import { registerSchema } from '../../validation';
Validation using Zod :
// validationSchemas.ts
import { z } from 'zod';
/* The `.refine()` method is used to add additional validation rules to the validation schema. In
this case, it is checking if the `password` field is equal to the `repeat_password` field. If
they are not equal, it will throw an error with the specified message ("Passwords don't match")
and the path of the error will be set to `['repeat_password']`. This allows for more specific
error messages and error paths when validating the data. */
export const registerSchema = z
.object({
username: z.string().min(3).max(20).trim(),
email: z.string().email().trim(),
password: z.string().min(6),
repeat_password: z.string().min(6),
})
.refine((data) => data.password === data.repeat_password, {
message: " : Passwords don't match",
path: ['repeat_password'], // path of error
});
Custom Error Handler :
*A Convenient and Elegant way to handle errors is to create a custom error handler and define all errors over there
*We will throw our custom errors if and when the need arises
*By following this pattern, we are in complete control of the declariation and summoning of Errors from a single source of truth
class CustomErrorHandler extends Error {
constructor(public status: number, public message: string) {
super();
this.status = status;
this.message = message;
}
/**
* The function returns a new instance of a custom error handler with a status code of 409 and a
* given error message.
* @param {string} message - The `message` parameter is a string that represents the error message to
* be displayed.
* @returns A new instance of the CustomErrorHandler class with a status code of 409 and the provided
* message.
*/
static alreadyExists(message: string) {
return new CustomErrorHandler(409, message);
}
/**
* The function returns a custom error handler with a 401 status code and a message indicating that
* the user authentication failed due to an invalid password or email.
* @param [message=password or email invalid] - The message parameter is a string that represents the
* error message to be displayed when the user authentication fails. By default, the message is set
* to 'password or email invalid'.
* @returns A new instance of the CustomErrorHandler class with a status code of 401 and a message of
* "password or email invalid".
*/
static userAuthFailed(message = 'missing or invalid jwt') {
/* The line `return new CustomErrorHandler(401, message);` is creating a new instance of the
`CustomErrorHandler` class with a status code of 401 and the provided error message. This instance
is then returned by the `wrongUserCredentials` static method. */
return new CustomErrorHandler(401, message);
}
/**
* The function returns a custom error handler with a 401 status code and a message indicating
* invalid password or email.
* @param [message=password or email invalid] - The message parameter is a string that represents the
* error message to be displayed when the user credentials are invalid. The default value is
* 'password or email invalid'.
* @returns a new instance of the CustomErrorHandler class with a status code of 401 and a message of
* "password or email invalid".
*/
static wrongCredentials(message = 'password or email invalid') {
return new CustomErrorHandler(401, message);
}
static notFound(message = 'Not Found') {
return new CustomErrorHandler(404, message);
}
static unAuthorized(message = 'auth failed') {
return new CustomErrorHandler(401, message);
}
static multerError(message = 'Multer File Upload Error') {
return new CustomErrorHandler(401, message);
}
}
export default CustomErrorHandler;
Top comments (0)