<-- Back to The Cleavr Slice

23 September 2022

blog

2FA

Two-Factor Authentication (2FA) implementation in AdonisJS

In this blog, we’ll be discussing Two-Factor Authentication (2FA), and show you how we implemented 2FA in Cleavr using a demo project with the AdonisJS web framework.

AdonisJS is one of the primary technologies used in Cleavr and we've been a proud supporter and sponsor. I hope this blog will be beneficial to people who want to implement 2FA with their own AdonisJS projects.

Two-factor Authentication adds an extra layer of security to your online accounts by requiring an additional login credential in addition to a username and password. Instead of immediately gaining access after entering a username and password, users will be required to provide another piece of information, such as a PIN that’s sent through SMS or by using tokens retrieved from 2FA applications such as Google Authenticator.

As a demonstration, I’ve created a demo application in which you can enable 2FA for an account. You can view the complete source code of the application here

The following are the list of activities that we’ll be doing throughout the blog:

Migrating the Database

First, we need to store the secret and recovery codes of a user enabling 2FA. We’ll create a database migration to add two_factor_secret and two_factor_recovery_codes properties to the users table.

table.text("two_factor_secret").nullable();
table.text("two_factor_recovery_codes").nullable();

Updating Model

We don’t want to store these credentials in plain text on our database and don’t want to serialize the model properties. Update the User model with the following block of the code. Models are located under app/Models.

@column({
   serializeAs: null,
   consume: (value: string) => (value ? JSON.parse(Encryption.decrypt(value) ?? '{}') : null),
   prepare: (value: string) => Encryption.encrypt(JSON.stringify(value)),
 })
 public twoFactorSecret?: string

 @column({
   serializeAs: null,
   consume: (value: string) => (value ? JSON.parse(Encryption.decrypt(value) ?? '[]') : []),
   prepare: (value: string[]) => Encryption.encrypt(JSON.stringify(value)),
 })
 public twoFactorRecoveryCodes?: string[]

serializeAs: null removes the model properties from the serialized output.

Encryption is a module provided by AdonisJS which helps with the encryption and decryption of previously encrypted values.

Creating a User Controller to enable 2FA

Now, let’s create a controller that handles enabling two-factor authentication.

export default class UserController {
  public async enableTwoFactorAuthentication({ auth, view }) {
  const user = auth?.user

  user.twoFactorSecret = TwoFactorAuthProvider.generateSecret(user)
  user.twoFactorRecoveryCodes = await TwoFactorAuthProvider.generateRecoveryCodes()
  await user.save()
}

Here we are generating secret and recovery codes for the user that’s enabling 2FA and storing them to our database.

Creating an Auth Provider class

I’ve created a class TwoFactorAuthProvider where the logic related to 2FA is located. Lets see how the generateSecret and generateRecoveryCode methods look like:

const twoFactor = require(‘node-2fa’)
import cryptoRandomString from 'crypto-random-string'

class TwoFactorAuthProvider {
  private issuer = Config.get('twoFactorAuthConfig.app.name') || 'adonisjs-2fa'

  public generateSecret(user: User) { const secret = twoFactor.generateSecret({ name: this.issuer, account: user.email })
  return secret.secret }

  public async generateRecoveryCodes() {
    const recoveryCodeLimit: number = 8
    const codes: string[] = []
    for (let i = 0; i < recoveryCodeLimit; i++) {
      const recoveryCode: string = `${await this.secureRandomString()}-${await this.secureRandomString()}`
      codes.push(recoveryCode)
    }
    return codes
  }

