Securing Your NestJS Application with TOTP 2FA

Securing Your NestJS Application with TOTP 2FA

Securing user accounts is a critical aspect of any modern web application. Two-factor authentication (2FA) is an effective way to enhance security by requiring users to provide two forms of identification; typically a password and a second factor like a one-time code.

Time-Based One-Time Passwords (TOTP) are a widely used 2FA method. TOTP generates time-sensitive codes that users access through apps like Google Authenticator or Authy. These codes provide an additional layer of security, which makes it harder for attackers to gain unauthorized access.

This guide will walk you through implementing TOTP-based 2FA in a NestJS application. You'll learn how to generate TOTPs and QR codes for users to scan with their authenticator apps, verify the tokens, and integrate the process into your authentication flow.

How to Set Up TOTP 2FA in Your NestJS App

To follow along with this guide, you need to have the following:

Setting Up a NestJS Application

To keep this guide's focus on implementing TOTP 2FA, I have set up a starter template that you'll build on. To clone this template to your local machine, execute the command below:

git clone --single-branch -b starter-template https://github.com/kimanikevin254/nestjs-totp-2fa.git

Open the project in your code editor and explore the files. This project uses Docker to run a Postgres database as you can see in the docker-compose.yml file in the project root folder. Let's look at the other key files in this project:

  • src/auth/auth.controller.ts: Defines two routes that allow the user to sign up and sign in to the application.
  • src/auth/auth.service.ts: Defines a couple of auth-related methods for hashing the user's password before storing it in the database, generating an access token, signing up, and signing in.
  • src/user/user.controller.ts: Defines a protected route that allows authenticated users to access their profile information.
  • src/user/entity.ts: Defines the schema for the user model.
  • src/user/service.ts: Defines a couple of methods that create a user, retrieve a user by email and ID, and retrieve the user profile.
  • .env.example: Defines the environment variables required for this project.

There is also a common module that defines code used by the other modules such as loading environment variables, custom decorators, guards, and interfaces.

Now that you understand the project, you can go ahead to set it up. First, rename the .env.example file to .env. You can change some of these credentials or use them as they are.

Next, start the database by running the Docker containers using the command below:

docker compose up

Next, install all the dependencies using the command below:

npm i

You can now run the NestJS application using the command:

npm run start:dev

Once the application starts successfully, open your Postman application and make a POST request to "localhost:3000/api/auth/signup" with the following payload:

{
    "name": "John Doe",
    "email": "john@gmail.com",
    "password": "JoHn12345!"
}

You should get a response with the access token and user ID:

User created successfully

Try to log in using the same credentials by sending a POST request to "localhost:3000/api/auth/signin":

Successful sign-in

Finally, send a GET request to "localhost:3000/api/user" with the access token you received in the previous step in the authorization header:

Profile info

In the next section, you will implement TOTP 2FA. When users sign up, they will receive a QR code to scan with their authenticator app. For subsequent login attempts, they will need to provide the token displayed in the authenticator app.

Generating a Time-Based One-Time Password

You will use the OTPAuth library to generate TOTPs. To install this library, execute the following command in your terminal:

npm i otpauth

Next, you need to define a method to generate a time-based one-time password. But first, open the src/auth/auth.service.ts file and add the following import statement:

import * as OTPAuth from "otpauth";

Next, add the following method to the AuthService class:

private generateTOTP(email: string): OTPAuth.TOTP {
    return new OTPAuth.TOTP({
        issuer: 'NestJS App',
        label: email,
        algorithm: 'SHA256',
        digits: 6,
        period: 30,
        secret: new OTPAuth.Secret(), // Generates a random cryptographically secure secret.
    });
}

This method generates a Time-Based One-Time Password (TOTP) object. It takes the user's email as input and creates a new TOTP instance with the following settings:

  • Issuer: Defines the provider that the account is associated with. In this case, you use the application name ('NestJS App').
  • Label: Defines the identifier for the account. In this case, the label is the user's email.
  • Algorithm: Define the algorithm used to generate the OTP.
  • Digits: Defines the length of the OTP.
  • Period: Defines how long an OTP is valid for.
  • Secret: Defines a unique secret key user to generate the OTP.

Generating a QR Code

After generating a TOTP object, you will need a method that generates a QR code from the TOTP object and returns it to the user so that they can scan it using their authenticator app. You will use the node-qrcode library to generate QR codes. Install it using the command below:

npm i qrcode

Next, open the src/auth/auth.service.ts file and add the following import statement:

import * as QRCode from "qrcode";

Next, add the following method to the AuthService class:

private async generateQRCode(totp: OTPAuth.TOTP): Promise<string> {
    const totpURI = OTPAuth.URI.stringify(totp)
    return QRCode.toDataURL(totpURI);
}

