DEV Community

MongoDB Guests for MongoDB

Posted on

Advanced API Authentication Strategies with Node.js and MongoDB (Part 2)

Implementing MFA and TOTP with Node.js and MongoDB

This article was written by Moses Anumadu.

In part one (1) of this series, we implemented basic authentication. Users can register and also log in. In this section, we will take it a step further by adding multi-factor authentication (MFA) using a time-based one-time password (TOTP).

Basic authentication, which uses only a password and email, is arguably not secure enough for today's internet. Many users reuse their passwords, and attackers can exploit this vulnerability.

The core principle of multi-factor authentication is that it combines something the user knows with something the user owns to verify the user's identity. Something the User knows can be a password. Something the user owns can be a token sent to the user's device or email. The user needs to prove he knows the password and own the device where TOTP was sent.

If conditions are met, the user can be verified. With that said, let's get started on how to implement this in a Node.js and MongoDB application.

Pre-requisites

To follow along with this tutorial, ensure you have prior experience working with the following technologies:

Adding MFA with TOTP

Let's go over what the flow of our MFA implementation would look like. So think of it like this:
First, the authenticated user has to enable MFA in the app. Next, the server generates a shared secret. Next, the user would need to scan a QR code with their phone camera or any of the authenticator apps of their choice (Google Authenticator, Authy, Microsoft Authenticator, etc). Next, the authenticator app would store the secret; the next app generates a 6-digit code. Finally, the server would verify the code. If correct, the user is authenticated.

The important thing to note here is that both the server and the authenticator app share the same secret. Using the current time and that secret, both sides independently generate matching one-time codes.

Creating a Temporary Authentication Middleware

One more step before we proceed. JWT authentication will be implemented in part three (3) of this tutorial. Since we have not implemented it yet, but need a way to identify the logged-in user, we will identify the user using the request header. This is temporary and will be updated to use JWT in part three. For now, create tempAuth.js in src/middleware/ directory. Update the file with the code below

const User = require("../models/User");

const tempAuth = async (req, res, next) => {
  try {
    const email = req.headers["x-user-email"];

    if (!email) {
      return res.status(401).json({
        success: false,
        message: "Missing x-user-email header",
      });
    }

    const user = await User.findOne({ email });

    if (!user) {
      return res.status(401).json({
        success: false,
        message: "User not found",
      });
    }

    req.user = user;

    next();
  } catch (error) {
    console.error(error);

    return res.status(500).json({
      success: false,
      message: "Authentication middleware error",
    });
  }
};

module.exports = tempAuth;
Enter fullscreen mode Exit fullscreen mode

This middleware is only temporary and will later be replaced with proper JWT authentication.

Generating MFA Secrets and QR Codes

We now have a way to temporarily identify the user. Let's proceed to the MFA secrets and QR codes implementation. If you can recall, in the first part of this series, we installed all the NPM packages we would need. Two of them were Otplib and Qrcode. It is the package of choice for this implementation. If you do not have them installed already, you can install them using the NPM command below:

npm install otplib
Enter fullscreen mode Exit fullscreen mode

And

npm install --save qrcode
Enter fullscreen mode Exit fullscreen mode

Once you confirm you have them installed, let's import and use them in the authController.js. Open src/controllers/authController.js. Add the code below to the top section of the page.

const QRCode = require("qrcode");

const {
  generateSecret,
  generateURI,
  verify,
} = require("otplib");

Enter fullscreen mode Exit fullscreen mode

Next, add the code below after the login method on the page

const setupMFA = async (req, res) => {
  try {
    const user = req.user;

    // Generate shared secret
    const secret = generateSecret();

    // Generate OTP auth URL
    const otpAuthUrl = generateURI({
      label: user.email,
      issuer: "AdvancedAuthAPI",
      secret,
    });

    // Generate QR code image
    const qrCodeImageUrl =
      await QRCode.toDataURL(otpAuthUrl);

    // Store secret temporarily
    user.mfaSecret = secret;

    await user.save();

    return res.status(200).json({
      success: true,
      message: "MFA setup initialized",
      secret,
      qrCodeImageUrl,
    });
  } catch (error) {
    console.error(error);

    return res.status(500).json({
      success: false,
      message: "Failed to setup MFA",
    });
  }
};

module.exports = { register, login, setupMFA};
Enter fullscreen mode Exit fullscreen mode

The code is well commented. Regardless, let's still go over a few lines and what they do. This line generates the shared secret: const secret = generateSecret();

The generated secret is then embedded into a special OTP URI in the block of code below

const otpAuthUrl = generateURI({
  label: user.email,
  issuer: "AdvancedAuthAPI",
  secret,
});
Enter fullscreen mode Exit fullscreen mode

