DEV Community

Cover image for Building two-factor authentication with NestJS and Postgres
Arctype Team for Arctype

Posted on

Building two-factor authentication with NestJS and Postgres

Introduction

Cybercrime and hostile operations against public and private entities have become more prevalent in recent years. This rise in risk explains why many software companies are adding an extra layer of security to their customers' accounts.

2FA is an extra layer of security that confirms that the person seeking to get into an online account is who they say they are. A user's username and password must be entered first. They will then be asked to provide additional details before being granted access. This approach will protect a compromised account from fraudulent activities. Even if a hacker discovers the user's password, they will not be able to login into the account because they lack the second-factor authentication (2FA) code.

This tutorial will teach you how to implement 2FA authentication in a NestJS application. Grab the code from Github at any time. Let’s get started!

Prerequisites

This tutorial is a hands-on demonstration. To follow along, ensure you have the following installed:

  • Nodejs - Nodejs is the runtime environment for our application.
  • Postgres database - We’ll save the user’s records in a Postgres database.
  • Arctype - We’ll use a Postgres GUI to help with user authentication.

Create a Nest application

Let’s start by creating a NestJS application for our project. Before we do that, we’ll install the Nest CLI with the command below:

npm i -g @nestjs/cli
Enter fullscreen mode Exit fullscreen mode

Then, create a Nest application with the command below.

nest new authentication
Enter fullscreen mode Exit fullscreen mode

Wait for some time for the installation to complete before proceeding to the next step.

Installing dependencies

Now, let’s install the dependencies for this project. We’ll start with the dev dependencies using the command below.

npm i -D @types/bcrypt @types/nodemailer
Enter fullscreen mode Exit fullscreen mode

Then we’ll add our other dependencies.

npm i bcrypt  @nestjs/jwt @nestjs-modules/mailer nodemailer hbs @nestjs/typeorm typeorm pg
Enter fullscreen mode Exit fullscreen mode

This will take a little bit of time to install, so wait for it to finish. When it’s done, it’s time to set up a database for our application.

Setup Postgres database

At this point, we have installed all the dependencies we need for this project. Now let’s go ahead and set up our Postgres database. We’ll use the TypeORM Postgres Object Relational Mapper to connect our application to the Postgres database. Run the commands below to set up a Postgres database.

sudo su - postgres
psql
create database  authentication
Enter fullscreen mode Exit fullscreen mode


create user authentication with encrypted password authentication
grant all privileges on database authentication to authentication
Enter fullscreen mode Exit fullscreen mode

Next, open the /src/app.module.ts file import the TypeOrmModule, and connect to the database using the forRoot method with the code snippet below.

…
import { TypeOrmModule } from '@nestjs/typeorm';
imports: [
   TypeOrmModule.forRoot({
     type: 'postgres',
     host: 'localhost',
     username: 'postgres',
     password: 'authentication',
     database: 'authentication',
     entities: [User],
     synchronize: true,
   }),
TypeOrmModule.forFeature([User]),
],
…
Enter fullscreen mode Exit fullscreen mode

In the code snippet above, notice that we passed in the User entity, but have yet to create it. Don’t worry - we’ll create this entity in a subsequent section. Also, notice that we used the forFeature() method to define which repository is registered in the current scope, which lets TypeORM know about the User entity.

Now, let’s create the User entity to define our models in the database.

Create the User entity

At this point, our application is connected to the Postgres database. Now we’ll create a User entity to represent the user data we’ll store in the database. First, create an app.entity.ts file in the src folder and add the code snippet below.

import { Entity, Column, PrimaryGeneratedColumn, PrimaryColumn, CreateDateColumn } from 'typeorm';

@Entity()
export class User {
   @PrimaryGeneratedColumn("uuid")
   id: number;

   @Column()
   fullname: string;

   @Column({unique:true})
   email: string;

   @Column()
   password: string

   @Column({ select: false, nullable: true })
   authConfirmToken: String

   @Column({ default: false, nullable: true })
   isVerified: Boolean;

   @CreateDateColumn()
   createdAt: Date;   
}
Enter fullscreen mode Exit fullscreen mode

