Nest.js Tutorial

Two-factor authentication

Marcin Wanago
JavaScriptNestJSTypeScript

While developing our application, security should be one of our main concerns. One of the ways we can improve it is by implementing a two-factor authentication mechanism. This article goes through its principles and puts them into practice with NestJS and Google Authenticator.

Adding two-factor authentication

The core idea behind two-factor authentication is to confirm the user’s identity in two ways. There is an important distinction between two-step authentication and two-factor authentication. A common example is with the ATM. To use it, we need both a credit card and a PIN code. We call it a two-factor authentication because it requires both something we have and something we know. For example, requiring a password and a PIN code could be called a two-step flow instead.

The very first thing is to create a secret key unique for every user. In the past, I’ve used the speakeasy library for that. Unfortunately, it is not maintained anymore. Therefore, in this article, we use the otplib package for this purpose.

1npm install otplib

Along with the above secret, we also generate a URL with the otpauth:// protocol. It is used by applications such as Google Authenticator. We need to provide a name for our application to display it on our users’ devices. To do that, let’s add an environment variable called TWO_FACTOR_AUTHENTICATION_APP_NAME.

twoFactorAuthentication.service.ts
1import { Injectable } from '@nestjs/common';
2import { authenticator } from 'otplib';
3import User from '../../users/user.entity';
4import { UsersService } from '../../users/users.service';
5 
6@Injectable()
7export class TwoFactorAuthenticationService {
8  constructor (
9    private readonly usersService: UsersService,
10    private readonly configService: ConfigService
11  ) {}
12 
13  public async generateTwoFactorAuthenticationSecret(user: User) {
14    const secret = authenticator.generateSecret();
15 
16    const otpauthUrl = authenticator.keyuri(user.email, this.configService.get('TWO_FACTOR_AUTHENTICATION_APP_NAME'), secret);
17 
18    await this.usersService.setTwoFactorAuthenticationSecret(secret, user.id);
19 
20    return {
21      secret,
22      otpauthUrl
23    }
24  }
25}

An essential thing above is that we save the generated secret in the database. We will need it later.

user.entity.ts
1import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
2 
3@Entity()
4class User {
5  @PrimaryGeneratedColumn()
6  public id: number;
7 
8  @Column({ nullable: true })
9  public twoFactorAuthenticationSecret?: string;
10 
11  // ...
12}
13 
14export default User;
user.service.ts
1import { Injectable } from '@nestjs/common';
2import { InjectRepository } from '@nestjs/typeorm';
3import { Repository } from 'typeorm';
4import User from './user.entity';
5 
6@Injectable()
7export class UsersService {
8  constructor(
9    @InjectRepository(User)
10    private usersRepository: Repository<User>,
11  ) {}
12 
13  async setTwoFactorAuthenticationSecret(secret: string, userId: number) {
14    return this.usersRepository.update(userId, {
15      twoFactorAuthenticationSecret: secret
16    });
17  }
18 
19  // ...
20}

We also need to serve the otpauth URL to the user in a QR code. To do that, we can use the qrcode library.

twoFactorAuthentication.service.ts
1import { Injectable } from '@nestjs/common';
2import { toFileStream } from 'qrcode';
3import { Response } from 'express';
4 
5@Injectable()
6export class TwoFactorAuthenticationService {
7  // ...
8 
9  public async pipeQrCodeStream(stream: Response, otpauthUrl: string) {
10    return toFileStream(stream, otpauthUrl);
11  }
12}

Once we have all of the above, we can create a controller that uses this logic.

twoFactorAuthentication.controller.ts
1import {
2  ClassSerializerInterceptor,
3  Controller,
4  Header,
5  Post,
6  UseInterceptors,
7  Res,
8  UseGuards,
9  Req,
10} from '@nestjs/common';
11import { TwoFactorAuthenticationService } from './twoFactorAuthentication.service';
12import { Response } from 'express';
13import JwtAuthenticationGuard from '../jwt-authentication.guard';
14import RequestWithUser from '../requestWithUser.interface';
15 
16@Controller('2fa')
17@UseInterceptors(ClassSerializerInterceptor)
18export class TwoFactorAuthenticationController {
19  constructor(
20    private readonly twoFactorAuthenticationService: TwoFactorAuthenticationService,
21  ) {}
22 
23  @Post('generate')
24  @UseGuards(JwtAuthenticationGuard)
25  async register(@Res() response: Response, @Req() request: RequestWithUser) {
26    const { otpauthUrl } = await this.twoFactorAuthenticationService.generateTwoFactorAuthenticationSecret(request.user);
27 
28    return this.twoFactorAuthenticationService.pipeQrCodeStream(response, otpauthUrl);
29  }
30}
Above, we use the RequestWithUser interface and require the user to be authenticated. If you want to know more about it, check out API with NestJS #3. Authenticating users with bcrypt, Passport, JWT, and cookies

