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:
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();
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.
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.
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
.
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.
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>
<nuxt-img src="{{ code.svg }}" />
</div>
Additionally, you can also send the recovery codes with the same response and show the codes.
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.
Sign up for a 5-day free trial of Cleavr Pro. No credit card required until you decide to subscribe.
Sign up for free