Create Authentication system with NodeJS, ExpressJS, TypeScript and Jest E2E testing -- PART 3.

Mehdi JaiMehdi Jai
17 min read

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


0
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.