Calling the above endpoint results in the API returning a QR code. Our users can now scan it with the Google Authenticator application.

Turning on the two-factor authentication

So far, our users can generate a QR code and scan it with the Google Authenticator application. Now we need to implement the logic of turning on the two-factor authentication. It requires the user to provide the code from the Authenticator application. We then need to validate it against the secret string we’ve saved in the database while generating a QR code.

We need to save the information about the two-factor authentication being turned on in the database. To do that, let’s expand the entity of the user.

user.entity.ts
1import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
2 
3@Entity()
4class User {
5  @PrimaryGeneratedColumn()
6  public id: number;
7 
8  @Column({ default: false })
9  public isTwoFactorAuthenticationEnabled: boolean;
10 
11  // ...
12}
13 
14export default User;

We also need to create a method in the service to set this flag to true.

users.service.ts
1import { Injectable } from '@nestjs/common';
2import { InjectRepository } from '@nestjs/typeorm';
3import { Repository } from 'typeorm';
4import User from './user.entity';
5 
6@Injectable()
7export class UsersService {
8  constructor(
9    @InjectRepository(User)
10    private usersRepository: Repository<User>,
11  ) {}
12  
13  async turnOnTwoFactorAuthentication(userId: number) {
14    return this.usersRepository.update(userId, {
15      isTwoFactorAuthenticationEnabled: true
16    });
17  }
18 
19  // ...
20}

The most crucial part here is verifying the user’s code against the secret saved in the database. Let’s do that in the TwoFactorAuthenticationService:

twoFactorAuthentication.service.ts
1import { Injectable } from '@nestjs/common';
2import { authenticator } from 'otplib';
3import User from '../../users/user.entity';
4 
5@Injectable()
6export class TwoFactorAuthenticationService {
7  public isTwoFactorAuthenticationCodeValid(twoFactorAuthenticationCode: string, user: User) {
8    return authenticator.verify({
9      token: twoFactorAuthenticationCode,
10      secret: user.twoFactorAuthenticationSecret
11    })
12  }
13 
14  // ...
15}

Once we’ve got all of the above ready to go, we can use this logic in our controller:

twoFactorAuthentication.controller.ts
1import {
2  ClassSerializerInterceptor,
3  Controller,
4  Post,
5  UseInterceptors,
6  UseGuards,
7  Req,
8  Body,
9  UnauthorizedException, HttpCode,
10} from '@nestjs/common';
11import { TwoFactorAuthenticationService } from './twoFactorAuthentication.service';
12import JwtAuthenticationGuard from '../jwt-authentication.guard';
13import RequestWithUser from '../requestWithUser.interface';
14import { TurnOnTwoFactorAuthenticationDto } from './dto/turnOnTwoFactorAuthentication.dto';
15import { UsersService } from '../../users/users.service';
16 
17@Controller('2fa')
18@UseInterceptors(ClassSerializerInterceptor)
19export class TwoFactorAuthenticationController {
20  constructor(
21    private readonly twoFactorAuthenticationService: TwoFactorAuthenticationService,
22    private readonly usersService: UsersService
23  ) {}
24  
25  @Post('turn-on')
26  @HttpCode(200)
27  @UseGuards(JwtAuthenticationGuard)
28  async turnOnTwoFactorAuthentication(
29    @Req() request: RequestWithUser,
30    @Body() { twoFactorAuthenticationCode } : TwoFactorAuthenticationCodeDto
31  ) {
32    const isCodeValid = this.twoFactorAuthenticationService.isTwoFactorAuthenticationCodeValid(
33      twoFactorAuthenticationCode, request.user
34    );
35    if (!isCodeValid) {
36      throw new UnauthorizedException('Wrong authentication code');
37    }
38    await this.usersService.turnOnTwoFactorAuthentication(request.user.id);
39  }
40 
41  // ...
42}
Above, we create a Data Transfer Object with the twoFactorAuthenticationCode property. If you want to know more about how to create DTOs with validation, check out API with NestJS #4. Error handling and data validation

Now, the user can generate a QR code, save it in the Google Authenticator application, and send a valid code to the /2fa/turn-on endpoint. If that’s the case, we acknowledge that the two-factor authentication has been saved.

