July 22, 2019











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. f

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

1 npm install speakeasy @ types / speakeasy

authentication.service.ts

1 2 3 4 5 6 7 8 9 10 11 import * as speakeasy from 'speakeasy' ; function getTwoFactorAuthenticationCode ( ) { const secretCode = speakeasy . generateSecret ( { name : process . env . TWO_FACTOR_AUTHENTICATION_APP_NAME , } ) ; return { otpauthUrl : secretCode . otpauth_url , base32 : secretCode . base32 , } ; }

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.

1 npm 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

1 2 3 4 5 import * as QRCode from 'qrcode' ; function respondWithQRCode ( data : string , response : Response ) { QRCode . toFileStream ( response , data ) ; }

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

authentication.controller.ts

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

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

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

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

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

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

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

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

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

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

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 function authMiddleware ( omitSecondFactor = false ) : RequestHandler { return async ( request : RequestWithUser , response : Response , next : NextFunction ) = > { const cookies = request . cookies ; if ( cookies && cookies . Authorization ) { const secret = process . env . JWT_SECRET ; try { const verificationResponse = jwt . verify ( cookies . Authorization , secret ) as DataStoredInToken ; const { _id : id , isSecondFactorAuthenticated } = verificationResponse ; const user = await userModel . findById ( id ) ; if ( user ) { if ( ! omitSecondFactor && user . isTwoFactorAuthenticationEnabled && ! isSecondFactorAuthenticated ) { next ( new WrongAuthenticationTokenException ( ) ) ; } else { request . user = user ; next ( ) ; } } else { next ( new WrongAuthenticationTokenException ( ) ) ; } } catch ( error ) { next ( new WrongAuthenticationTokenException ( ) ) ; } } else { next ( new AuthenticationTokenMissingException ( ) ) ; } } ; }

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

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

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.

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

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.