For the user to be able to scan, we converted the URI into a QR code using the qrcode package in the line of code below:

const qrCodeImageUrl =
await QRCode.toDataURL(otpAuthUrl);
Enter fullscreen mode Exit fullscreen mode

When a user scans the QR code with their authenticator app, the authenticator app securely stores the shared secret.

Verifying MFA Codes

We have completed the MFA setup functionality. Let's proceed to the verification functionality. We need another controller method to verify the 6-digit code generated by the authenticator app. This will have a different endpoint also. Let's proceed. Still in src/controllers/authController.js add the following verification controller method below setupMFA:

const verifyMFA = async (req, res) => {
  try {
    const user = req.user;

    const { token } = req.body;

    if (!token) {
      return res.status(400).json({
        success: false,
        message: "MFA token is required",
      });
    }

    const verification = await verify({
      token,
      secret: user.mfaSecret,
    });

    const isValid = verification?.valid === true;

    if (!isValid) {
      return res.status(401).json({
        success: false,
        message: "Invalid MFA code",
      });
    }

    user.mfaEnabled = true;

    await user.save();

    return res.status(200).json({
      success: true,
      message: "MFA enabled successfully",
    });
  } catch (error) {
    console.error(error);

    return res.status(500).json({
      success: false,
      message: "Failed to verify MFA",
    });
  }
};

module.exports = { register, login, setupMFA, verifyMFA };
Enter fullscreen mode Exit fullscreen mode

The token is passed along as req.body. When received in the code, it is verified using

const verification = await verify({
       token,
       secret: user.mfaSecret,
     });
Enter fullscreen mode Exit fullscreen mode

If the code is valid, the user is logged in, and MFA is enabled. Did you notice that the MFA is only enabled after a successful verification? user.mfaEnabled = true;. This was done to prevent a broken MFA setup, which can accidentally lock a user out of their account. This prevents broken MFA setups and accidental account lockouts.

Adding MFA Routes

We need to update our authRoutes.js to include routes for both the setupMFA and verifyMFA controller methods. To do this, open src/routes/authRoutes.js and replace the content of the page with the code below

const express = require("express");

const {
  register,
  login,
  setupMFA,
  verifyMFA,
} = require("../controllers/authController");

const tempAuth = require("../middleware/tempAuth");

const router = express.Router();

router.post("/register", register);
router.post("/login", login);
router.post("/mfa/setup", tempAuth, setupMFA);
router.post("/mfa/verify", tempAuth, verifyMFA);

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

Testing MFA Setup

In part one (1) of this series, we set up Ngrok for tunneling our local host to the internet and tested our endpoints using Postman. If you do not have that setup, follow the link to set up to follow along.

If your project is not running already, start it using the command below

npm run dev
Enter fullscreen mode Exit fullscreen mode

Then, send a POST request to the following endpoint:

POST /api/auth/mfa/setup
Enter fullscreen mode Exit fullscreen mode

Remember our temporary middleware, set the header value to the value below

Headers:
x-user-email: test_user@example.com
Enter fullscreen mode Exit fullscreen mode

The response should include a QR code image URL. Check out the images below for more details


Copy the URL from the response and paste it into a browser. You should see a QR code image, like what we have below

Next, scan the QR code using any authenticator app of your choice. After scanning, your authenticator app should begin generating 6-digit codes like the image below.

Testing MFA Verification

Let's proceed to test the MFA verification endpoint. Send a post request to the route below

POST /api/auth/mfa/verify
Enter fullscreen mode Exit fullscreen mode

Also, send the following header values:

x-user-email: test_user@example.com
Enter fullscreen mode Exit fullscreen mode

Then, the request body should contain the following:

{
  "token": "MFA_app_generated_code"
}
Enter fullscreen mode Exit fullscreen mode

Don't forget to replace the token placeholder in the request body with the correct code from your authenticator app.

The images below should throw more light on the steps.

Implementing the Half-Authenticated Flow

Our MFA setup and verification now work. However, it is completely independent of the login and sign-up. We have created routes for the MFA but have not linked it to the login and sign-up flow. What we have now gives a user full authentication once a correct email and password are provided. We need to change that. We need a partial authentication flow: this is what I mean, when a user provides a correct email and password, we need to partially identify the user, and yet, prompt for MFA. Only when MFA is completed can the user be fully authenticated.

To achieve this, we will issue a temporary JWT token to the user after a correct password and email are provided; at this state the user is not yet authenticated but can be identified with this JWT token. Then, when the MFA process is completed, only then will the user be fully authenticated.