Logging in with two-factor authentication

The next step in our two-factor authentication flow is allowing the user to log in. In this article, we implement the following approach:

  • the user logs in using the email and the password, and we respond with a JWT token,
  • if the 2FA is turned off, we give full access to the user,
  • if the 2FA is turned on, we provide the access just to the /2fa/authenticate endpoint,
  • the user looks up the Authenticator application code and sends it to the /2fa/authenticate endpoint; we respond with a new JWT token with full access.

The first missing part of the above flow is the route that allows the user to send the two-factor authentication code.

twoFactorAuthentication.controller.ts
1import {
2  ClassSerializerInterceptor,
3  Controller,
4  Post,
5  UseInterceptors,
6  UseGuards,
7  Req,
8  Body,
9  UnauthorizedException, HttpCode,
10} from '@nestjs/common';
11import { TwoFactorAuthenticationService } from './twoFactorAuthentication.service';
12import JwtAuthenticationGuard from '../jwt-authentication.guard';
13import RequestWithUser from '../requestWithUser.interface';
14import { UsersService } from '../../users/users.service';
15import { TwoFactorAuthenticationCodeDto } from './dto/twoFactorAuthenticationCode.dto';
16import { AuthenticationService } from '../authentication.service';
17 
18@Controller('2fa')
19@UseInterceptors(ClassSerializerInterceptor)
20export class TwoFactorAuthenticationController {
21  constructor(
22    private readonly twoFactorAuthenticationService: TwoFactorAuthenticationService,
23    private readonly usersService: UsersService,
24    private readonly authenticationService: AuthenticationService
25  ) {}
26 
27  @Post('authenticate')
28  @HttpCode(200)
29  @UseGuards(JwtAuthenticationGuard)
30  async authenticate(
31    @Req() request: RequestWithUser,
32    @Body() { twoFactorAuthenticationCode } : TwoFactorAuthenticationCodeDto
33  ) {
34    const isCodeValid = this.twoFactorAuthenticationService.isTwoFactorAuthenticationCodeValid(
35      twoFactorAuthenticationCode, request.user
36    );
37    if (!isCodeValid) {
38      throw new UnauthorizedException('Wrong authentication code');
39    }
40 
41    const accessTokenCookie = this.authenticationService.getCookieWithJwtAccessToken(request.user.id, true);
42 
43    request.res.setHeader('Set-Cookie', [accessTokenCookie]);
44 
45    return request.user;
46  }
47 
48  // ...
49}

A crucial thing to notice above is that we’ve added an argument to the getCookieWithJwtAccessToken method.

authentication.service.ts
1import { Injectable } from '@nestjs/common';
2import { UsersService } from '../users/users.service';
3import { JwtService } from '@nestjs/jwt';
4import { ConfigService } from '@nestjs/config';
5import TokenPayload from './tokenPayload.interface';
6 
7@Injectable()
8export class AuthenticationService {
9  constructor(
10    private readonly usersService: UsersService,
11    private readonly jwtService: JwtService,
12    private readonly configService: ConfigService
13  ) {}
14 
15  public getCookieWithJwtAccessToken(userId: number, isSecondFactorAuthenticated = false) {
16    const payload: TokenPayload = { userId, isSecondFactorAuthenticated };
17    const token = this.jwtService.sign(payload, {
18      secret: this.configService.get('JWT_ACCESS_TOKEN_SECRET'),
19      expiresIn: `${this.configService.get('JWT_ACCESS_TOKEN_EXPIRATION_TIME')}s`
20    });
21    return `Authentication=${token}; HttpOnly; Path=/; Max-Age=${this.configService.get('JWT_ACCESS_TOKEN_EXPIRATION_TIME')}`;
22  }
23 
24  // ...
25}

Thanks to setting the isSecondFactorAuthenticated property, we can now distinguish between tokens created with and without two-factor authentication.

Checking if the user is authenticated with the second factor

Since we can authenticate users using the second factor, we now should check it before we grant them access to various resources. In the third part of this series, we’ve created a Passport strategy that parses the cookie and the JWT token. Let’s expand on this idea and create a strategy and a guard that check if the two-factor authentication was successful.

