An Account Recovery Process Tutorial Using NodeJS With Knex and Express

Written by antonkalik | Published 2024/03/28
Tech Story Tags: knex | nodemailer | expressjs | handlebars | nodejs-tutorial | account-recovery-guide | forgot-password-flow | hackernoon-top-story | hackernoon-es | hackernoon-hi | hackernoon-zh | hackernoon-fr | hackernoon-bn | hackernoon-ru | hackernoon-vi | hackernoon-pt | hackernoon-ja | hackernoon-de | hackernoon-ko | hackernoon-tr

TLDRThis guide explores resetting user passwords using Nodemailer, Knex, and Express. It covers sending emails for password recovery and validating message delivery.via the TL;DR App

This is a detailed analysis of how to make a reset for a user when he has forgotten his password and how to send emails from Node JS and validate sending messages.

Most of us have experienced the account recovery process at least once — when we forget a password, there’s a need for procedures to create a new one and regain access to the system. This article focuses on implementing such a process using Node.js, Knex, and some undisclosed tools, alongside Express to handle routes and perform the necessary operations.

We’ll cover the router implementation, handling URL parameters, determining what to send to the user when only an email or phone number is available as proof, managing email submissions, and addressing security concerns.

Forgot Password Flow

Before diving into coding, I’d like to ensure that we’re working with the same codebase, which you can access from my public repository on GitHub. We will upgrade step by step to implement the forgot password flow. For email transport, we will utilize Google’s email service.

Now, take a look at the schema of the forgot password flow.

The server will be responsible for sending emails to the user mailbox containing a valid link for password resetting, and will also validate the token and user existence.

Packages and Migration

To begin utilizing the email service and sending emails with Node.js, we need to install the following packages in addition to our existing dependencies:

npm i --save nodemailer handlebars

Nodemailer: Powerful module that allows to send emails easily using SMTP or other transport mechanisms.

Handlebars: Handlebars is a popular templating engine for JavaScript. It will allow us to define templates with placeholders that can be filled with data when rendering.

Now, we need to create the migration, so in my case, I have to add a new column forgot_password_token to users table:

knex migrate:make add_field_forgot_password_token -x ts

and in the generated file, I set the code:

import type { Knex } from 'knex';

export async function up(knex: Knex): Promise<void> {
  return knex.schema.alterTable('users', table => {
    table.string('forgot_password_token').unique();
  });
}

export async function down(knex: Knex): Promise<void> {
  return knex.schema.alterTable('users', table => {
    table.dropColumn('forgot_password_token');
  });
}

Migration for Forgot Password Token in Users table

and then migrate the latest file:

knex migrate:knex

So now we can set to the users table our forgot_password_token

Routers

To manage controllers responsible for handling the logic of password forgetfulness and resetting, we must establish two routes. The first route initiates the forgot password process, while the second handles the reset process, expecting a token parameter in the URL for verification. To implement this, create a file named forgotPasswordRouter.ts within the src/routes/ directory, and insert the following code:

import { Router } from 'express';
import { forgotPasswordController } from 'src/controllers/forgotPasswordController';
import { resetPasswordController } from 'src/controllers/resetPasswordController';

export const forgotPasswordRouter = Router();

forgotPasswordRouter.post('/', forgotPasswordController);
forgotPasswordRouter.post('/reset/:token', resetPasswordController);

Forgot Password Router

Two controllers will manage the logic for sending emails and resetting the password.

Forgot Password Controller

When the client forgets his password he has no session, which means we can’t get user data except email or any other security identifiers. In our case, we are sending an email to handle a password reset. That logic we’re going to set into the controller.

forgotPasswordRouter.post('/', forgotPasswordController);

Remember the ‘forgot password?’ link below the login form usually in the UI of any clients in the login form? Clicking on it directs us to a view where we can request a password reset. We simply input our email, and the controller handles all the necessary procedures. Let’s examine the following code:

import { Request, Response } from 'express';
import { UserModel } from 'src/models/UserModel';
import type { User } from 'src/@types';
import { TokenService } from 'src/services/TokenService';
import { EmailService } from 'src/services/EmailService';

export const forgotPasswordController = async (req: Request, res: Response) => {
  try {
    const {
      email,
    }: {
      email: string;
    } = req.body;

    const user = await UserModel.findByEmail(email);

    if (user) {
      const token = await TokenService.sign(
        {
          id: user.id,
        },
        {
          expiresIn: '1 day',
        }
      );
      await user.context.update({ forgot_password_token: token });
      await EmailService.sendPasswordResetEmail(email, token);
    }

    return res.sendStatus(200);
  } catch (error) {
    return res.sendStatus(500);
  }
};

Forgot Password Controller

From the body, we going to get an email, and then we will find the user using UserModel.findByEmail. If the user exists, we create a JWT token using TokenService.sign and save the token to the user forgot_password_token with an expiration of 1 day. Then we will send the message to the email with a proper link together with a token where the user will be able to change his password.

