TypeScript Express

Node.js Two-Factor Authentication

Marcin Wanago
ExpressJavaScriptNode.js

Identity theft is a serious issue nowadays. With so many accidents happening on the web, it is a great time to learn about providing an additional layer of security for our users. In this article, we go through the principles and implementation of Node.js Two-Factor Authentication (2FA). We do that by using Google Authenticator and a Node.js server.

The source code is available in the GitHub repository in the part-11 branch. Feel free to give it a star.

Implementing Node.js Two-Factor Authentication

With Two-Factor Authentication, the user needs to prove his identity in two ways. A straightforward example of that is using an ATM. You need a bank card – something you possess – and the PIN code – something you know. Another example is combining your regular password with a one-time code that your smartphone generates.

Generating a secret key

First, we need to create a secret key, unique for every user.

For effortless Node.js Two-Factor Authentication handling, we use the speakeasy library. Its first job is to generate a secret key for us.

1npm install speakeasy @types/speakeasy
authentication.service.ts
1import * as speakeasy from 'speakeasy';
2 
3function getTwoFactorAuthenticationCode() {
4  const secretCode = speakeasy.generateSecret({
5    name: process.env.TWO_FACTOR_AUTHENTICATION_APP_NAME,
6  });
7  return {
8    otpauthUrl : secretCode.otpauth_url,
9    base32: secretCode.base32,
10  };
11}
Here we add a new environment variable – it determines the name that will be visible in the Google Authenticator application.

There are two essential things that our  getTwoFactorAuthenticationCode returns. One of them is the secret code in the base32 format. We use it to validate the identity of the user later.

The second thing is  otpauth_url. It is a One Time Password Authentication (OTPA) compatible with Google Authenticator. We can use it to generate a Quick Response (QR) code that we display for the user.

Creating a QR image

Applications like the Google Authenticator allow users to add a page that they authenticate to either by manually entering a key, or scanning a QR code. The latter is way faster and is a standard right now. To generate QR images, we use a library called qrcode.

1npm install qrcode @types/qrcode

The most suitable function for us that it has is called  toFileStream. It writes the QR code to a writable stream. An example of such is the response object!

If you want to dive deeper, check out Writable streams, pipes, and the process streams
authentication.service.ts
1import * as QRCode from 'qrcode';
2 
3function respondWithQRCode(data: string, response: Response) {
4  QRCode.toFileStream(response, data);
5}

Once we have all of the above, we can create our new endpoint in the authentication controller.

authentication.controller.ts
1private generateTwoFactorAuthenticationCode = async (
2  request: RequestWithUser,
3  response: express.Response,
4) => {
5  const user = request.user;
6  const {
7    otpauthUrl,
8    base32,
9  } = this.authenticationService.getTwoFactorAuthenticationCode();
10  await this.user.findByIdAndUpdate(user._id, {
11    twoFactorAuthenticationCode: base32,
12  });
13  this.authenticationService.respondWithQRCode(otpauthUrl, response);
14}
15 
16private initializeRoutes() {
17  this.router.post(`${this.path}/2fa/generate`, authMiddleware(), this.generateTwoFactorAuthenticationCode);
18}

As you can see, we also save the generated code in the database. It later comes in handy when turning on Two-Factor Authentication. Please note that a user needs to be logged in for it to work.

Since we have the generated code, we can use the Google Authenticator now:

We now have a fully functional workflow of generating a secret code and presenting it to the user!

Turning on Node.js Two-Factor Authentication

Currently, we only generate secret codes, but we haven’t yet turned on the Node.js Two-Factor Authentication for a user. For it to happen, we need a separate endpoint that the user sends his first verification code to. To begin, we need a function that validates the upcoming verification code.

authentication.service.ts
1public verifyTwoFactorAuthenticationCode(twoFactorAuthenticationCode: string, user: User) {
2  return speakeasy.totp.verify({
3    secret: user.twoFactorAuthenticationCode,
4    encoding: 'base32',
5    token: twoFactorAuthenticationCode,
6  });
7}

The  speakeasy.totp.verify method verifies our Time-based One-time Password (TOTP) that user got from the Google Authenticator app against the secret code that we generated and saved in the database previously. Once we got that, we can create an endpoint that turns on the Two-Factor Authentication.

authentication.controller.ts
1private turnOnTwoFactorAuthentication = async (
2  request: RequestWithUser,
3  response: express.Response,
4  next: express.NextFunction,
5) => {
6  const { twoFactorAuthenticationCode } = request.body;
7  const user = request.user;
8  const isCodeValid = await this.authenticationService.verifyTwoFactorAuthenticationCode(
9    twoFactorAuthenticationCode, user,
10  );
11  if (isCodeValid) {
12    await this.user.findByIdAndUpdate(user._id, {
13      isTwoFactorAuthenticationEnabled: true,
14    });
15    response.send(200);
16  } else {
17    next(new WrongAuthenticationTokenException());
18  }
19}
20 
21private initializeRoutes() {
22  this.router.post(
23    `${this.path}/2fa/turn-on`,
24    validationMiddleware(TwoFactorAuthenticationDto),
25    authMiddleware(),
26    this.turnOnTwoFactorAuthentication,
27  );
28}
If you want to know how the validationMiddleware works, check out Error handling and validating incoming data

In the  turnOnTwoFactorAuthentication function, we check if the provided code is valid. If that’s the case, we enable the Two-Factor Authentication by setting the isTwoFactorAuthenticationEnabled flag to true.

In this part of the series, we’ve made some minor change to the User model. You can look it up in the repository.

Logging in using our Node.js Two-Factor Authentication