authentication.service.ts
1import { ExtractJwt, Strategy } from 'passport-jwt';
2import { PassportStrategy } from '@nestjs/passport';
3import { Injectable } from '@nestjs/common';
4import { ConfigService } from '@nestjs/config';
5import { Request } from 'express';
6import { UsersService } from '../users/users.service';
7import TokenPayload from './tokenPayload.interface';
8 
9@Injectable()
10export class JwtTwoFactorStrategy extends PassportStrategy(
11  Strategy,
12  'jwt-two-factor'
13) {
14  constructor(
15    private readonly configService: ConfigService,
16    private readonly userService: UsersService,
17  ) {
18    super({
19      jwtFromRequest: ExtractJwt.fromExtractors([(request: Request) => {
20        return request?.cookies?.Authentication;
21      }]),
22      secretOrKey: configService.get('JWT_ACCESS_TOKEN_SECRET')
23    });
24  }
25 
26  async validate(payload: TokenPayload) {
27    const user = await this.userService.getById(payload.userId);
28    if (!user.isTwoFactorAuthenticationEnabled) {
29      return user;
30    }
31    if (payload.isSecondFactorAuthenticated) {
32      return user;
33    }
34  }
35}

Above, the crucial logic happens in the validate method. If the two-factor authentication is not enabled for the current user, we don’t check if the token contains the isSecondFactorAuthenticated flag.

To use the above strategy, we need to create a guard:

jwt-two-factor.guard.ts
1import { Injectable } from '@nestjs/common';
2import { AuthGuard } from '@nestjs/passport';
3 
4@Injectable()
5export default class JwtTwoFactorGuard extends AuthGuard('jwt-two-factor') {}

We can now use it on endpoints that we want to protect with two-factor authentication.

posts.controller.ts
1import {
2  Body,
3  Controller,
4  Post,
5  UseGuards,
6  Req,
7  UseInterceptors,
8  ClassSerializerInterceptor,
9} from '@nestjs/common';
10import PostsService from './posts.service';
11import CreatePostDto from './dto/createPost.dto';
12import RequestWithUser from '../authentication/requestWithUser.interface';
13import JwtTwoFactorGuard from '../authentication/jwt-two-factor.guard';
14 
15@Controller('posts')
16@UseInterceptors(ClassSerializerInterceptor)
17export default class PostsController {
18  constructor(
19    private readonly postsService: PostsService
20  ) {}
21  
22  @Post()
23  @UseGuards(JwtTwoFactorGuard)
24  async createPost(@Body() post: CreatePostDto, @Req() req: RequestWithUser) {
25    return this.postsService.createPost(post, req.user);
26  }
27 
28  // ...
29}
It is crucial not to use the JwtTwoFactorGuard on the /2fa/authenticate endpoint, because we need users to access it before authenticating with the second factor.

Modifying the basic logging-in logic

The last step is modifying the regular /authentication/log-in endpoint. It always responds with the user’s data, even if we didn’t perform two-factor authentication yet. Let’s change it.

authentication.controller.ts
1import {
2  Req,
3  Controller,
4  HttpCode,
5  Post,
6  UseGuards,
7  ClassSerializerInterceptor,
8  UseInterceptors,
9} from '@nestjs/common';
10import { AuthenticationService } from './authentication.service';
11import RequestWithUser from './requestWithUser.interface';
12import { LocalAuthenticationGuard } from './localAuthentication.guard';
13import { UsersService } from '../users/users.service';
14 
15@Controller('authentication')
16@UseInterceptors(ClassSerializerInterceptor)
17export class AuthenticationController {
18  constructor(
19    private readonly authenticationService: AuthenticationService,
20    private readonly usersService: UsersService
21  ) {}
22 
23  @HttpCode(200)
24  @UseGuards(LocalAuthenticationGuard)
25  @Post('log-in')
26  async logIn(@Req() request: RequestWithUser) {
27    const { user } = request;
28    const accessTokenCookie = this.authenticationService.getCookieWithJwtAccessToken(user.id);
29    const {
30      cookie: refreshTokenCookie,
31      token: refreshToken
32    } = this.authenticationService.getCookieWithJwtRefreshToken(user.id);
33 
34    await this.usersService.setCurrentRefreshToken(refreshToken, user.id);
35 
36    request.res.setHeader('Set-Cookie', [accessTokenCookie, refreshTokenCookie]);
37 
38    if (user.isTwoFactorAuthenticationEnabled) {
39      return;
40    }
41 
42    return user;
43  }
44 
45  // ...
46}

Summary

In this article, we’ve implemented a fully working two-factor authentication flow. Our users can now generate a unique, secret key, and we present them with a QR image. After turning on the two-factor authentication, we validate upcoming requests.

The above approach might benefit from additional features. An example would be support for backup codes that the user could use in case of losing the phone. I encourage you to improve the flow presented in this article.