In the above code snippet, we created an entity by defining a User class. We did this by defining the properties of the User entity using the Column, PrimaryGeneratedColumn, and DateCreatedColumn decorators. The PrimaryGeneratedColumn decorator will generate random ids for the users using UUID module. We added the unique property to our email Column to ensure no user registered with the same email twice. Lastly, the DateCreatedColumn decorator will add a date by default when a record is created.

Next, open the app.module.ts file and import the User entity. This import resolves the error showing on the app.module.ts file.

import { User } from './app.entity'
Enter fullscreen mode Exit fullscreen mode

Our User entity is set. Now, let's create the controllers to handle the user's requests.

Create the app service

At this point, our User entity is set. Now let’s set up our app service by setting our route handler functions. Open the app.service.ts file and the required modules.

import { Injectable, HttpException, HttpStatus } from "@nestjs/common";
import { User } from "./app.entity"
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'
import * as bcrypt from 'bcrypt';
import { JwtService } from '@nestjs/jwt';
import { MailerService } from '@nestjs-modules/mailer';

…
Enter fullscreen mode Exit fullscreen mode

In the above code snippet, we import the several vital elements:

  • The @Injectable decorator, which makes our appService class available to managed by the Nest IoC container
  • HttpException, which lets us create custom errors
  • HttpStatus, which sends custom status codes
  • The User entity (described above)
  • And InjectRepository, which injects our User entity to the appService class.

Also, we import bcrypt, JwtService, and MailerService, which we’ll configure in our app module later in this section.

In addition, we create a global code variable to store the random verification code that will be sent to the users after registration. We generate a random code and assign it to the code variable, and then create the sendConfirmationEmail and sendConfirmedEmail methods to send confirmation and verification emails to registered users.

@Injectable()
export class AppService {

 private code;

 constructor(@InjectRepository(User) private userRepository: Repository<User>, private mailerService: MailerService) {
   this.code = Math.floor(10000 + Math.random() * 90000);
 }

 async sendConfirmedEmail(user: User) {
   const { email, fullname } = user
   await this.mailerService.sendMail({
     to: email,
     subject: 'Welcome to Nice App! Email Confirmed',
     template: 'confirmed',
     context: {
       fullname,
       email
     },
   });
 }

