DEV Community

Cover image for 2FA with NestJs & passeport using Google Authenticator
Matthew
Matthew

Posted on

2FA with NestJs & passeport using Google Authenticator

I recently had to implement a two factor authentication on a project for my company and it was a whole new thing for me. Sure I had already used 2fa before but I had never implemented it.

What is 2fa ? Well we all know that passwords aren't really secure enough to avoid security breaches so...

2FA is an extra layer of security used to make sure that people trying to gain access to an online account are who they say they are. First, a user will enter their username and a password. Then, instead of immediately gaining access, they will be required to provide another piece of information.

In this case, I was asked to use the google authenticator app to generate a 2fa code that would be used to authenticate the user after the login step.

I'll be using nestJs with passportjs. Here's the GitHub link if you want to check it out: https://github.com/MatthieuHahn/2fa

Initializing the NestJS project with basic login password authentication

Let's create a new nestJs project.

nest new 2fa

Creating the authentication module

First let's install the nestJs passeport dependencies and types. We'll need them in the next step.

yarn add @nestjs/passport passport passport-local
yarn add -D @types/passport-local

Then, we'll generate the authentication module, controller and service.

nest generate resource authentication

It'll ask you a few questions: choose REST API and then answer No (it's an auth module, we don't need CRUD endpoints)

Then we'll create a user module, service and interface

nest generate module users

nest generate service users

export interface User {
  userId: number;
  username: string;
  password: string;
}
Enter fullscreen mode Exit fullscreen mode

The users service will contain fake data, but, of course, you should add a data layer to persist data ๐Ÿ˜…

import { Injectable } from '@nestjs/common';
import { User } from "./user.entity";

@Injectable()
export class UsersService {
  private readonly users = [
    {
      userId: 1,
      username: 'john',
      password: 'changeme',
    },
    {
      userId: 2,
      username: 'maria',
      password: 'guess',
    },
  ];