  public async secureRandomString() {
   return cryptoRandomString.async({ length: 10, type: 'hex' })
  }
}

To create the secret, we’re using the generateSecret method provided by node-2fa package. You can install the package for your project by running the npm command npm install node-2fa --save.

generateSecret will generate a user-specific 32-character secret. We’re providing the name of the app and the user’s email as parameters for the function. This secret key will be used to verify whether the token provided by the user during authentication is valid or not.

We also generated recovery codes which can be used in case we’re unable to retrieve tokens from 2FA applications. We assign the user a list of recovery codes and each code can be used only once during the authentication process. The recovery codes are random strings generated using the cryptoRandomString library which can be installed using npm install crypto-random-string --save.

Generating the QR Code

Once we generate the secret and recovery codes, we should provide users with a way to add the account to an authenticator application such as Google Authenticator. To easily set up an account with such authenticator applications, we’ll be providing them with a QR code. Authenticator applications generate a time-based one-time token, which is provided to the app or site during the login process when you’ve enabled 2FA for your account.

We’ll be using qrcode NPM package for generating a QR Code. It can be installed using npm i --save-dev @types/qrcode for TypeScript projects or npm install qrcode --save.

const twoFactor = require(‘node-2fa’)

class TwoFactorAuthProvider {
  // ...
  public async generateQrCode(user: User) {
    const appName = encodeURIComponent(this.issuer)
    const userName = encodeURIComponent(user.email)
    const query = `?secret=${user.twoFactorSecret}&issuer=${appName}`
    const url = `otpauth://totp/${appName}${userName}${query}`
    const svg = await QRCode.toDataURL(url)
    return { svg, url }
  }
}

Make sure that the issuer, email and secret are valid and match the values you’ve used while generating the secret. If they don’t match you may see a Key not recognized error while scanning the QR code.

Setting up an account on 2FA Applications

We need to return the QR code from our UserController’s enableTwoFactorAuthentication method so the frontend can show the QR to the user for setting up the account on 2FA applications.

export default class UserController {
  public async enableTwoFactorAuthentication({ auth, view }) {
    // …
    return view.render('pages/settings', {
      status: {
      type: 'success',
      message: 'Two factor authentication enabled.',
    },
    twoFactorEnabled: user.isTwoFactorEnabled,
    code: await TwoFactorAuthProvider.generateQrCode(user)
  })
}

In the UI, you can show the QR code using the HTML image tag.

<div>
  <img src="{{ code.svg }}" />
</div>

Additionally, you can also send the recovery codes with the same response and show the codes.

Two Factor Challenge

Now the most important part of 2FA begins. We need to ask for a one-time token with the users that have 2FA enabled when they log in.

First, we’ll verify whether the credentials supplied by users are valid or not. If the credentials are valid, we proceed to check whether 2FA is enabled or not. In the case where 2FA is enabled, we redirect users to the Two Factor Challenge page where they’ll be asked to provide a one-time token or a recovery code. In other cases, we log in the users directly.

export default class AuthController {
  public async login({ request, response, auth, view, session }) {
    const { email, password } = request.only(["email", "password"]);
    const user = await auth.use("web").verifyCredentials(email, password);

    if (user.isTwoFactorEnabled) {
      session.put("login.id", user.id);
      return view.render("pages/two-factor-challenge");
    }

    session.forget("login.id");
    session.regenerate();
    await auth.login(user);
    response.redirect("/");
  }
}

We’re storing the user ID in the session before redirecting the user to the two-factor challenge page so that we’re aware of the user who is trying to authenticate.

Once the user is on the two-factor challenge page, the user will be asked to enter either a token or a recovery code. Then, once the submit button is clicked we need to verify that either the token or recovery code is valid for the user or not.

export default class AuthController {
  // …
  public async twoFactorChallenge({ request, session, view, auth, response }) {
    const { code, recoveryCode } = request.only(["code", "recoveryCode"]);
    const user = await User.query()
      .where("id", session.get("login.id"))
      .first();

    session.forget("login.id");
    session.regenerate();

    if (code) {
      const isValid = await twoFactor.verifyToken(user.twoFactorSecret, code);
      if (isValid) {
        await auth.login(user);
        return response.redirect("/");
      }
    } else if (recoveryCode) {
      const codes = user?.twoFactorRecoveryCodes ?? [];
      if (codes.includes(recoveryCode)) {
        user.twoFactorRecoveryCodes = codes.filter(c => c !== recoveryCode);
        await user.save();
        await auth.login(user);
        return response.redirect("/");
      }
    }
  }
}

What goes here is, we receive the 2FA code provided by the user and find the user using the user ID stored in the session. We’ll be using the verifyToken method provided by node-2fa. It then checks if a time-based token matches a token from the secret key. In the case of recovery codes, we check whether the recovery code provided by the user exists in our system for the user or not, and then remove the recovery code once it has been used. Don’t forget to clear the session variables once the login process completes.

That's it! Your users can now enable 2FA for their accounts and enjoy the additional security it provides. The code used in this blog is available over on here. I hope you found this blog useful. Come back soon to the Cleavr Slice for some more helpful blog posts.

Take control of your servers and deployments. Without breaking a sweat.

Sign up for a 5-day free trial of Cleavr Pro. No credit card required until you decide to subscribe.

Sign up for free