Implementing SMS-enabled Two-Factor Authentication using NestJS, Twilio and Prisma
Chiamaka Ojiyi
Posted on May 8, 2023
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
Once installed, we can use the CLI to generate a new nest project.
nest new project-name
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
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();
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();
}
}
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('*');
}
}
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"
}
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;
}
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',
});
}
}
}
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
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;
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
Initialize Prisma in your project by running the command below in your terminal.
npx prisma init
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"
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")
}
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
}
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
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
When the above command is run, the Nest CLI does the following:
- Creates the files
src/prisma/prisma.module.ts
andsrc/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();
});
}
}
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 {}
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
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
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;
}
}
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(),
});
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;
}
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');
}
}
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
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;
};
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);
}
}
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
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
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
};
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 {}
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;
}
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(),
});
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),
};
}
}
}
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;
};
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);
}
}
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=
Install the Twilio npm package which we'll use to access the Twilio API.
npm i twilio
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,
};
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;
}
};
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;
}
}
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(),
});
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);
}
}
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 };
}
}
}
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;
};
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();
}
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(),
});
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);
}
}
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 };
}
}
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(),
});
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);
}
}
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 };
}
}
This service method does the following:
- If the
set_2fa
parameter of the request body istrue
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 isfalse
and the user already has 2FA enabled, an OTP with the use caseD2FA
is sent to their phone number. The OTP will be validated on a separate endpoint before 2FA is disabled on the user account.
- If the
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);
}
}
@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 };
}
}
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 };
}
}
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);
}
}
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),
};
}
}
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.
Posted on May 8, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
May 8, 2023