  async findOne(username: string): Promise<User | undefined> {
    return this.users.find(user => user.username === username);
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's add a validateUser method that will check if the user and password that we'll send are a match with our data.

import { Injectable } from '@nestjs/common';
import { UsersService } from "../users/users.service";
import { User } from "../users/user.entity";

@Injectable()
export class AuthenticationService {
  constructor(private usersService: UsersService) {
  }

  async validateUser(email: string, pass: string): Promise<Partial<User>> {
    const user = await this.usersService.findOne(email);
    try {
      // Of course, we should consider encrypting the password
      const isMatch = pass === user.password;
      if (user && isMatch) {
        const { password: _, ...userWithoutPassword } = user;

        return userWithoutPassword;
      }
    } catch (e) {
      return null;
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

If you are not familiar with nestJs, this framework provides a UseGuard decorator that will act as an auth middleware which can rely on the passportjs library. So let's use this feature and the nestJs passport library to manage the user/password login.

First we define the local auth guard which extends the passportjs local strategy.

local-auth.guard.ts

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

Enter fullscreen mode Exit fullscreen mode

And then we define the local authentication strategy. The constructor contains the auth fields that will be sent by the front-end via the login POST route (email, password).
The validate method will use the validateUser method we created earlier. If it returns no user, then it will throw a 401 Unauthorized error, else it will return the user without the password.

local.strategy.ts

import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthenticationService } from '../authentication.service';
import { User } from '../../users/user.entity';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authenticationService: AuthenticationService) {
    super({
      usernameField: 'email',
      passwordField: 'password',
    });
  }

  async validate(email: string, password: string): Promise<Partial<User>> {
    const userWithoutPsw = await this.authenticationService.validateUser(email, password);
    if (!userWithoutPsw) {
      throw new UnauthorizedException();
    }
    return userWithoutPsw;
  }
}
Enter fullscreen mode Exit fullscreen mode

We can now create the login method in the authentication controller and service. For now it'll only return the user's email. But later on, we'll add a Jwt access token

First we add the login method to the authentication service.

async login(userWithoutPsw: Partial<User>, isTwoFactorAuthenticated = false) {
    const payload = {
      email: userWithoutPsw.email,
      isTwoFactorAuthenticationEnabled: !!userWithoutPsw.isTwoFactorAuthenticationEnabled,
      isTwoFactorAuthenticated,
    };

    return {
      email: payload.email,
    };
  }
Enter fullscreen mode Exit fullscreen mode

Then we create the controller route with the LocalAuthGuard

@UseGuards(LocalAuthGuard)
@Post('login')
@HttpCode(200)
async login(@Request() req) {
  const userWithoutPsw: Partial<User> = req.user;

  return this.authenticationService.login(userWithoutPsw);
}
Enter fullscreen mode Exit fullscreen mode

Ok, so now, if we send a post request on the /authentication/login route with correct credentials, it will return the user's email. Let's add the Jwt management now.

First we add the nestJs Jwt package.

yarn add @nestjs/jwt

Then we have to register the JwtModule in the Authentication module in order to be able to use it. The secret should, of course, be secret and in an .env file.

JwtModule.register({
    secret: 'secret',
    signOptions: { expiresIn: '1d' },
  })
Enter fullscreen mode Exit fullscreen mode

Then we just have to add the JwtService to the AuthenticationService and use it to generate the access_token we'll return to the front-end.
Here's what the authentication service should look like now.

import { Injectable } from '@nestjs/common';
import { UsersService } from "../users/users.service";
import { User } from "../users/user.entity";
import { JwtService } from "@nestjs/jwt";

@Injectable()
export class AuthenticationService {
  constructor(private usersService: UsersService, private jwtService: JwtService) {
  }

  async validateUser(email: string, pass: string): Promise<Partial<User>> {
    const user = await this.usersService.findOne(email);
    try {
      // Of course, we should consider encrypting the password
      const isMatch = pass === user.password;
      if (user && isMatch) {
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        const { password: _, ...userWithoutPassword } = user;
        return userWithoutPassword;
      }
    } catch (e) {
      return null;
    }
  }

  async login(userWithoutPsw: Partial<User>) {
    const payload = {
      email: userWithoutPsw.email,
    };

    return {
      email: payload.email,
      access_token: this.jwtService.sign(payload),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the frontend has a Jwt token it'll be able to use to authenticate requests to the backend. But how will the backend manage the Authorization token ?
Well, let's create a JwtAuthGuard using passportjs jwt package.

yarn add passport-jwt

We can now create the jwt strategy which is based on passportjs.

jwt.strategy.ts

import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { UsersService } from '../../users/users.service';
import { TokenPayload } from '../token-payload.entity';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly userService: UsersService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: 'secret',
    });
  }

  async validate(payload: TokenPayload) {
    const user = await this.userService.findOne(payload.email);

    if (user) {
      return user;
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

And create the JwtAuthGuard.

jwt-auth.guard.ts

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
Enter fullscreen mode Exit fullscreen mode

So for now, we have a complete basic authentication using JWT to authenticate requests. But as we said before, it is based on a user/password login and therefore it is not very secure. So let's add the two factor authentication.

Two factor authentication

Here's the login flow for 2fa authentication:

  1. The user logs in with his email and password
  2. If the 2fa is not enabled, he can enable it using the turn-on route. This will generate a QrCode that the user will scan with the google authenticator app.
  3. The use then uses the random code the app has generated to authenticate

Creating the 2fa system

First we have to create a unique secret for every user that turns on 2fa, but we'll also need a special otp authentication url that we'll be using later to create a QrCode. The otplib package is a good match, so let's install it.

yarn add otplib

We should also update the user interface and add the twoFactorAuthenticationSecret property.

export interface User {
  userId: number;
  email: string;
  username: string;
  password: string;
  twoFactorAuthenticationSecret: string;
}
Enter fullscreen mode Exit fullscreen mode

Then we create a method to generate the secret and otpAuthUrl in the authentication service and return both of them. The AUTH_APP_NAME is the name that will appear in the google authenticator app.

async generateTwoFactorAuthenticationSecret(user: User) {
    const secret = authenticator.generateSecret();

    const otpauthUrl = authenticator.keyuri(user.email, 'AUTH_APP_NAME', secret);

    await this.usersService.setTwoFactorAuthenticationSecret(secret, user.userId);

    return {
      secret,
      otpauthUrl
    }
  }
Enter fullscreen mode Exit fullscreen mode

We have to update the user with the secret that has just been generated. Once again, this should all be in a database.

  async setTwoFactorAuthenticationSecret(secret: string, userId: number) {
    this.users.find(user => user.userId === userId).twoFactorAuthenticationSecret = secret;
  }
Enter fullscreen mode Exit fullscreen mode

Now, we can generate the QrCode that will be used to add our application to the google authenticator app.

yarn add qrcode

Let's add the generate method in the authentication service.

  import { toDataURL } from 'qrcode';

  async generateQrCodeDataURL(otpAuthUrl: string) {
    return toDataURL(otpAuthUrl);
  }
Enter fullscreen mode Exit fullscreen mode

Now we need to offer the possiblity for the user to turn on the 2fa. So let's add another property to the user interface.

export interface User {
  userId: number;
  email: string;
  username: string;
  password: string;
  twoFactorAuthenticationSecret: string;
  isTwoFactorAuthenticationEnabled: boolean;
}
Enter fullscreen mode Exit fullscreen mode

Add the turnOn method in the users service

  async turnOnTwoFactorAuthentication(userId: number) {
    this.users.find(user => user.userId === userId).isTwoFactorAuthenticationEnabled = true;
  }
Enter fullscreen mode Exit fullscreen mode

Add the method that will verify the authentication code with the user's secret

isTwoFactorAuthenticationCodeValid(twoFactorAuthenticationCode: string, user: User) {
    return authenticator.verify({
      token: twoFactorAuthenticationCode,
      secret: user.twoFactorAuthenticationSecret,
    });
  }
Enter fullscreen mode Exit fullscreen mode

Add the turn on route in the authentication controller

 @Post('2fa/turn-on')
  @UseGuards(JwtAuthGuard)
  async turnOnTwoFactorAuthentication(@Req() request, @Body() body) {
    const isCodeValid =
      this.authenticationService.isTwoFactorAuthenticationCodeValid(
        body.twoFactorAuthenticationCode,
        request.user,
      );
    if (!isCodeValid) {
      throw new UnauthorizedException('Wrong authentication code');
    }
    await this.usersService.turnOnTwoFactorAuthentication(request.user.id);
  }
Enter fullscreen mode Exit fullscreen mode

Logging in with 2fa

Let's add a login with 2fa method in the authentication service. The difference with the default login function is that we add the 2fa status in the payload.

  async loginWith2fa(userWithoutPsw: Partial<User>) {
    const payload = {
      email: userWithoutPsw.email,
      isTwoFactorAuthenticationEnabled: !!userWithoutPsw.isTwoFactorAuthenticationEnabled,
      isTwoFactorAuthenticated: true,
    };

    return {
      email: payload.email,
      access_token: this.jwtService.sign(payload),
    };
  }
Enter fullscreen mode Exit fullscreen mode

We can now create the 2fa authentication route in the controller. If the code sent in the post body is valid, then we try to login with 2fa else we throw an error.

@Post('2fa/authenticate')
  @HttpCode(200)
  @UseGuards(JwtAuthGuard)
  async authenticate(@Request() request, @Body() body) {
    const isCodeValid = this.authenticationService.isTwoFactorAuthenticationCodeValid(
      body.twoFactorAuthenticationCode,
      request.user,
    );

    if (!isCodeValid) {
      throw new UnauthorizedException('Wrong authentication code');
    }

    return this.authenticationService.loginWith2fa(request.user);
  }
Enter fullscreen mode Exit fullscreen mode

It is now possible to create an AuthGuard strategy based on Jwt and the 2fa status. If the 2fa is not turned on then we can rely on the jwt only, if the 2fa is enabled then we check if the user is 2fa authenticated.

jwt-2fa.strategy.ts

import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { UsersService } from '../../users/users.service';

@Injectable()
export class Jwt2faStrategy extends PassportStrategy(Strategy, 'jwt-2fa') {
  constructor(private readonly userService: UsersService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: 'secret',
    });
  }

  async validate(payload: any) {
    const user = await this.userService.findOne(payload.email);

    if (!user.isTwoFactorAuthenticationEnabled) {
      return user;
    }
    if (payload.isTwoFactorAuthenticated) {
      return user;
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

jwt-2fa-auth.guard.ts

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class Jwt2faAuthGuard extends AuthGuard('jwt-2fa') {}
Enter fullscreen mode Exit fullscreen mode

Let's see how this goes right now.

Testing

So first we'll do a POST request to log in with the user and password:

Image description

This will return the following:

Image description

Then, we need to get the QrCode to add our app to the google authenticator app

Image description

This will return a base64 data url which in turn will happen to be a QrCode

Image description

If you scan this with the Google Authenticator App it should add your app:

Image description

And then you should be able to call the authenticate route with the current code from the google authenticator app

Discussion (0)