This method generates a QR code for the provided TOTP object. It first converts the TOTP object into a URI (a string format) using OTPAuth.URI.stringify(totp). Then, it generates a QR code from that URI and returns it as a base64-encoded data URL, which can be used to display the QR code as an image.

Serializing the TOTP

You need to store the TOTP object in the database so that you can use it to validate the tokens provided by the user during the login process. But you cannot store the object as is. If you take a look at the User entity in the src/user/user.entity.ts file, you will notice that the totp field accepts a string value. Therefore, you need a method that will convert the TOTP object into a string before storing it in the database. To achieve this, add the following method to the AuthService class:

private serializeTOTP(totp: OTPAuth.TOTP) {
    return JSON.stringify({
        issuer: totp.issuer,
        label: totp.label,
        issuerInLabel: totp.issuerInLabel,
        secret: totp.secret.hex,
        algorithm: totp.algorithm,
        digits: totp.digits,
        period: totp.period,
    });
}

This method converts the TOTP object into a JSON string. It extracts all the properties from the TOTP object and then formats them into a JSON structure. This allows the TOTP data to be saved or transferred in a standardized, easily readable format. You just can't use JSON.stringify(totp) because the totp.secret value is an instance of the Secret class. Using totp.secret.hex for the secret value extracts the secret in hex format which can easily be serialized.

Validating Tokens

When a user attempts to log in, you will need a method that can validate the token they provide for two-factor authentication. But first, remember that the totp object is saved as a string which means that you will need a method that can convert it back to an instance of the OTPAuth.TOTP class. To achieve this, add the following method to the AuthService class:

private deserializeTOTP(totpString: string): OTPAuth.TOTP {
    const parsed = JSON.parse(totpString);

    return new OTPAuth.TOTP({
      issuer: parsed.issuer,
      label: parsed.label,
      issuerInLabel: parsed.issuerInLabel,
      secret: OTPAuth.Secret.fromHex(parsed.secret),
      algorithm: parsed.algorithm,
      digits: parsed.digits,
      period: parsed.period,
    });
}

This method converts the TOTP string back into a TOTP object. It first parses the string into an object and then uses the parsed data to create a new TOTP instance, including the secret (converted back from hex) and other properties like issuer, label, algorithm, digits, and period. This allows the TOTP data to be restored to its original form for use in validation.

Next, add the following method that validates the token provided by the user:

private validateTOTP(token: string, totp: OTPAuth.TOTP) {
    return totp.validate({ token, window: 1 });
}

This method checks if the provided token is valid by using the TOTP object. It calls the validate method of the totp instance, passing the token and a window value. The search window helps to account for clock drift between the client and the server. If the token is valid, it returns a number; otherwise, it returns null.

Updating Existing Code to Accommodate 2FA

At this point, you have defined all the helper methods you need to implement 2FA. What remains is updating the already existing code to accommodate 2FA. To do this, open the src/auth/auth.service.ts file and replace the signup method with the following:

async signup(dto: SignUpDto) {
    // Check if user exists
    const user = await this.userService.findByEmail(dto.email);

    if (user) {
      throw new HttpException(
        'Email address is already registered.',
        HttpStatus.BAD_REQUEST,
      );
    }

    // Hash password
    const passwordHash = await this.hashPassword(dto.password);

    const newUser = await this.userService.create({ ...dto, passwordHash });

    // Generate TOTP object
    const totp = this.generateTOTP(newUser.email);

    // Serialize totp
    const serializedTOTP = this.serializeTOTP(totp);

    // Save user TOTP
    await this.userService.updateUserTOTP(newUser.id, serializedTOTP);

    // Generate QR code for the user to scan
    const qrCode = await this.generateQRCode(totp);

    return { image: qrCode };
}

You will define the updateUserTOTP method in the UserService class later.

In addition to saving the user to the database, this method now generates a TOTP object, serializes it, updates the user information, and generates a QR code that the end user can scan.

Next, replace the login method with the following:

async login(dto: LogInDto) {
    const user = await this.userService.findByEmail(dto.email);

    if (!user) {
      throw new HttpException('Incorrect credentials', HttpStatus.UNAUTHORIZED);
    }

    // Check if password matches
    const passwordMatches = await bcrypt.compare(
      dto.password,
      user.passwordHash,
    );

    if (!passwordMatches) {
      throw new HttpException('Incorrect credentials', HttpStatus.UNAUTHORIZED);
    }

    return {
      message: 'Provide the OTP from your Authenticator app',
      userId: user.id,
    };
}

After validating the user credentials, this method now returns a message that prompts the user to submit the token from their authenticator app for 2FA.

Next, add the following method that will validate the user's token and return an access token:

async twoFactorAuth(userId: string, token: string) {
    // Retrieve user
    const user = await this.userService.findById(userId);

    // Deserialize saved totp
    const deserializedTOTP = this.deserializeTOTP(user.totp);

    // Validate token
    if (this.validateTOTP(token, deserializedTOTP) === null) {
      throw new HttpException('Invalid token', HttpStatus.UNAUTHORIZED);
    }

    const tokens = await this.generateTokens(user.id);

    return {
      tokens,
      userId: user.id,
    };
}