Google Setup

To be able to send the email, we have to create our new email address which will be a sender.

Let’s go to Google, to create a new email account, and then, when the account is created, proceed to the Manage your Google Account link. You can find it on the top right by clicking on avatar. Then, on the left menu, click on the Security item, and then press 2-Step Verification. Below you will find the App passwords section, click on the arrow:

Input the name that needs to be used. In my case, I set Nodemailer and press Create.

Copy the generated password, and set it to your .env file. We need to set to file two variables:

MAIL_USER="[email protected]"
MAIL_PASSWORD="vyew hzek avty iwst"

Of course, to have a proper email like info@company_name.com, you have to set up Google Workspace or AWS Amazon WorkMail together with AWS SES, or any other services. But in our case, we are using a simple Gmail account for free.

Email Service

With the .env file prepared, we are ready to set up our service for sending emails. The controller will use the service with the generated token and the recipient email address for our message.

await EmailService.sendPasswordResetEmail(email, token);

Let’s create src/services/EmailService.ts and define the class for the service:

export class EmailService {}

And now as initial data, I have to get the environment to use with nodemailer:

import process from 'process';
import * as nodemailer from 'nodemailer';
import * as dotenv from 'dotenv';

dotenv.config();

export class EmailService {
  private static transporter: nodemailer.Transporter;
  private static env = {
    USER: process.env.MAIL_USER,
    PASS: process.env.MAIL_PASSWORD,
  };
}

Email Service

We have to take care of service initialization. I wrote about it before in my previous article. Here is an example:

import { TokenService } from 'src/services/TokenService';
import { RedisService } from 'src/services/RedisService';
import { EmailService } from 'src/services/EmailService';

export const initialize = async () => {
  await RedisService.initialize();
  TokenService.initialize();
  EmailService.initialize();
};

Initializing Services

Now, let’s proceed with creating the initialization within our EmailService class:

import process from 'process';
import * as nodemailer from 'nodemailer';
import * as dotenv from 'dotenv';

dotenv.config();

export class EmailService {
  private static transporter: nodemailer.Transporter;
  private static env = {
    USER: process.env.MAIL_USER,
    PASS: process.env.MAIL_PASSWORD,
  };

  public static initialize() {
    try {
      EmailService.transporter = nodemailer.createTransport({
        service: 'gmail',
        auth: {
          user: this.env.USER,
          pass: this.env.PASS,
        },
      });
    } catch (error) {
      console.error('Error initializing email service');
      throw error;
    }
  }
}

Email Service Initialization

There is initialization nodemailer.createTransport(), a method provided by the nodemailer library. It creates a transporter object that will be used to send our emails. The method accepts an options object as an argument where you specify the configuration details for the transporter.

We are using Google: service: 'gmail' specifies the email service provider. Nodemailer provides built-in support for various email service providers, and gmail indicates that the transporter will be configured to work with Gmail's SMTP server.

For authentication auth, it’s necessary to set the credentials that are required to access the email service provider's SMTP server.

For user should be set to the email address from which we are going to send emails, and that password has been generated in the Google account from App Passwords.

Now, let’s set the last part of our service:

import process from 'process';
import * as nodemailer from 'nodemailer';
import * as dotenv from 'dotenv';
import { generateAttachments } from 'src/helpers/generateAttachments';
import { generateTemplate } from 'src/helpers/generateTemplate';
import { getHost } from 'src/helpers/getHost';

dotenv.config();

export class EmailService {
  // ...rest code

  public static async sendPasswordResetEmail(email: string, token: string) {
    try {
      const host = getHost();
      const template = generateTemplate<{
        token: string;
        host: string;
      }>('passwordResetTemplate', { token, host });
      const attachments = generateAttachments([{ name: 'email_logo' }]);
      const info = await EmailService.transporter.sendMail({
        from: this.env.USER,
        to: email,
        subject: 'Password Reset',
        html: template,
        attachments,
      });
      console.log('Message sent: %s', info.messageId);
    } catch (error) {
      console.error('Error sending email: ', error);
    }
  }
}

Send Password Reset Email

Before proceeding, it’s crucial to determine the appropriate host for when the client receives an email. Establishing a link with a token in the email body is essential.

import * as dotenv from 'dotenv';
import process from 'process';

dotenv.config();

export const getHost = (): string => {
  const isProduction = process.env.NODE_ENV === 'production';
  const protocol = isProduction ? 'https' : 'http';
  const port = isProduction ? '' : `:${process.env.CLIENT_PORT}`;
  return `${protocol}://${process.env.WEB_HOST}${port}`;
};

Get host

For templates, I am using handlebarsand for that, we need to create in src/temlates/passwordResetTemplate.hbs our first HTML template:

