Create Authentication system with NodeJS, ExpressJS, TypeScript and Jest E2E testing -- PART 3.
Table of contents
Auth Repository
Finally, we arrived to the core logic of our project. Create now src/repositories/auth.repo.ts
. In this file we gonna create a static methods class for our repo.
Before that, Let's add some other functions and services we gonna need here. We will need:
- JWT Handlers
- Mail Service
- Function helpers
JWT Handlers
Create this file src/utils/jwtHandler.ts
import appConfig from '@/config/app.config';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
export const generateAccessToken = (userId: string) => {
return jwt.sign( // This payload is the object returned in JWT middleware (check the next code snippet)
{
userId,
timestamp: Date.now(),
},
appConfig.jwt.secret,
{ expiresIn: appConfig.jwt.expiresIn } // set an expiration period in the appConfig
);
};
// This function generated a UUID for the refresh token sent at login
export const generateRefreshToken = () => {
return uuidv4();
};
// jsonwbtoken verification methods. Check their docs for further information
export const verifyAccessToken = (token: string) => {
return jwt.verify(token, appConfig.jwt.secret);
};
export const verifyRefreshToken = (token: string) => {
return jwt.verify(token, appConfig.jwt.refreshSecretKey);
};
In the JWT Middleware, the user object in the call back is the payload we created. So, in our config, the user will return the user id and a timestamp.
// src/middlewares/jwt.middleware.ts
jwt.verify(token, appConfig.jwt.secret, (err, **user**) => {
// ...
});
Mail Service
This services calls nodemailer
to send the email. Create src/services/mail.service.ts
install first nodemailer
npm i nodemailer
npm i -D @types/nodemailer
import mailConfig from '@/config/mail.config';
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: mailConfig.mailHost,
port: mailConfig.mailPort,
auth: {
user: mailConfig.mailUser,
pass: mailConfig.mailPass,
},
});
export async function sendEmail(payload: { receivers: string[]; subject: string; html: string }) {
const info = await transporter.sendMail({
from: `"${mailConfig.mailFromName}" <${mailConfig.mailFromEmail}>`,
to: payload.receivers.join(', '),
subject: payload.subject,
html: payload.html,
});
return info;
}
As you can see, we have another config file for emails. Create src/config/mail.config.ts
import { config } from 'dotenv';
config();
const mailConfig = {
mailFromEmail: process.env.MAIL_FROM_EMAIL!,
mailFromName: process.env.MAIL_FROM_NAME!,
mailHost: process.env.MAIL_HOST!,
mailPort: Number(process.env.MAIL_PORT!),
mailUser: process.env.MAIL_USER!,
mailPass: process.env.MAIL_PASS!,
};
export default mailConfig;
Note: We already added the .env
variables for the mailer.
Helper functions
A helper functions we can use in our app. Create src/utils/helpers.ts
// We will use it in testing
export default async function wait(time: number) {
await new Promise((r) => setTimeout(r, time));
}
export function addTime(value: number, unit: 'ms' | 's' | 'm' | 'h' | 'd', start?: Date) {
let addedValue = value;
switch (unit) {
case 's':
addedValue *= 1000;
break;
case 'm':
addedValue *= 60 * 1000;
break;
case 'h':
addedValue *= 60 * 60 * 1000;
break;
case 'd':
addedValue *= 24 * 60 * 60 * 1000;
break;
}
const initValue = start ? start.getTime() : Date.now();
return new Date(initValue + addedValue);
}
The Auth Repo
First, we will go with each function alone, then have the summary file.
Login
static async loginUser(payload: TAuthSchema): Promise<ApiResponseBody<IAuthResponse>> {
const resBody = new ApiResponseBody<IAuthResponse>();
try {
// Validating the user email
const user = await prisma.user.findUnique({
where: {
email: payload.email,
},
});
if (!user) {
const resBody = ResponseHandler.Unauthorized('Credentials Error');
return resBody;
}
// Validating the password
const isValidPassword = await bcrypt.compare(payload.password, user.password);
if (isValidPassword) {
const token = generateAccessToken(user.id);
const refreshToken = generateRefreshToken();
// Creating the refresh token and storing it
await prisma.refreshToken.create({
data: {
token: refreshToken,
userId: user.id,
expiresAt: addTime(30, 'd'),
},
});
const accessToken = {
token: token,
refreshToken: refreshToken,
};
// The response body
const responseData = {
accessToken: accessToken,
user: {
id: user.id,
email: user.email,
phone: user.phone,
name: user.name,
verifiedEmail: user.verifiedEmail,
userType: user.userType,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
},
};
resBody.data = responseData;
return resBody;
} else {
// In case if password doesn't match
const resBody = ResponseHandler.Unauthorized('Password not match');
return resBody;
}
} catch (err) {
logger.error(err);
resBody.error = {
code: HttpStatusCode.INTERNAL_SERVER_ERROR,
message: String(err),
};
}
return resBody;
}
You can throw "Credentials Error" for password not match error as well if you want.
Refresh token
When the access token has expired, the client side calls to refresh that access token. And we use this "Refresh token" as validation process, also to know which user is it. Note that refresh token has an expiration date too (double of JWT token).
static async refreshToken({
refreshToken,
}: TRefreshTokenSchema): Promise<ApiResponseBody<IRefreshTokenResponse>> {
const resBody = new ApiResponseBody<IRefreshTokenResponse>();
try {
const storedToken = await prisma.refreshToken.findUnique({
where: { token: refreshToken },
});
if (!storedToken || new Date() > storedToken.expiresAt) {
const resBody = ResponseHandler.Unauthorized('Invalid or expired refresh token');
return resBody;
}
const newAccessToken = generateAccessToken(storedToken.userId);
const newRefreshToken = generateRefreshToken();
// Updating the refresh token and the expiration date
await prisma.refreshToken.update({
where: { token: refreshToken },
data: {
token: newRefreshToken,
expiresAt: addTime(30, 'd'),
},
});
// Return the new refresh token and access token
resBody.data = { accessToken: newAccessToken, refreshToken: newRefreshToken };
} catch (err) {
logger.error(err);
resBody.error = {
code: HttpStatusCode.INTERNAL_SERVER_ERROR,
message: String(err),
};
}
return resBody;
}
Register / Create User
static async createUser(payload: TRegisterSchema): Promise<ApiResponseBody<IUser>> {
const resBody = new ApiResponseBody<IUser>();
try {
// Create the user in DB. In case of already existing email, Prisma will throw an error
const user = await prisma.user.create({
data: {
email: payload.email,
phone: payload.phone,
name: payload.name,
password: bcrypt.hashSync(payload.password, 10), // Hash the password
userType: payload.type,
},
});
resBody.data = {
id: user.id,
email: user.email,
phone: user.phone,
name: user.name,
verifiedEmail: user.verifiedEmail,
userType: user.userType,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
// In case we require user email verification, we send the email.
if (appConfig.requireVerifyEmail) {
await this.sendEmailVerification(user);
}
} catch (err) {
logger.error(err);
// We check if there is an email unique constraint error thrown by Prisma.
if (err instanceof PrismaClientKnownRequestError) {
if (
err.code === 'P2002' &&
err.meta?.target &&
Array.isArray(err.meta.target) &&
err.meta.target.includes('email')
) {
resBody.error = {
code: HttpStatusCode.CONFLICT,
message: 'Email already exists',
};
}
} else {
resBody.error = {
code: HttpStatusCode.INTERNAL_SERVER_ERROR,
message: String(err),
};
}
}
return resBody;
}
Send Email Verification
This is a private method, called by CreateUser
method.
private static async sendEmailVerification(user: User) {
try {
const token = uuidv4();
await prisma.verifyEmailToken.create({
data: {
token,
userId: user.id,
expiresAt: addTime(1, 'h'), // Token expired in 1 hour
},
});
// We can use handlebars to create a beautiful HTML UI
const bodyHTML = `<h1>Verify Your Email</h1>
<p>Verify your email. The link expires after <strong>1 hour</strong>.</p>
<a id="token-link" href="${process.env.VERIFY_EMAIL_UI_URL}/${token}">Confirm Email</a><br>
or copy this link: <br>
<span>${process.env.VERIFY_EMAIL_UI_URL}/${token}</span>`;
sendEmail({
receivers: [user.email],
subject: 'Verify Email',
html: bodyHTML,
});
} catch (err) {
logger.error({ message: 'Send Email Verification Error:', error: err });
}
}
Notice that the a
tag have an ID of token-link
. We will use that later for testing, it's important for email testing.
Confirm email
The token passed to this method is received from the sent email
static async verifyUser(payload: TValidateUserSchema): Promise<ApiResponseBody<IStatusResponse>> {
const resBody = new ApiResponseBody<IStatusResponse>();
try {
// Find the token and get only the non-expired one.
const token = await prisma.verifyEmailToken.findUnique({
where: {
token: payload.token,
expiresAt: {
gte: new Date(),
},
},
include: {
user: true,
},
});
if (!token) {
resBody.error = {
code: HttpStatusCode.NOT_FOUND,
message: 'Invalid or expired token',
};
return resBody;
}
// Set the user to verified
await prisma.user.update({
where: {
id: token.userId,
},
data: {
verifiedEmail: true,
},
});
// Delete the token from DB after use
await prisma.verifyEmailToken.delete({
where: {
token: payload.token,
},
});
resBody.data = {
status: true,
};
} catch (err) {
logger.error(err);
resBody.error = {
code: HttpStatusCode.INTERNAL_SERVER_ERROR,
message: String(err),
};
}
return resBody;
}
Forget Password
static async forgotPassword(
payload: TForgetPasswordSchema
): Promise<ApiResponseBody<IStatusResponse>> {
const resBody = new ApiResponseBody<IStatusResponse>();
try {
// Check the user
const user = await prisma.user.findUnique({
where: {
email: payload.email,
userType: payload.type,
},
});
if (!user) {
resBody.error = {
code: HttpStatusCode.NOT_FOUND,
message: 'User not found',
};
return resBody;
}
// Generate a token
const token = uuidv4();
await prisma.resetPasswordToken.create({
data: {
token,
userId: user.id,
expiresAt: addTime(30, 'm'), // Valid for 30 minutes
},
});
const bodyHTML = `<h1>Reset Password</h1>
<p>Click here to reset your password:</p>
<a id="token-link" href="${process.env.RESET_PASSWORD_UI_URL}/${token}">Reset Password</a><br>
or copy this link: <br>
<span>${process.env.RESET_PASSWORD_UI_URL}/${token}</span>`;
if (user) {
sendEmail({
receivers: [user.email],
subject: 'Reset Password',
html: bodyHTML,
});
}
resBody.data = {
status: true,
};
} catch (err) {
logger.error(err);
resBody.error = {
code: HttpStatusCode.INTERNAL_SERVER_ERROR,
message: String(err),
};
}
return resBody;
}
Reset password
static async resetPassword(
payload: TResetPasswordSchema
): Promise<ApiResponseBody<IStatusResponse>> {
const resBody = new ApiResponseBody<IStatusResponse>();
try {
// Look for the unexpired token
const token = await prisma.resetPasswordToken.findUnique({
where: {
token: payload.token,
expiresAt: {
gte: new Date(),
},
},
include: {
user: true,
},
});
if (!token) {
resBody.error = {
code: HttpStatusCode.FORBIDDEN,
message: 'Invalid or expired token',
};
return resBody;
}
// Hash the new password
const hashedPassword = await bcrypt.hash(payload.newPassword, 10);
// Update the password
await prisma.user.update({
where: {
id: token.userId,
},
data: {
password: hashedPassword,
},
});
// Delete the used token
await prisma.resetPasswordToken.delete({
where: {
token: payload.token,
},
});
resBody.data = {
status: true,
};
} catch (err) {
logger.error(err);
resBody.error = {
code: HttpStatusCode.INTERNAL_SERVER_ERROR,
message: String(err),
};
}
return resBody;
}
Update password
static async updatePassword(
payload: TUpdatePasswordSchema,
userId: string
): Promise<ApiResponseBody<IStatusResponse>> {
const resBody = new ApiResponseBody<IStatusResponse>();
try {
// Getting the user.
// The userId is fetched from the JWT Authentication middleware.
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
resBody.error = {
code: HttpStatusCode.NOT_FOUND,
message: 'User not found',
};
return resBody;
}
// Validate the old password
const isValidPassword = await bcrypt.compare(payload.oldPassword, user.password);
if (!isValidPassword) {
resBody.error = {
code: HttpStatusCode.UNAUTHORIZED,
message: 'Invalid old password',
};
return resBody;
}
if (appConfig.updatePasswordRequireVerification) {
// In case the confirmation is required, we send the email and wait for the user to confirm it
await this.sendConfirmPasswordUpdate(user, payload.newPassword);
} else {
const hashedPassword = await bcrypt.hash(payload.newPassword, 10);
await prisma.user.update({
where: {
id: userId,
},
data: {
password: hashedPassword,
},
});
}
resBody.data = {
status: true,
};
} catch (err) {
logger.error(err);
resBody.error = {
code: HttpStatusCode.INTERNAL_SERVER_ERROR,
message: String(err),
};
}
return resBody;
}
Send update password email
private static async sendConfirmPasswordUpdate(user: User, newPassword: string) {
try {
const token = uuidv4();
const hashedPassword = await bcrypt.hash(newPassword, 10);
await prisma.updatePasswordToken.create({
data: {
token,
newPassword: hashedPassword, // We store the hashed password
userId: user.id,
expiresAt: addTime(1, 'h'),
},
});
const bodyHTML = `<h1>Confirm password update</h1>
<p>Confirm updating password. The link expires after <strong>1 hour</strong>.</p>
<a id="token-link" href="${process.env.CONFIRM_UPDATE_PASSWORD_EMAIL_UI_URL}/${token}">Confirm password</a><br>
or copy this link: <br>
<span>${process.env.CONFIRM_UPDATE_PASSWORD_EMAIL_UI_URL}/${token}</span>`;
sendEmail({
receivers: [user.email],
subject: 'Update Password',
html: bodyHTML,
});
} catch (err) {
logger.error({ message: 'Send password update Email Error:', error: err });
}
}
Confirm update password
After the user receives the email and is redirected to the client side. The latter sends us the token. The process is the almost the same as the previous token validation processes.
static async confirmUpdatePassword(
payload: TValidateUserSchema
): Promise<ApiResponseBody<IStatusResponse>> {
const resBody = new ApiResponseBody<IStatusResponse>();
try {
const token = await prisma.updatePasswordToken.findUnique({
where: {
token: payload.token,
},
});
if (!token) {
resBody.error = {
code: HttpStatusCode.FORBIDDEN,
message: 'Invalid or expired token',
};
return resBody;
}
// We update the user password from the stored password
await prisma.user.update({
where: {
id: token.userId,
},
data: {
password: token.newPassword,
},
});
// And finally delete the token record
await prisma.updatePasswordToken.delete({
where: {
token: payload.token,
},
});
resBody.data = {
status: true,
};
} catch (err) {
logger.error(err);
resBody.error = {
code: HttpStatusCode.INTERNAL_SERVER_ERROR,
message: String(err),
};
}
return resBody;
}
Summary
We walked through all the methods. Here is the whole file
Subscribe to my newsletter
Read articles from Mehdi Jai directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Mehdi Jai
Mehdi Jai
Result-oriented Lead Full-Stack Web Developer & UI/UX designer with 5+ years of experience in designing, developing and deploying web applications.