When we are done, the flow should be: password verified, issue a temporary JWT token to identify the user, MFA code generated from the authenticator app, the user submits the OTP code, and finally, authentication is completed. Let's proceed.

Creating MFA Tokens

Let's write the code to generate the temporary JWT token for the MFA process. Create src/utils/tokenUtils.js the following file and update it with the code below

const jwt = require("jsonwebtoken");

const generateMFAToken = (user) => {
  return jwt.sign(
    {
      userId: user._id,
      type: "mfa",
    },
    process.env.JWT_ACCESS_SECRET,
    {
      expiresIn: process.env.MFA_TOKEN_EXPIRES_IN,
    }
  );
};

module.exports = {
  generateMFAToken,
};
Enter fullscreen mode Exit fullscreen mode

In the code above, we imported require("jsonwebtoken"); and used it to generate the JWT token. Token expiration and secret values come from the env file created earlier in part one of this series.

Updating the Login Flow

We need to update the login controller method to add the generated MFA JWT token. To continue, open src/controllers/authController.js and add the following.

First, add the import below to the top section of the page.

const jwt = require("jsonwebtoken");

const {
  generateMFAToken,
} = require("../utils/tokenUtils");
Enter fullscreen mode Exit fullscreen mode

Next, let's update the login controller with the code below. Paste the code just above the return statement.

// MFA enabled
if (user.mfaEnabled) {
  const mfaToken = generateMFAToken(user);

  return res.status(200).json({
    success: true,
    requiresMFA: true,
    mfaToken,
  });
}
Enter fullscreen mode Exit fullscreen mode

Now, users with MFA enabled no longer receive full authentication immediately after password verification. Instead, they receive a temporary MFA token.

Completing MFA Login

We need another controller method to process the MFA verification. Add the following controller below verifyMFA method:

const verifyMFALogin = async (req, res) => {
  try {
    const { token, mfaToken } = req.body;

    if (!token || !mfaToken) {
      return res.status(400).json({
        success: false,
        message:
          "Token and MFA token are required",
      });
    }

    // Verify temporary MFA token
    const decoded = jwt.verify(
      mfaToken,
      process.env.JWT_ACCESS_SECRET
    );

    if (decoded.type !== "mfa") {
      return res.status(401).json({
        success: false,
        message: "Invalid MFA session",
      });
    }

    const user = await User.findById(
      decoded.userId
    );

    if (!user || !user.mfaEnabled) {
      return res.status(401).json({
        success: false,
        message: "Invalid user",
      });
    }

    // Verify OTP
    const verification = await verify({
      token,
      secret: user.mfaSecret,
    });

    const isValid =
      verification?.valid === true;

    if (!isValid) {
      return res.status(401).json({
        success: false,
        message: "Invalid MFA code",
      });
    }

    return res.status(200).json({
      success: true,
      message: "MFA login successful",
    });
  } catch (error) {
    console.error(error);

    return res.status(401).json({
      success: false,
      message:
        "Invalid or expired MFA session",
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

Also, don't forget to update the export block with the code below.

module.exports = {
  register,
  login,
  setupMFA,
  verifyMFA,
  verifyMFALogin,
};
Enter fullscreen mode Exit fullscreen mode

Adding the MFA Login Verification Route

Let's create routes for the above changes. Open src/routes/authRoutes.js and update the page with the code below:

router.post(
  "/mfa/login/verify",
  verifyMFALogin
);
Enter fullscreen mode Exit fullscreen mode

Testing the Full MFA Login Flow

The first step is to log in normally. Send a Post request to the route below

POST /api/auth/login
Enter fullscreen mode Exit fullscreen mode

If MFA is enabled, the response should contain an mfaToken just like the image below:

Next, verify MFA by sending a Post request to the route below

POST /api/auth/mfa/login/verify
Enter fullscreen mode Exit fullscreen mode

The request should pass the code below in the body.
Request body:

{
  "token": "123456",
  "mfaToken": "..."
}
Enter fullscreen mode Exit fullscreen mode

After testing, your response should look similar to the image below.

Conclusion

In this section, we added an extra layer on top of the existing authentication system we built in part one. After entering the correct email and password, users must configure and verify MFA using TOTP. To achieve this, we implemented the following in the code:

  • login verification
  • TOTP-based MFA
  • QR code onboarding
  • half-authenticated login flows

We've gone halfway, but we're not done. In Part 3, we will take it a step further by adding

  • JWT access tokens
  • refresh tokens
  • MongoDB-backed sessions
  • refresh token rotation
  • protected routes
  • secure logout and session revocation

See you in part 3. Cheers.

Top comments (0)