This method handles two-factor authentication for a user. It retrieves the user's data, recreates the TOTP object from the stored data, and validates the provided token. If the token is invalid, an exception is thrown. If valid, it generates new JWT tokens and returns them along with the user's ID.

Next, open the src/auth/auth.controller.ts file and replace the signup method with the following code:

async signup(@Body() dto: SignUpDto, @Res() res: Response) {
    const result = await this.authService.signup(dto);

    // Generate HTML response
    const html = `
      <html>
        <body style="text-aign: center; font-family: Arial. sans-serif;">
          <h3>Scan the QR Code to Set Up 2FA</h2>
          <img src="${result.image}" alt="QR Code" style="margin-top: 20px" />
        </body>
      </html>
    `;

    res.setHeader('Content-Type', 'text/html');
    res.status(HttpStatus.CREATED).send(html);
}

Remember to make sure that the Res decorator is imported from @nestjs/common by replacing the first import statement with import { Body, Controller, HttpCode, HttpStatus, Post, Res } from '@nestjs/common'; Make sure to also import Response from express by adding this import statement: import { Response } from 'express';.

This method calls the signup method from the authentication service to register the user and generate a QR code for setting up two-factor authentication. Then, it sends an HTML response displaying the QR code, prompting the user to scan it to enable 2FA.

Next, still in the same class, you need to add an endpoint that will allow the user to validate their OTP. Add the following method to the AuthController class:

@Post('/two-factor-auth')
@HttpCode(HttpStatus.OK)
twoFactorAuth(@Body() dto: TwoFactorAuthDto) {
    return this.authService.twoFactorAuth(dto.userId, dto.token);
}

This method takes the user's ID and token from the request body, validates the token using the authentication service and returns new access tokens if the validation is successful.

Next, you need to define the TwoFactorAuthDto class. Create a new file named two-factor-auth.dto.ts in the src/auth/dto folder and add the code below.

import { IsNotEmpty, IsString } from "class-validator";

export class TwoFactorAuthDto {
    @IsString()
    @IsNotEmpty()
    userId: string;

    @IsString()
    @IsNotEmpty()
    token: string;
}

This class validates that the userId and token fields are non-empty strings using class-validator decorators.

Remember to go back to the src/auth/auth.controller.ts file and import the decorator using this import statement: import { TwoFactorAuthDto } from './dto/two-factor-auth.dto';,

Lastly, open the src/user/user.service.ts file and add the following method that is used to update a user's totp value in the database.

async updateUserTOTP(userId: string, totp: string) {
    // Retrieve user
    const user = await this.findById(userId);

    if (!user) {
      throw new HttpException(
        'User with the provided ID does not exist',
        HttpStatus.NOT_FOUND,
      );
    }

    // update user totp
    user.totp = totp;

    return this.userRepository.save(user);
}

Make sure the HttpException class and HttpStatus enum are imported into the file by replacing the first import statement with the following:

import { HttpException, HttpStatus, Injectable } from "@nestjs/common";

Two-factor authentication is now fully set up in your application. You can now go ahead and test it out.

Testing the Application

To test if everything is working as expected, make sure your Docker containers are still running, and then run the NestJS application using the following command:

npm run start:dev

Next, open your Postman application and send a POST request to the "localhost:3000/api/auth/signup" endpoint as you did earlier. Make sure to use different credentials this time. You should get a response prompting you to scan the QR code:

Prompt to scan QR code

Make sure you select the Preview tab to see the QR code.

Next, scan the QR code using your authenticator app and once it is set successfully, attempt to log in using the same credentials by sending a POST request to the "localhost:3000/api/auth/signin" endpoint. You will be prompted to provide your OTP.

Prompt to provide OTP

Take note of your user ID from the response and make a POST request to "localhost:3000/api/auth/two-factor-auth" with the following payload:

{
    "userId": "<YOUR-USER-ID>",
    "token": "<YOUR-TOKEN-FROM-THE-AUTHENTICATOR-APP>"
}

Make sure to replace the placeholder values with the appropriate values.

You should get back response with an access token and your user ID:

Successful sign-in

This confirms that everything is working as expected.

You can access the full code on GitHub.

Conclusion

As you have seen in this guide, implementing TOTP 2FA in your NestJS application is a straightforward process, thanks to the awesome community packages. You learned how to generate secure TOTP codes, present them as QR codes for users to scan, and validate these codes during subsequent login attempts.

By following these steps, you now have a robust and secure 2FA system that enhances your application’s authentication process. This implementation not only adds an extra layer of security but also demonstrates how modern authentication techniques can be seamlessly integrated into a NestJS application. Feel free to build upon this foundation and adapt it to suit the specific needs of your project!