The last part is logging in and authenticating using the Node.js Two-Factor Authentication. To implement it, we use the  verifyTwoFactorAuthenticationCode function again. To make it more clear, let’s review the whole flow of authentication:

  • The user attempts to log in using his email and a valid password, and we give him a JWT token. If he doesn’t have the 2FA turned on, this gives him full access. If he does have the 2FA turned on, we provide him with access just to the  /2fa/authenticate endpoint.
  • The user sends a valid code to the  /2fa/authenticate endpoint and is given a new JWT token with full access

That being said, let’s create a new route that allows the user to authenticate using a JWT token.

authentication.controller.ts
1private secondFactorAuthentication = async (
2  request: RequestWithUser,
3  response: express.Response,
4  next: express.NextFunction,
5) => {
6  const { twoFactorAuthenticationCode } = request.body;
7  const user = request.user;
8  const isCodeValid = await this.authenticationService.verifyTwoFactorAuthenticationCode(
9    twoFactorAuthenticationCode, user,
10  );
11  if (isCodeValid) {
12    const tokenData = this.authenticationService.createToken(user, true);
13    response.setHeader('Set-Cookie', [this.createCookie(tokenData)]);
14    response.send({
15      ...user.toObject(),
16      password: undefined,
17      twoFactorAuthenticationCode: undefined
18    });
19  } else {
20    next(new WrongAuthenticationTokenException());
21  }
22}

In the code above, we validate the upcoming  twoFactorAuthenticationCode. If it is valid, we create and send back a new token. We respond with the user details, excluding the password and the Two-Factor Authentication code. To do this, we modify the  createToken function:

authentication.service.ts
1public createToken(user: User, isSecondFactorAuthenticated = false): TokenData {
2  const expiresIn = 60 * 60; // an hour
3  const secret = process.env.JWT_SECRET;
4  const dataStoredInToken: DataStoredInToken = {
5    isSecondFactorAuthenticated,
6    _id: user._id,
7  };
8  return {
9    expiresIn,
10    token: jwt.sign(dataStoredInToken, secret, { expiresIn }),
11  };
12}

Now the JWT token also carries the information about the Two-Factor Authentication. The only thing left to do is to alter the authMiddleware.

The authMiddleware

We need the authMiddleware to check the isSecondFactorAuthenticated flag and throw an error if it is set to false when the user has 2FA turned on. Also, since there is one endpoint that should work even with the 2FA turned off, we need an option to omit it.

auth.middleware.ts
1function authMiddleware(omitSecondFactor = false): RequestHandler {
2  return async (request: RequestWithUser, response: Response, next: NextFunction) => {
3    const cookies = request.cookies;
4    if (cookies && cookies.Authorization) {
5      const secret = process.env.JWT_SECRET;
6      try {
7        const verificationResponse = jwt.verify(cookies.Authorization, secret) as DataStoredInToken;
8        const { _id: id, isSecondFactorAuthenticated } = verificationResponse;
9        const user = await userModel.findById(id);
10        if (user) {
11          if (!omitSecondFactor && user.isTwoFactorAuthenticationEnabled && !isSecondFactorAuthenticated) {
12            next(new WrongAuthenticationTokenException());
13          } else {
14            request.user = user;
15            next();
16          }
17        } else {
18          next(new WrongAuthenticationTokenException());
19        }
20      } catch (error) {
21        next(new WrongAuthenticationTokenException());
22      }
23    } else {
24      next(new AuthenticationTokenMissingException());
25    }
26  };
27}
For a step-by-step explanation of authentication with the email and the password, check out Registering users and authenticating with JWT

In the code above, we demand the  isSecondFactorAuthenticated to be true, if the user has the Two-Factor Authentication enabled and the  omitSecondFactor flag isn’t set to false.

Now we can use the authMiddleware with the  omitSecondFactor flag for the  /auth/2fa/authenticate endpoint.

authentication.controller.ts
1private initializeRoutes() {
2  this.router.post(
3    `${this.path}/2fa/authenticate`,
4    validationMiddleware(TwoFactorAuthenticationDto),
5    authMiddleware(true),
6    this.secondFactorAuthentication,
7  );
8}

Modify the basic logging in logic

So far, the  /auth/login acts the same way regardless of the 2FA being turned on or not. Let’s modify it by responding just with the  isTwoFactorAuthenticationEnabled flag if it is turned on. Thanks to that, we don’t give the details of a user just yet. We also avoid sending the  twoFactorAuthenticationCode.

1private loggingIn = async (request: express.Request, response: express.Response, next: express.NextFunction) => {
2  const logInData: LogInDto = request.body;
3  const user = await this.user.findOne({ email: logInData.email });
4  if (user) {
5    const isPasswordMatching = await bcrypt.compare(logInData.password, user.password);
6    if (isPasswordMatching) {
7      user.password = undefined;
8      user.twoFactorAuthenticationCode = undefined;
9      const tokenData = this.authenticationService.createToken(user);
10      response.setHeader('Set-Cookie', [this.createCookie(tokenData)]);
11      if (user.isTwoFactorAuthenticationEnabled) {
12        response.send({
13          isTwoFactorAuthenticationEnabled: true,
14        });
15      } else {
16        response.send(user);
17      }
18    } else {
19      next(new WrongCredentialsException());
20    }
21  } else {
22    next(new WrongCredentialsException());
23  }
24}

Summary

By doing all of the above, we set up a basic flow for the Node.js Two-Factor Authentication. We implement a way to generate a secret key and a QR image, turn on the Two-Factor Authentication, and validate upcoming requests. It might use some tweaks, like additional error handling, and a way for the users to deal with a lost device. This article covers just one way to implement Node.js 2FA. Feel free to change the flow and implement additional features, if needed.