DEV Community

Cover image for Implementing SMS-enabled Two-Factor Authentication using NestJS, Twilio and Prisma
Chiamaka Ojiyi
Chiamaka Ojiyi

Posted on

Implementing SMS-enabled Two-Factor Authentication using NestJS, Twilio and Prisma

Introduction

When building robust and secure applications, implementing an additional layer of security is strongly recommended. The username and password are a basic first step in the security architecture of digital applications. This first layer of security is prone to attacks and can lead to account breaches when it is the only barrier sitting in front of restricted resources.

Two-factor authentication acts as a second barrier to accessing protected resources. It requires that a user provides additional information after the username-password identity declaration step has been fulfilled. Two-factor authentication can be achieved by requiring a user to enter a one-time passcode or fingerprint.

In this article, we will be going over how to implement an SMS-based two-factor authentication in a NestJS backend project. We will be using Twilio as the SMS provider for sending out a time-based one-time passcode to the user's phone number. If you are coming from the Express framework but want to get started with NestJS, this is a good guide to get a feel of how things are done in NestJS.

Prerequisites

To follow along with this guide, you should have Node.js and Postgres installed on your computer. The project source code is written in Typescript. However, a knowledge of Javascript will suffice to understand what's going on.

Project architecture

We will be building a couple of REST endpoints in this project. Below is a high-level overview of the project structure.

The user signs up on the application and proceeds to log in. A JSON web token is used to authenticate the user upon successful login.

The user can enable two-factor authentication on their account. To enable 2FA, they need to first verify their phone number since they will be receiving their one-time password on that number.

Once 2FA is enabled on their account, the user will be required to enter a time-based OTP once they have successfully fulfilled the username-password component of the login process.

Setting up the Nest project

Compared to Express, NestJS shines when it comes to project organization and setup, thanks to its robust CLI. In your terminal, run the command below to install the NestJS CLI globally.

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

Once installed, we can use the CLI to generate a new nest project.

nest new project-name
Enter fullscreen mode Exit fullscreen mode

Our newly scaffolded project will have a structure like this:

project-name
|-- node_modules
|-- src
|   |-- app.controller.spec.ts
|   |-- app.controller.ts
|   |-- app.module.ts
|   |-- app.service.ts
|   |-- main.ts
|-- test
|-- .eslintrc.js
|-- .gitignore
|-- .prettierrc
|-- nest-cli.json
|-- package-lock.json
|-- package.json
|-- README.md
|-- tsconfig.build.json
|-- tsconfig.json
Enter fullscreen mode Exit fullscreen mode

NestJS has a module-oriented approach to project development. This means that each feature is developed as a module that comprises a controller and a service. This approach makes projects clean, organized, and easy to maintain.

Let's start our project on a clean slate by deleting files we won't be needing. Please delete the files: app.controller.spec.ts, app.controller.ts, and app.service.ts and remove their references in app.module.ts.

Setting up utilities

In this section, we will set up some utilities such as the project base URL, logging middleware, and custom exception filter which will be used throughout the application.

Setting global prefix in NestJS

In the src/main.ts, we can set a global prefix for all our endpoints by adding our preferred prefix inside the application bootstrap function.

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    app.setGlobalPrefix('api/v1');
    await app.listen(3000);
}

bootstrap();
Enter fullscreen mode Exit fullscreen mode

Logging middleware

NestJs has an inbuilt logging package that we can customize to log requests to our endpoints. We will create a global middleware that will log incoming requests, request methods, status codes, request content length, and user agent.

In your src folder, create the logger middleware in this path: src/common/utils/logger.ts.

import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()

export class LoggerMiddleware implements NestMiddleware {
    private logger = new Logger('HTTP');

    use(request: Request, response: Response, next: NextFunction): void {
        const { ip, method, originalUrl } = request;
        const userAgent = request.get('user-agent') || '';

        response.on('finish', () => {
        const { statusCode } = response;
        const contentLength = response.get('content-length');
        this.logger.log(`${method} ${originalUrl} ${statusCode} ${contentLength} - ${userAgent} ${ip}`);
        });

        next();

    }

}
Enter fullscreen mode Exit fullscreen mode

We will then register our logger middleware in our app module. In src/app.module.ts, import the logger middleware like so:

import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/utils/logger';

@Module({
    imports: [],
    controllers: [],
    providers: [],
})

export class AppModule implements NestModule {
    configure(consumer: MiddlewareConsumer) {
    consumer.apply(LoggerMiddleware).forRoutes('*');

    }
}
Enter fullscreen mode Exit fullscreen mode

Custom exception filter

NestJs has a built-in exception filter that automatically sends a response to the client for all unhandled errors. This global filter sends a generic response in this format:

{
  "statusCode": 500,
  "message": "Internal server error"
}
Enter fullscreen mode Exit fullscreen mode

This response doesn't let the client know what went wrong and that is why you should create a custom filter that propagates the actual error to the client.

First, we'll create an interface for our custom error filter in this path: src/common/interfaces/error.interface.ts. I don't like slapping a 500 status code if I can help it, so this interface will extend the Error object and we can give an appropriate status code for errors.

export interface ResponseError extends Error {
    statusCode?: number;
}
Enter fullscreen mode Exit fullscreen mode

Create the custom filter in this path: src/common/filters/custom-exception.filter.ts.

import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { ResponseError } from '../interfaces/error.interface';

@Catch()
export class CustomExceptionFilter implements ExceptionFilter {
  catch(error: ResponseError, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse < Response > ();
    const request = ctx.getRequest < Request > ();

    if (error instanceof HttpException) {
      response.status(error.getStatus()).json({
        statusCode: error.getStatus(),
        message: error.message,
        error: error.name,
      });
    } else {
      const status = error?.statusCode || 500;
      response.status(status).json({
        statusCode: status,
        message: error.message,
        error: 'Internal Server Error',
      });
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Setting up Postgres database

Now we'll create our database using psql, a command line utility for interacting with Postgres. Start up psql in your terminal by running the command:

psql -U postgres
Enter fullscreen mode Exit fullscreen mode

The command we ran connects us to Postgres with the default user. We can then create a database by running the following command:

CREATE DATABASE two-fa-demo;
Enter fullscreen mode Exit fullscreen mode

Setting up Prisma

In this section, we will be connecting to our Postgres database using the Prisma ORM. Prisma simplifies our interaction with the database and generates migrations for every model update we make.

🔥 Tip: If you're using Visual Studio Code, I recommend installing the Prisma extension to enable syntax highlighting and linting for .prisma files.

Install the Prisma CLI as a development dependency.

npm install -D prisma
Enter fullscreen mode Exit fullscreen mode

Initialize Prisma in your project by running the command below in your terminal.

npx prisma init
Enter fullscreen mode Exit fullscreen mode

The command above creates a Prisma folder in our project's root directory. The Prisma folder contains the schema.prisma file which we'll use to define the models for our database tables. When we migrate our model to our database, Prisma will generate a migration file that will map our model to our database.

You should also notice that Prisma created a .env file in our project root directory. Update the database URL with our Postgres database connection string.

DATABASE_URL="postgres://postgres@localhost:5432/two-fa-demo"
Enter fullscreen mode Exit fullscreen mode

In the prisma/schema.prisma file, make sure that the data source provider is set to postgresql.

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
Enter fullscreen mode Exit fullscreen mode

Defining models

We will be creating 2 tables in this project: A User table and an Otp table. Update the prisma/schema.prisma file with the models:

model User {
  id        String   @id @default(uuid())
  email     String   @unique
  phone     String   @unique
  password  String
  firstName String
  lastName  String
  twoFA     Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  Otp       Otp[]
  isPhoneVerified Boolean @default(false)
}

model Otp {
  id        String   @id @default(uuid())
  owner     User     @relation(fields: [userId], references: [id])
  userId    String
  code      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  expiresAt DateTime @db.Timestamp(5)
  useCase   UseCase
}

enum UseCase {
  LOGIN
  D2FA
  PHV
}
Enter fullscreen mode Exit fullscreen mode

We declare a one-to-many relationship between the User table and the Otp table. A user can have many OTPs. When a user is created in our application, 2FA is disabled by default and their phone number is set as unverified.

The OTPs generated in our application will be valid for 5 minutes and will have 3 use cases:

  • LOGIN: This is for OTPs that are sent to users who have 2FA-enabled accounts
  • D2FA: When a user decides to disable two-factor authentication on their account, we will send an OTP to their phone number. If they verify this, we will disable 2FA on their account. This use case addresses that.
  • PHV: This is for OTPs that are sent to the user's phone number when they want to verify their phone number on the application.

Now that we have defined our models, we can run the following command to generate and run a migration against our database.

npx prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

With the above command, Prisma does the following:

  • Generates an SQL migration file in the prisma/migrations/<migration-folder>.
  • Runs the migration against the database to create the tables according to the defined models.
  • Generates the Prisma client, a type-safe query builder adapted to our models. You'll notice that our package.json has been updated with a new dependency: @prisma/client.

Whenever you update the prisma/schema.prisma file, it is required that you regenerate the Prisma client with the command *npx prisma generate*. This way the client is adapted to the new changes in your schema file.

Creating Prisma service

We will now encapsulate the Prisma client in its service. The idea of creating a service for the Prisma client is to isolate it from the rest of our application code, making our code more organized and maintainable. The Prisma service will be used to create an instance of the Prisma client and query our database.

In your terminal, run the following command:

nest generate module prisma
nest generate service prisma
Enter fullscreen mode Exit fullscreen mode

When the above command is run, the Nest CLI does the following:

  • Creates the files src/prisma/prisma.module.ts and src/prisma/prisma.service.ts.
  • Updates the app.module.ts file by importing the Prisma module and adding it to the imports array.

In the Prisma service file, we'll create a PrismaService class that extends the Prisma Client. Update the Prisma service file with the code:

import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }

  async enableShutdownHooks(app: INestApplication) {
    this.$on('beforeExit', async () => {
      await app.close();
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

The onModuleInit method ensures that a connection to the database is established when the Prisma module is initialized. The enableShutdownHooks method ensures that our application gracefully shutdowns whenever the beforeExit event is triggered.

Next, import the PrismaService in the Prisma Module file and add the PrismaService to the providers and exports array. The Prisma module file should look like this:

import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}
Enter fullscreen mode Exit fullscreen mode

Implementing sign up

In this section, we'll be creating everything regarding signing up to the application. In your terminal run these commands to create a module, controller, and service for account creation on the application:

nest generate module account
nest generate controller account
nest generate service account
Enter fullscreen mode Exit fullscreen mode

Notice that Nest CLI has:

  • Created the files src/account/account.controller.ts, src/account/account.service.ts, src/account/account.module.ts.
  • Imported the account controller and service into the account module and updated the account module controller and provider arrays.

Create validation pipe

Next, we'll create a pipe for validating incoming requests. We'll be using Joi for request body validation. Install the joi npm package like so:

npm i joi
Enter fullscreen mode Exit fullscreen mode

Pipes are classes that sit in front of a controller route handler. NestJS uses pipes to validate or transform the data on incoming requests. We'll create a custom pipe that will use Joi to validate incoming requests.

Create the custom pipe in this path: src/common/pipes/joi.ts.

import {
  PipeTransform,
  Injectable,
  ArgumentMetadata,
  BadRequestException,
} from '@nestjs/common';
import { ObjectSchema } from 'joi';

@Injectable()
export class JoiValidationPipe implements PipeTransform {
  constructor(private schema: ObjectSchema) {}

  transform(value: any, metadata: ArgumentMetadata) {
    const { error } = this.schema.validate(value);
    if (error) {
      const errorMessage = error.details[0].message.replace(/"/g, '');
      throw new BadRequestException(errorMessage);
    }
    return value;
  }
}

Enter fullscreen mode Exit fullscreen mode

Our custom pipe takes a joi schema as an argument. The joi schema is a representation of data that is acceptable for a route. Whenever an incoming request fails the joi validation, we throw a 400 error and propagate the error message to the client.

Create joi sign-up schema

In the path src/common/joi-schema/signup.ts, create the schema for sign-up.

import * as Joi from 'joi';

export const signupSchema = Joi.object({
  email: Joi.string().email().required(),
  phone: Joi.string().required(),
  password: Joi.string().required(),
  firstName: Joi.string().required(),
  lastName: Joi.string().required(),
});

Enter fullscreen mode Exit fullscreen mode

Our sign-up route will be expecting a post request with a body that meets the rules outlined in the joi schema.

Also, we'll create a dto class to represent the expected data for our sign-up route. This dto class will be used to infer the type of the incoming request's body. In src/account/dto/create-user.dto.ts add this code:

export class CreateUserDto {
  email: string;
  phone: string;
  password: string;
  firstName: string;
  lastName: string;
}
Enter fullscreen mode Exit fullscreen mode

Create sign-up service

In src/account/account.service.ts, we'll create the service method for signing up.

import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { hashPassword } from '../common/utils/passwordHasher';
import _ from 'underscore';

@Injectable()
export class AccountService {
  constructor(private prisma: PrismaService) {}

  async signUp(createUserDto: CreateUserDto) {
    createUserDto.password = await hashPassword(createUserDto.password);
    const user = await this.prisma.user.create({ data: createUserDto });
    return _.omit(user, 'password');
  }
}
Enter fullscreen mode Exit fullscreen mode

We import the Prisma service, inject it into the account service class and use it to create a new user record in the database. I created a utility to hash the user's password before the user account is created. Once the user account is created, I use the underscore package to omit the password from the user record before sending it back to our route controller.

Install some dependencies we'll be needing.

npm i bcrypt
npm i underscore
Enter fullscreen mode Exit fullscreen mode

Create the password-hasher utility in src/common/utils/passwordHasher.ts.

import * as bcrypt from 'bcrypt';

export const hashPassword = async (password: string): Promise<string> => {
  const hash = await bcrypt.hash(password, 10);
  return hash;
};
Enter fullscreen mode Exit fullscreen mode

Create sign-up route handler

Update the src/account/account.controller.ts file to look like this:

 import {
  Controller,
  Post,
  Body,
  UsePipes,
} from '@nestjs/common';
import { AccountService } from './account.service';
import { CreateUserDto } from './dto/create-user.dto';
import { signupSchema } from '../common/joi-schema/signup';
import { JoiValidationPipe } from '../common/pipes/joi';

@Controller('account')
export class AccountController {
  constructor(private readonly accountService: AccountService) {}

  @Post('signup')
  @UsePipes(new JoiValidationPipe(signupSchema))
  create(@Body() createUserDto: CreateUserDto) {
    return this.accountService.signUp(createUserDto);
  }
}

Enter fullscreen mode Exit fullscreen mode

The create method in the account controller accepts a post request whose body will be validated by our custom pipe. Notice how we pass the signup schema to the pipe using the @UsePipes decorator. We extract the body from the request using the @Body decorator and finally pass it on to the service method. NestJS will automatically send a 201 status code when the request resolves successfully.

Our sign-up route will be accessed at the path api/v1/account/signup. Notice that the @Controller decorator is passed a parameter: account. Now every route created in this controller class will be prefixed with /account.

Implementing login

Our login will take this flow:

  • User will enter email and password
  • User details will be validated
  • If correct, we generate a JSON web token based on the user credentials and return the token to the client.

First, we'll create an auth module to handle login and authorization-related functionality. Run the below commands in your terminal to create the auth module, controller, and service files.

nest generate module auth
nest generate controller auth
nest generate service auth
Enter fullscreen mode Exit fullscreen mode

Register the JWT module

In your .env, add a secret for your JSON web token. This will be used in creating and validating our JWTs.

JWT_SECRET=twofademo
Enter fullscreen mode Exit fullscreen mode

Create a constants file to easily access the JWT secret. In src/common/utils/constants.ts, add this code:

export const constants = {
  jwtSecret: process.env.JWT_SECRET
};
Enter fullscreen mode Exit fullscreen mode

NestJS has a built-in module that makes it easy to handle creating JWTs. Register the NestJS JWT module in the auth module:

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { PrismaModule } from '../prisma/prisma.module';
import { constants } from '../common/utils/constants';

@Module({
  controllers: [AuthController],
  providers: [AuthService],
  imports: [
    PrismaModule,
    JwtModule.register({
      global: true,
      secret: constants.jwtSecret,
      signOptions: { expiresIn: '2 days' },
    }),
  ],
})
export class AuthModule {}
Enter fullscreen mode Exit fullscreen mode

Validation for login route

Create the login dto in this path: src/auth/dto/login.dto.ts and add this code:

export class LoginDto {
  email: string;
  password: string;
}
Enter fullscreen mode Exit fullscreen mode

Create the login joi schema in this path: src/common/joi-schema/login.ts :

import * as Joi from 'joi';

export const loginSchema = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().required(),
});
Enter fullscreen mode Exit fullscreen mode

Set up login service handler

In src/auth/auth.service.ts, add the code:

import { HttpStatus, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { HttpException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Request } from 'express';
import { PrismaService } from '../prisma/prisma.service';
import { LoginDto } from './dto/login.dto';
import { verifyPassword } from '../common/utils/passwordHasher';

@Injectable()
export class AuthService {
  constructor(private prisma: PrismaService, private jwtService: JwtService) {}

  async login(loginDto: LoginDto) {
    const user = await this.prisma.user.findUnique({
      where: { email: loginDto.email },
    });
    if (!user) {
      throw new HttpException(
        'Invalid email or password',
        HttpStatus.BAD_REQUEST,
      );
    }
    const validPassword = await verifyPassword(
      loginDto.password,
      user.password,
    );

    if (!validPassword) {
      throw new HttpException(
        'Invalid email or password',
        HttpStatus.BAD_REQUEST,
      );
    }

    if (!user.twoFA) {
      const payload = {
        email: user.email,
        first_name: user.firstName,
        last_name: user.lastName,
        sub: user.id,
      };
      return {
        success: true,
        access_token: await this.jwtService.signAsync(payload),
      };
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

In the login method of the AuthService class, we check if the user exists in our database. If true, we verify their password. If they do not have 2FA enabled on their account, we immediately generate a JWT using their credentials. If the user doesn't exist in the database, we throw an error message with a 400 status code.

Create verifyPassword function in the passwordHasher utility file we previously created.

export const verifyPassword = async (password, hash): Promise<boolean> => {
  const isMatch = await bcrypt.compare(password, hash);
  return isMatch;
};
Enter fullscreen mode Exit fullscreen mode

Set up login route handler

Set up the route handler for login by adding the code below to the AuthController file.

import {
  Body,
  Controller,
  HttpCode,
  Post,
  UsePipes,
} from '@nestjs/common';
import { Request } from 'express';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { loginSchema } from '../common/joi-schema/login';
import { JoiValidationPipe } from '../common/pipes/joi';
import { tokenSchema } from '../common/joi-schema/token';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @UsePipes(new JoiValidationPipe(loginSchema))
  @HttpCode(200)
  @Post('login')
  login(@Body() loginDto: LoginDto) {
    return this.authService.login(loginDto);
  }

}
Enter fullscreen mode Exit fullscreen mode

The login route can be accessed at the path: api/v1/auth/login. Successful requests resolve with a status code of 200. In the login method, we extract the body of the request and pass it on to our service handler method.

Phone verification

Before users can enable two-factor authentication on their account, their phone number must be verified on the application. In this section, we will be using Twilio to send a verification OTP to their registered phone number.

Set up Twilio

Sign up for an account on the Twilio website. Once signed up on Twilio, you'll get a phone number that you'll use to send out SMS. Also, grab your Twilio Account SID and Twilio Auth Token from your Twilio console. Update your .env with your new credentials like so:

TWILIO_AUTH_TOKEN=
TWILIO_ACCOUNT_SID=
TWILIO_PHONE_NUMBER=
Enter fullscreen mode Exit fullscreen mode

Install the Twilio npm package which we'll use to access the Twilio API.

npm i twilio
Enter fullscreen mode Exit fullscreen mode

Update the constants utility file previously created to look like this:

export const constants = {
  jwtSecret: process.env.JWT_SECRET,
  twilioAuthToken: process.env.TWILIO_AUTH_TOKEN,
  twilioAccountSID: process.env.TWILIO_ACCOUNT_SID,
  twilioPhoneNumber: process.env.TWILIO_PHONE_NUMBER,
};
Enter fullscreen mode Exit fullscreen mode

Next, we'll create a function to handle sending out SMS. Create the function in this file path: src/common/utils/twilio.ts.

import { Twilio } from 'twilio';
import { constants } from './constants';

const { twilioAccountSID, twilioAuthToken, twilioPhoneNumber } = constants;
const client = new Twilio(twilioAccountSID, twilioAuthToken);

export const sendSMS = async (phoneNumber: string, message: string) => {
  try {
    const smsResponse = await client.messages.create({
      from: twilioPhoneNumber,
      to: phoneNumber,
      body: message,
    });
    console.log(smsResponse.sid);
  } catch (error) {
    error.statusCode = 400;
    throw error;
  }
};

Enter fullscreen mode Exit fullscreen mode

Create Auth Guard

In NestJS, guards are classes that are used to determine if a request has the necessary authorization to access a resource. Our phone verification route will be a protected route. Hence, requests to this route must carry a bearer token from a signed-in user.

Create the auth guard in this path: src/common/guards/auth.guard.ts.

import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { constants } from '../utils/constants';
import { Request } from 'express';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);
    if (!token) {
      throw new UnauthorizedException();
    }
    try {
      const payload = await this.jwtService.verifyAsync(token, {
        secret: constants.jwtSecret,
      });
      // 💡 We're assigning the payload to the request object here
      // so that we can access it in our route handlers
      request['user'] = payload;
    } catch {
      throw new UnauthorizedException();
    }
    return true;
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

Enter fullscreen mode Exit fullscreen mode

In the auth guard, we have a private method that extracts the bearer token from the authorization header of the incoming request and a canActivate method that validates the bearer token. If the token is not valid, we throw a 401 error else we append a user property to the request and pass the request on to its route handler.

Send phone verification OTP

The phone verification request will be sent to a post endpoint. Create a joi validation schema for the request in this path: src/common/joi/verify-phone.ts.

import * as Joi from 'joi';

export const verifyPhoneSchema = Joi.object({
  verify: Joi.boolean().valid(true).required(),
});

Enter fullscreen mode Exit fullscreen mode

The schema shows that the endpoint expects a request with a verify key in the request body.

In the AccountController file, add the route handler method for phone verification.

 import {
  Controller,
  Post,
  Body,
  UsePipes,
  UseGuards,
  HttpCode,
  Req,
} from '@nestjs/common';
import { Request } from 'express';
import { AccountService } from './account.service';
import { CreateUserDto } from './dto/create-user.dto';
import { signupSchema } from '../common/joi-schema/signup';
import { JoiValidationPipe } from '../common/pipes/joi';
import { AuthGuard } from '../common/guards/auth.guard';
import { verifyPhoneSchema } from '../common/joi-schema/verify-phone';

@Controller('account')
export class AccountController {
  constructor(private readonly accountService: AccountService) {}

  ...

  @HttpCode(200)
  @Post('phone/verify')
  @UsePipes(new JoiValidationPipe(verifyPhoneSchema))
  @UseGuards(AuthGuard)
  verifyPhone(@Body() _body, @Req() request: Request) {
    return this.accountService.verifyPhone(request);
  }
}
Enter fullscreen mode Exit fullscreen mode

The request will be sent to the path: api/v1/account/phone/verify. We use the @UseGuards decorator to ensure that only authorized requests can access this route. Once the request successfully passes the guard validation, we pass the entire request to the account service method. The reason for this is that we need to access the user property on the request. This way, we know the user who is requesting a phone verification.

Create the service method for phone verification and update your imports.

...
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { Request } from 'express';
import { generateOTP } from '../common/utils/codeGenerator';
import { Prisma } from '@prisma/client';
import { getExpiry, isTokenExpired } from '../common/utils/dateTimeUtility';
import { sendSMS } from '../common/utils/twilio';

@Injectable()
export class AccountService {
  constructor(private prisma: PrismaService) {}

    ...
  async verifyPhone(req: Request) {
      const userDetails = req['user'];
      const user = await this.prisma.user.findUnique({
        where: { id: userDetails.sub },
      });
      if (!user) {
        throw new HttpException('User not found', HttpStatus.NOT_FOUND);
      }
      if (user.isPhoneVerified) {
        return { success: true };
      }
      const otp = generateOTP(6);
      const otpPayload: Prisma.OtpUncheckedCreateInput = {
        userId: user.id,
        code: otp,
        useCase: 'PHV',
        expiresAt: getExpiry(),
      };

      await this.prisma.otp.create({
        data: otpPayload,
      });
      await sendSMS(
        user.phone,
        `Use this code ${otp} to verify the phone number registered on your account`,
      );
      return { success: true };
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

In the verifyPhone method, we extract the user property on the request and check if the user indeed exists on the database. If the user is unverified, we generate a 6-digit code, create a record in our Otp table with the use case of PHV and send the user an sms containing an Otp that will expire in 5 minutes.

Create the utility functions generateOTP and getExpiry.

//src/common/utils/codeGenerator.ts
export const generateOTP = (n: number): string => {
  const digits = '0123456789';
  let otp = '';

  for (let i = 0; i < n; i++) {
    otp += digits[Math.floor(Math.random() * digits.length)];
  }

  return otp;
};

Enter fullscreen mode Exit fullscreen mode

Import the moment npm package by running npm install moment.

//src/common/utils/dateTimeUtility.ts
import * as moment from 'moment';
export const getExpiry = () => {
  const createdAt = new Date();
  const expiresAt = moment(createdAt).add(5, 'minutes').toDate();
  return expiresAt;
};

export function isTokenExpired(expiry: Date): boolean {
  const expirationDate = new Date(expiry);
  const currentDate = new Date();
  return expirationDate.getTime() <= currentDate.getTime();
}
Enter fullscreen mode Exit fullscreen mode

Verify PHV OTP

The client will make a request to verify the OTP the user received for phone verification. Create the joi schema for this endpoint in this path: src.common/joi/token.ts.

import * as Joi from 'joi';

export const tokenSchema = Joi.object({
  token: Joi.string().required(),
});
Enter fullscreen mode Exit fullscreen mode

Create a route handler method for this verification in the AccountController.

@Controller('account')
export class AccountController {
  constructor(private readonly accountService: AccountService) {}

  ...

  @UsePipes(new JoiValidationPipe(tokenSchema))
  @UseGuards(AuthGuard)
  @HttpCode(200)
  @Post('phone/verify/token')
  validatePhoneVerification(@Body() _body, @Req() request: Request) {
    return this.accountService.validatePhoneVerification(request);
  }
}
Enter fullscreen mode Exit fullscreen mode

Create the service method to handle this verification in the AccountService class.

@Injectable()
export class AccountService {
  constructor(private prisma: PrismaService) {}

    ...
  async validatePhoneVerification(req: Request) {
    const {
      body: { token },
    } = req;
    const userDetails = req['user'];
    // find otp record
    const otpRecord = await this.prisma.otp.findFirst({
      where: { code: token, useCase: 'PHV', userId: userDetails.sub },
    });
    if (!otpRecord) {
      throw new HttpException('Invalid OTP', HttpStatus.NOT_FOUND);
    }
    // check if otp is expired
    const isExpired = isTokenExpired(otpRecord.expiresAt);
    if (isExpired) {
      throw new HttpException('Expired token', HttpStatus.NOT_FOUND);
    }
    // update user isPhoneVerified to true
    await this.prisma.user.update({
      where: { id: userDetails.sub },
      data: { isPhoneVerified: true },
    });

    // delete the otp record
    await this.prisma.otp.delete({ where: { id: otpRecord.id } });
    return { success: true };
  }

}
Enter fullscreen mode Exit fullscreen mode

The service method does the following:

  • Extracts the token from the request body and checks if it exists in the database.
  • Checks the token expiry if it exists
  • Update the isPhoneVerified field in the user record to true if the token is valid
  • Deletes the OTP record from the database

Setting 2FA

In this section, we'll create an endpoint to enable or disable two-factor authentication on the user account.

Create the joi schema for this endpoint in src/common/joi-schema/set2fa.ts.

import * as Joi from 'joi';

export const set2faSchema = Joi.object({
  set_2fa: Joi.boolean().required(),
});
Enter fullscreen mode Exit fullscreen mode

Create the route handler method in AccountController.

@Controller('account')
export class AccountController {
  constructor(private readonly accountService: AccountService) {}

 ...

  @UsePipes(new JoiValidationPipe(set2faSchema))
  @UseGuards(AuthGuard)
  @HttpCode(200)
  @Post('set/twofa')
  enableTwoFA(@Body() _body, @Req() request: Request) {
    return this.accountService.setTwoFA(request);
  }
}
Enter fullscreen mode Exit fullscreen mode

The request will be sent to the path: api/v1/account/set/twofa. Create the service method for this route in the AccountService class.

@Injectable()
export class AccountService {
  constructor(private prisma: PrismaService) {}

  ...

  async setTwoFA(req: Request) {
    const {
      body: { set_2fa },
    } = req;
    const userDetails = req['user'];

    const user = await this.prisma.user.findUnique({
      where: { id: userDetails.sub },
    });

    if (!user) {
      throw new HttpException('User not found', HttpStatus.NOT_FOUND);
    }

    if (user.twoFA === set_2fa) {
      return { success: true };
    }

    if (user.twoFA && set_2fa == false) {
      // generate otp to disable MFA, create otp record, send sms and return
      const otp = generateOTP(6);
      const otpPayload: Prisma.OtpUncheckedCreateInput = {
        userId: user.id,
        code: otp,
        useCase: 'D2FA',
        expiresAt: getExpiry(),
      };

      await this.prisma.otp.create({
        data: otpPayload,
      });
      await sendSMS(
        user.phone,
        `Use this code ${otp} to disable multifactor authentication on your account`,
      );
      return { success: true };
    }

    await this.prisma.user.update({
      where: { id: user.id },
      data: { twoFA: set_2fa },
    });

    return { success: true };
  }
}
Enter fullscreen mode Exit fullscreen mode

This service method does the following:

  • If the set_2fa parameter of the request body is true and the user does not have 2FA enabled, two-factor authentication is enabled on their account.
    • If the set_2fa parameter of the request body is false and the user already has 2FA enabled, an OTP with the use case D2FA is sent to their phone number. The OTP will be validated on a separate endpoint before 2FA is disabled on the user account.

Disabling 2FA

To disable two-factor authentication on their account, the user will receive an OTP. This OTP will be validated before 2FA is finally disabled on their account. We'll create the route handler and service method for this.

@Controller('account')
export class AccountController {
  constructor(private readonly accountService: AccountService) {}

 ...

  @UsePipes(new JoiValidationPipe(tokenSchema))
  @UseGuards(AuthGuard)
  @HttpCode(200)
  @Post('disable-twofa/verify')
  disable2FAVerification(@Body() _body, @Req() request: Request) {
    return this.accountService.disable2FAVerification(request);
  }
}
Enter fullscreen mode Exit fullscreen mode
@Injectable()
export class AccountService {
  constructor(private prisma: PrismaService) {}

  ...
  async disable2FAVerification(req: Request) {
      const {
        body: { token },
      } = req;
      const userDetails = req['user'];
      const otpRecord = await this.prisma.otp.findFirst({
        where: { code: token, useCase: 'D2FA', userId: userDetails.sub },
      });
      if (!otpRecord) {
        throw new HttpException('Invalid OTP', HttpStatus.NOT_FOUND);
      }
      const isExpired = isTokenExpired(otpRecord.expiresAt);
      if (isExpired) {
        throw new HttpException('Expired token', HttpStatus.NOT_FOUND);
      }
      await this.prisma.user.update({
        where: { id: userDetails.sub },
        data: { twoFA: false },
      });

      await this.prisma.otp.delete({ where: { id: otpRecord.id } });
      return { success: true };
    }
}

Enter fullscreen mode Exit fullscreen mode

To verify the OTP sent for disabling 2FA, a post request will be sent toapi/v1/account/disable-twofa/verify. In the disable2FAVerification method, we check if the OTP exists in the database and if it does, we check if it has expired. If the OTP is still valid, we update the user record in the database by setting the twoFA field to false. Finally, we delete the OTP record from the database.

Login flow for 2FA enabled account

Recall that in the login method of the AuthService class, if the user does not have two-factor authentication enabled, we immediately generate a JWT and send it back to the client. We'll now update the login method to cater to users who have enabled 2FA. Update the login method of the AuthService class:

@Injectable()
export class AuthService {
  constructor(private prisma: PrismaService, private jwtService: JwtService) {}

  async login(loginDto: LoginDto) {
    const user = await this.prisma.user.findUnique({
      where: { email: loginDto.email },
    });
    if (!user) {
      throw new HttpException(
        'Invalid email or password',
        HttpStatus.BAD_REQUEST,
      );
    }
    const validPassword = await verifyPassword(
      loginDto.password,
      user.password,
    );

    if (!validPassword) {
      throw new HttpException(
        'Invalid email or password',
        HttpStatus.BAD_REQUEST,
      );
    }

    if (!user.twoFA) {
      const payload = {
        email: user.email,
        first_name: user.firstName,
        last_name: user.lastName,
        sub: user.id,
      };
      return {
        success: true,
        access_token: await this.jwtService.signAsync(payload),
      };
    }

    const otp = generateOTP(6);
    const otpPayload: Prisma.OtpUncheckedCreateInput = {
      userId: user.id,
      code: otp,
      useCase: 'LOGIN',
      expiresAt: getExpiry(),
    };
    await this.prisma.otp.create({
      data: otpPayload,
    });
    await sendSMS(
      user.phone,
      `Use this code ${otp} to finalize login on your account`,
    );
    return { success: true };
  }
}
Enter fullscreen mode Exit fullscreen mode

Now for accounts with 2FA enabled, a 6-digit OTP with a use case of LOGIN will be sent to their phone number. The OTP will be validated on a separate endpoint and if it is valid, a JWT will be sent back to the client.

Verify LOGIN OTP

Update the AuthController to include a route handler method which will be used to validate the login OTP.

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  ...

  @UsePipes(new JoiValidationPipe(tokenSchema))
  @HttpCode(200)
  @Post('login/verify/token')
  verifyLogin(@Body() _body, @Req() request: Request) {
    return this.authService.verifyLogin(request);
  }
}
Enter fullscreen mode Exit fullscreen mode

The login OTP is sent as a post request to api/v1/auth/login/verify/token. Create a service method for this route handler in the AuthService class.

@Injectable()
export class AuthService {
  constructor(private prisma: PrismaService, private jwtService: JwtService) {}
  async verifyLogin(req: Request) {
    const {
      body: { token },
    } = req;
    const otpRecord = await this.prisma.otp.findFirst({
      where: { code: token, useCase: 'LOGIN' },
    });
    if (!otpRecord) {
      throw new HttpException('Invalid OTP', HttpStatus.NOT_FOUND);
    }
    const isExpired = isTokenExpired(otpRecord.expiresAt);
    if (isExpired) {
      throw new HttpException('Expired token', HttpStatus.NOT_FOUND);
    }
    const user = await this.prisma.user.findUnique({
      where: { id: otpRecord.userId },
    });

    if (!user) {
      throw new HttpException('Invalid OTP', HttpStatus.NOT_FOUND);
    }
    const payload = {
      email: user.email,
      first_name: user.firstName,
      last_name: user.lastName,
      sub: user.id,
    };
    return {
      success: true,
      access_token: await this.jwtService.signAsync(payload),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The verifyLogin service method does the following:

  • Checks if the OTP exists in the database.
  • If OTP exists, it checks if it is expired.
  • If OTP is valid, a JWT is generated based on the user credentials and returned to the client.

Conclusion

In conclusion, implementing two-factor authentication (2FA) can significantly enhance the security of an application. By requiring users to provide an additional verification factor, such as a time-based code from their mobile device, you can reduce the risk of unauthorized access and protect sensitive data.

In this article, we have covered various aspects of implementing 2FA in a NestJS project, including sign-up, login, authentication, request validation, and route protection. We have also discussed how to enable and disable 2FA for users.

By using NestJS's built-in features such as pipes and guards, we can ensure that our implementation is robust and secure. It is important to note that while 2FA adds an extra layer of security, it is not a replacement for strong passwords and other security best practices.

🔥 Project GitHub repository
🔥 Postman documentation for endpoints

Thank you for reading. Feel free to share your thoughts in the comments.

Top comments (2)

Collapse
 
arcturus91 profile image
Arturo Barrantes Vasquez

Nice!
i like how detailed are your explanations.
thank u

Collapse
 
algodame profile image
Chiamaka Ojiyi

Thank you Arturo