 async sendConfirmationEmail(user: any) {
   const { email, fullname } = await user
   await this.mailerService.sendMail({
     to: email,
     subject: 'Welcome to Nice App! Confirm Email',
     template: 'confirm',
     context: {
       fullname,
       code: this.code
     },
   });
 }
…
Enter fullscreen mode Exit fullscreen mode

Next, we create the signup method to handle the user’s registration. We do this with the code snippet below.

async signup(user: User): Promise<any> {
try{
   const salt = await bcrypt.genSalt();
   const hash = await bcrypt.hash(user.password, salt);
   const reqBody = {
     fullname: user.fullname,
     email: user.email,
     password: hash,
     authConfirmToken: this.code,
   }
   const newUser = this.userRepository.insert(reqBody);
   await this.sendConfirmationEmail(reqBody);
   return true
}catch(e){
    return new HttpException(e, HttpStatus.INTERNAL_SERVER_ERROR);
}
 }
…
Enter fullscreen mode Exit fullscreen mode

Our signup method is an asynchronous function that returns true when an account is created. We generate a salt value using the bcrypt genSalt () method and hash the user's password using the hash method. Then we store the hashed version of the user's password and create a new object using the userRepository insert method. Next, we call the signin method, which is an asynchronous function that returns the JWT token or an HTTP exception via the code snippet below:

async signin(user: User, jwt: JwtService): Promise<any> {
try{
   const foundUser = await this.userRepository.findOne({ email: user.email });
   if (foundUser) {
     if (foundUser.isVerified) {
       if (bcrypt.compare(user.password, foundUser.password)) {
         const payload = { email: user.email };
         return {
           token: jwt.sign(payload),
         };
       }
     } else {
       return new HttpException('Please verify your account', HttpStatus.UNAUTHORIZED)
     }
     return new HttpException('Incorrect username or password', HttpStatus.UNAUTHORIZED)
   }
   return new HttpException('Incorrect username or password', HttpStatus.UNAUTHORIZED)
}catch(e){
return new HttpException(e, HttpStatus.INTERNAL_SERVER_ERROR);
}
 }
…
Enter fullscreen mode Exit fullscreen mode

Our signin method uses the user’s email address to check if their record exists in our database. If the user is found, we use the bcrypt compare method to check if the user's password matches the hashed password stored in the database. Then generate and send a JWT token to the user. If no record matches the query, we’ll return a corresponding error message.

Next, we’ll create a verify method, which is an asynchronous function that returns true or an error when a user is verified.

async verifyAccount(code: String): Promise<any> {
try{
   const user = await this.userRepository.findOne({
     authConfirmToken: code
   });
   if (!user) {
     return new HttpException('Verification code has expired or not found', HttpStatus.UNAUTHORIZED)
   }
   await this.userRepository.update({ authConfirmToken: user.authConfirmToken }, { isVerified: true, authConfirmToken: undefined })
   await this.sendConfirmedEmail(user)
   return true
}catch(e){
   return new HttpException(e, HttpStatus.INTERNAL_SERVER_ERROR)
}
}
Enter fullscreen mode Exit fullscreen mode

In our verify method, we query the database for a user with the code in the request body. If no user matches the search, we return an HTTP exception. Otherwise, we update the user's isVerified property to true and reset the authConfirmToken to undefined to make it empty.

Let's open the app.module.ts file and configure the JwtService and MailerService. First, import the JwtModule, MailerModule, ConfigModule, and HandlebarsAdapter, which we’ll use to configure our email templates. The ConfigModule will enable us to load our environment variables like the JWT secret that will be created later in this section.

import { ConfigModule} from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { MailerModule } from '@nestjs-modules/mailer';
import { join } from 'path';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
…
Enter fullscreen mode Exit fullscreen mode

Create a .env file in the project root directory to store your JWT secret. You can generate one using the built-in crypto module.

require('crypto').randomBytes(64).toString('hex')
Enter fullscreen mode Exit fullscreen mode

And store the generated secret in the .env file you created.

JWT_SECRET = [your secret goes here]
Enter fullscreen mode Exit fullscreen mode

Setup the mailer module

Now append the code snippets below in the app module imports array to load the environment variables. We’ll also configure the JwtModule, MailerModule, and HandlebarsAdapter.

imports: [
   …

   ConfigModule.forRoot(),
   JwtModule.register({
     secret: process.env.JWT_SECRET,
     signOptions: { expiresIn: '60s' },
   }),
   MailerModule.forRoot({
       transport: {
       service: "gmail",
       secure: false,
       auth: {
         user: 'your email address',
         pass: 'your email password',
       },
     },
     defaults: {
       from: '"No Reply" <youremail@gmail.com>',
     },
     template: {
       dir: join(__dirname, "../views/email-templates"),
       adapter: new HandlebarsAdapter(), 
       options: {
         strict: true,
       },
     },
   }),
…
Enter fullscreen mode Exit fullscreen mode

Create the app controllers

At this point, our app service is set. Now let’s set up our app controllers to handle incoming requests. Open the app.controller.ts file and import the required modules.

import { Controller, Get, Post, Render, Res, Body, HttpStatus, Req, Param } from '@nestjs/common';
import { AppService } from './app.service';
import { User } from './app.entity';
import { JwtService } from '@nestjs/jwt
Enter fullscreen mode Exit fullscreen mode

Then we’ll use the @Controller method to define our app controllers. First we’ll create an AppController class with a constructor method. We create two private parameters for our appService class and the JwtService.

Then we create our Root and VerifyEmail routes, which will listen to a Get request using the @Get decorator, and render the index and the verify templates, which will be set up later in this section using the @Render decorator.

@Get()
 @Render('index')
 root() { }

 @Get('/verify')
 @Render('verify')
 VerifyEmail() { }
Enter fullscreen mode Exit fullscreen mode

Next, we create the Signup route which will listen to Post requests coming to /signup endpoint. The Signup controller gets the input from the user's form and matches it with the user entity we created. Then it awaits the result of the appService signup method, which takes the user object as a parameter.

@Post('/signup')
 async Signup(@Body() user: User) {
   return  await this.appService.signup(user);
 }
Enter fullscreen mode Exit fullscreen mode

Next, we create the Signin route which will listen to Post requests coming to /signin endpoint . The Signin controller gets the input from the user's form and matches it with the user entity we created. Then await the result of the appService signin method, which also takes the user object form object as a parameter.

 @Post('/signin')
 async Signup(@Body() user: User) {
  return await this.appService.signin(user);
 }
…
Enter fullscreen mode Exit fullscreen mode

Then we create a Verify route and await the result from the appService verifyAccount method, which takes the user confirmation code as a parameter.

 async Verify(@Body() body) {
   return await this.appService.verifyAccount(body.code)
 }
…
Enter fullscreen mode Exit fullscreen mode

Lastly, open the main.ts file, delete the boilerplate code and add the following code snippets below to set up our template engine and static files director to enable server-side rendering in our application.

import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';

async function bootstrap() {
 const app = await NestFactory.create<NestExpressApplication>(
   AppModule,
 );

 app.useStaticAssets(join(__dirname, '..', 'public'));
 app.setBaseViewsDir(join(__dirname, '..', 'views'));
 app.setViewEngine('hbs');

 await app.listen(3001);
}
bootstrap()
Enter fullscreen mode Exit fullscreen mode

Create the email templates

With our view engine and static files configured, let’s go create our templates. First, create a views folder in the project root directory, and in the views folder create an email-templates folder. Create an index.hbs and a verify.hbs files in the views folder. Then create a confirm.hbs and confirmed.hbs files in the email-templates folder. Open the view/index.hbs file and add the code snippet below.

<!DOCTYPE html>
<html lang="en">
<head>
   <!-- Required meta tags-->
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
   <meta name="description" content="Colorlib Templates">
   <meta name="author" content="Colorlib">
   <meta name="keywords" content="Colorlib Templates">
   <!-- Title Page-->
   <title>Signup to meet new people</title>
   <!-- Font special for pages-->
   <link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,600,600i,700,700i,800,800i" rel="stylesheet">
   <!-- Main CSS-->
   <link href="/css/main.css" rel="stylesheet" media="all">
</head>
<body>
   <div class="page-wrapper bg-dark p-t-100 p-b-50">
       <div class="wrapper wrapper--w900">
           <div class="card card-6">
               <div class="card-heading">
                   <h2 class="title">Signup</h2>
               </div>
               <div class="card-body">
                   <form method="POST" id="form">
                       <div class="form-row">
                           <div class="name">Full name</div>
                           <div class="value">
                               <input class="input--style-6" type="text" name="full_name" id="fullname">
                           </div>
                       </div>
                       <div class="form-row">
                           <div class="name">Email address</div>
                           <div class="value">
                               <div class="input-group">
                                   <input class="input--style-6" type="email" id="email" placeholder="">
                               </div>
                           </div>
                       </div>
                       <div class="form-row">
                           <div class="name">Password</div>
                           <div class="value">
                               <div class="input-group">
                                   <input class="input--style-6" type="password" id="password" placeholder="">
                               </div>
                           </div>
                       </div>
                   </form>
               </div>
               <div class="card-footer">
                   <button class="btn btn--radius-2 btn--blue-2" type="submit" onclick="signForm()">Signup</button>
               </div>
           </div>
       </div>
   </div>
   <!-- Jquery JS-->
   <script src="/vendor/jquery/jquery.min.js"></script>
   <script src="/js/global.js"></script>
   <script src="/js/index.js" async></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Open the verify.hbs file and add the code snippet below:

<!DOCTYPE html>
<html lang="en">

<head>
   <!-- Required meta tags-->
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
   <meta name="description" content="Colorlib Templates">
   <meta name="author" content="Colorlib">
   <meta name="keywords" content="Colorlib Templates">

   <!-- Title Page-->
   <title>Apply for job by Colorlib</title>

   <!-- Font special for pages-->
   <link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,600,600i,700,700i,800,800i" rel="stylesheet">
   <!-- Main CSS-->
   <link href="/css/main.css" rel="stylesheet" media="all">
</head>
<body>
   <div class="page-wrapper bg-dark p-t-100 p-b-50">
       <div class="wrapper wrapper--w900">
           <div class="card card-6">
               <div class="card-heading">
                   <h2 class="title">Confirm Email</h2>
               </div>
               <div class="card-body">
                   <form method="POST" id="form">
                       <div class="form-row">
                           <div class="name">Confirmation Code</div>
                           <div class="value">
                               <input class="input--style-6" type="text" id="code">
                           </div>
                       </div>
                   </form>
               </div>
               <div class="card-footer">
                   <button class="btn btn--radius-2 btn--blue-2" type="submit" onclick="codeForm()">Confirm</button>
               </div>
           </div>
       </div>
   </div>

   <!-- Jquery JS-->
   <script src="/vendor/jquery/jquery.min.js"></script>
   <!-- Main JS-->
   <script src="/js/global.js"></script>
    <script src="/js/index.js"></script>
</body>
</html>
<!-- end document-->
Enter fullscreen mode Exit fullscreen mode

Add the code snippet code below to the email-templates/confirm.hbs file.

<p>Hey {{ fullname }},</p>
<p>Verify your email with code below</p>
<p>Your verification code is: {{code}}</p>

<p>If you did not request this email you can safely ignore it.</p>
Enter fullscreen mode Exit fullscreen mode

And the code snippet below to the email-templates/confirmed.hbs file.

<p>Hey {{ fullname }},</p>
<p>Your account for {{email}} has been confirmed!</p>

<p>If you did not request this email you can safely ignore it.</p>
Enter fullscreen mode Exit fullscreen mode

Next, create a public folder in the project root directory for our static files, then create a js folder inside it. Inside that, create an index.js file with code snippet below:

function signForm() {
   const form = document.getElementById('form');
   const email = document.getElementById('email').value;
   const fullname = document.getElementById('fullname').value;
   const password = document.getElementById('password').value;

   fetch('http://localhost:3001/signup', {
       method: "POST",
       body: JSON.stringify({
           fullname,
           email,
           password
       }),
       headers: {
           "Content-type": "application/json"
       }
   }).then(data => data.json())
       .then(res => {
           if (res.status === 500) {
                alert("An error occurred, try again")
           } else {
               alert("Your account has been created, We sent a verification code to Email");
               form.reset();
               window.location.href = "/verify"
             }
       })

}

function codeForm() {
   const form = document.getElementById('form');
   const code = document.getElementById('code').value;

   fetch('http://localhost:3001/verify', {
       method: "POST",
       body: JSON.stringify({
           code
       }),
       headers: {
           "Content-type": "application/json"
       }
   }).then(data => data.json())
       .then(res => {
           if (res.status === 400) {
                 alert(res.response)
            } else {
                alert("Your account has been verified, proceed to the signin page");
                form.reset();
           }
       })
}
Enter fullscreen mode Exit fullscreen mode

The above code snippets make a post request to our /signup and /verify endpoint to register and to confirm a user's email.

Lastly, get the other static files from the Github repository for this project, and add them also to the public folder.

Enable Google LSAA

With our email templates setup, we should be able to send emails to our users. We're going to be using Gmail to send the emails in this tutorial. So, we need to configure our Gmail account to allow email from Less secure app access. Follow the steps below to enable LSAA on your Gmail account.

  1. Open Chrome. Click on the profile icon on the top right-hand side of your browser.

Screenshot of Chrome.

  1. Click on Manage your Google Account.
  2. Type less on the search box, and click on less secure app access.

Screenshot of Google account window.

  1. Toggle the Allow less secure apps: ON input box to enable it.

Screenshot of Google accounts setting.

Now let’s run the application and get it tested.

Test the Application

At this point, our application is ready. Let’s test it out. In your terminal, change the directory so that you're in the authentication folder and run the server with the command below.

#Change directory
cd authentication 

#Start the server
npm run start:dev
Enter fullscreen mode Exit fullscreen mode

Then go to http://localhost:3001/ to view the index page. You should see the result shown below.

The signup form (screenshot)

Fill in the fields and sign up. You’ll be asked to verify your account. Check your email for the confirmation code. Verify the code on the verify page.

Screenshot of confirmation code.

Success! We have a working 2FA application, as desired.

Got stuck? Have any issues? The code for this tutorial is fully available on Github if needed.

Conclusion

By building a demo project, we’ve learned how to implement 2FA authentication in a NestJS application. We started with the introduction of 2FA authentication concepts and learned how to create a NestJS application that puts them into practice. Now that you’ve gotten the knowledge you seek, how would you increase the security of your next NestJS project? Perhaps, you can learn more about Nest from the official website and take things even further.

Top comments (1)

Collapse
 
leamsigc profile image
Ismael Garcia

Really nice walk through, tutorial.

It is me or nest js is really close to spring boot like right?