<!-- passwordResetTemplate.hbs -->
<html lang='en'>
  <head>
    <style>
      a { color: #372aff; }  .token { font-weight: bold; }
    </style>
    <title>Forgot Password</title>
  </head>
  <body>
    <p>You requested a password reset. Please use the following link to reset your password:</p>
    <a class='token' href="{{ host }}/reset-password/{{ token }}">Reset Password</a>
    <p>If you did not request a password reset, please ignore this email.</p>
    <img src="cid:email_logo" alt="Email Logo"/>
  </body>
</html>

Password Reset Template

and now we can reuse this template with the helper:

import path from 'path';
import fs from 'fs';
import handlebars from 'handlebars';

export const generateTemplate = <T>(name: string, props: T): string => {
  const templatePath = path.join(__dirname, '..', 'src/templates', `${name}.hbs`);
  const templateSource = fs.readFileSync(templatePath, 'utf8');
  const template = handlebars.compile(templateSource);
  return template(props);
};

Generate Template Helper

To enhance our email, we can even include attachments. To do so, add the email_logo.png file to the src/assets folder. We can then render this image within the email using the following helper function:

import path from 'path';
import { Extension } from 'src/@types/enums';

type AttachmentFile = {
  name: string;
  ext?: Extension;
  cid?: string;
};

export const generateAttachments = (files: AttachmentFile[] = []) =>
  files.map(file => {
    const ext = file.ext || Extension.png;
    const filename = `${file.name}.${ext}`;
    const imagePath = path.join(__dirname, '..', 'src/assets', filename);
    return {
      filename,
      path: imagePath,
      cid: file.cid || file.name,
    };
  });

Generate Attachments Helper

After collecting all of those helpers, we have to be able to send email using:

const info = await EmailService.transporter.sendMail({
  from: this.env.USER,
  to: email,
  subject: 'Password Reset',
  html: template,
  attachments,
});

This approach offers decent scalability, enabling the service to employ various methods for sending emails with diverse content.

Now, let’s try to trigger the controller with our router and send the email. For that, I am using Postman:

The console will tell you that the message has been sent:

Message sent: <1k96ah55-c09t-p9k2–[email protected]>

Check for new messages in the inbox:

The link to Reset Password has to contain the token and host:

http://localhost:3000/reset-password/<token>

The port 3000 is specified here because this message pertains to the development process. This indicates that the client responsible for handling forms for password reset will also be operating within the development environment.

Reset Password

The token has to be validated on the controller side with TokenService from where we can get the user who sent that email. Let’s recover the router which uses the token:

forgotPasswordRouter.post('/reset/:token', resetPasswordController);

The controller will only update the password if the token is valid and not expired, as per the expiration time set to one hour. To implement this functionality, navigate to the src/controllers/ folder and create a file named resetPasswordController.ts containing the following code:

import bcrypt from 'bcrypt';
import { Request, Response } from 'express';
import { TokenService } from 'src/services/TokenService';
import { UserModel } from 'src/models/UserModel';
import type { User } from 'src/@types';

export const resetPasswordController = async (req: Request, res: Response) => {
  try {
    const token = req.params.token;

    if (!token) {
      return res.sendStatus(400);
    }

    const userData = await TokenService.verify<{ id: number }>(token);
    const user = await UserModel.findOneById<User>(userData.id);

    if (!user) {
      return res.sendStatus(400);
    }

    const newPassword = req.body.password;

    if (!newPassword) {
      return res.sendStatus(400);
    }

    const hashedPassword = await bcrypt.hash(newPassword, 10);
    await UserModel.updateById(user.id, { password: hashedPassword, passwordResetToken: null });

    return res.sendStatus(200);
  } catch (error) {
    const errors = ['jwt malformed', 'TokenExpiredError', 'invalid token'];
    if (errors.includes(error.message)) {
      return res.sendStatus(400);
    }

    return res.sendStatus(500);
  }
};

Reset Password Controller

This controller will receive the token, verify it, extract the user ID from the decrypted data, retrieve the corresponding user, acquire the new password sent by the client in the request body, and proceed to update the password in the database. Ultimately, this enables the client to log in using the new password.

Conclusion

The scalability of the email service is demonstrated through various approaches, such as sending confirmations or success messages, like those indicating a password update and enabling subsequent login. However, managing passwords is a great challenge, particularly when enhancing application security is imperative.

There are numerous options available to bolster security, including additional checks before permitting password changes, such as token comparison, email, and password validation.

Another option is to implement a PIN code system, where a code is sent to the user’s email for validation on the server side. Each of these measures necessitates the utilization of email-sending capabilities.

All implemented code you can find in the GitHub repository here.

Please feel free to conduct any experiments with this build, and share your feedback on what aspects you appreciate about this topic. Thank you so much.

References

Here, you can find several references that I utilized in this article:


Also published here


Written by antonkalik | Senior Software Engineer @ Amenitiz / Node JS / React
Published by HackerNoon on 2024/03/28