Implementing Two-Factor Authentication (2FA) in Node.js with TOTP and Google Authenticator

November 2, 2024 (2w ago)

Implementing Two-Factor Authentication (2FA) in Node.js with TOTP and Google Authenticator

Two-Factor Authentication (2FA) is an extra layer of security that requires not only a password and username but also something that only the user has on them, typically a one-time code generated by an app like Google Authenticator. This guide walks you through implementing 2FA in a Node.js application using TOTP (Time-based One-Time Password), Google Authenticator, and otplib.


Overview of Two-Factor Authentication (2FA)

2FA enhances security by requiring users to provide a time-based code from a separate device, typically their phone, in addition to their password. This makes it much harder for an attacker to gain access to an account, even if they have the user's password.

How TOTP Works

  1. TOTP Secret: A unique secret key is generated for each user and stored securely.
  2. Authenticator App: The user adds this secret to an app like Google Authenticator, which generates a new code every 30 seconds.
  3. Code Verification: The user provides the current code along with their login details, which the server verifies to complete the login process.

Tools Required

We’ll use the otplib library to generate and verify TOTP codes and qrcode to generate QR codes that can be scanned with Google Authenticator.


Setting Up the Project

We’ll build upon a basic Node.js setup with otplib and qrcode.

Step 1: Install the Required Dependencies

If you’re starting a new project, initialize it and install the necessary packages.

mkdir two-factor-auth
cd two-factor-auth
npm init -y
npm install express mongoose jsonwebtoken bcryptjs dotenv otplib qrcode

Configuring Environment Variables

Create a .env file to store environment variables, including the JWT secret and MongoDB URI.

MONGODB_URI=mongodb://localhost:27017/two_factor_auth
JWT_SECRET=your_jwt_secret
PORT=5000

Setting Up the User Model with 2FA Fields

Modify the User model to include fields for storing the 2FA secret and the 2FA enabled status.

models/User.js

const mongoose = require("mongoose");
const bcrypt = require("bcryptjs");
 
const userSchema = new mongoose.Schema({
  username: { type: String, required: true, unique: true },
  email: { type: String, required: true, unique: true },
  password: { type: String, required: true },
  twoFactorEnabled: { type: Boolean, default: false }, // Indicates if 2FA is enabled
  twoFactorSecret: { type: String }, // Stores the TOTP secret for 2FA
});
 
// Hash password before saving
userSchema.pre("save", async function (next) {
  if (this.isModified("password")) {
    this.password = await bcrypt.hash(this.password, 10);
  }
  next();
});
 
module.exports = mongoose.model("User", userSchema);

Setting Up 2FA Registration and Verification Routes

We’ll create routes to enable 2FA for users, including generating a TOTP secret and verifying TOTP codes.

Step 1: Generate TOTP Secret and QR Code

In the auth.js route file, create a route to generate a TOTP secret and QR code for the user.

routes/auth.js

const express = require("express");
const { authenticator } = require("otplib");
const QRCode = require("qrcode");
const User = require("../models/User");
 
const router = express.Router();
 
// Generate TOTP Secret and QR Code
router.post("/enable-2fa", async (req, res) => {
  try {
    const user = await User.findById(req.user.id); // Ensure user is authenticated
    if (!user) return res.status(404).json({ message: "User not found" });
 
    // Generate a TOTP secret
    const secret = authenticator.generateSecret();
    user.twoFactorSecret = secret;
    await user.save();
 
    // Generate a QR code for the secret
    const otpauth = authenticator.keyuri(user.email, "MyApp", secret);
    const qrCodeUrl = await QRCode.toDataURL(otpauth);
 
    res.status(200).json({ qrCodeUrl, secret });
  } catch (error) {
    res.status(500).json({ message: error.message });
  }
});
 
module.exports = router;

In this code:

  1. TOTP Secret: A unique TOTP secret is generated for the user.
  2. QR Code: The secret is embedded in a QR code URL for the user to scan with an authenticator app.

Step 2: Verifying the TOTP Code

After setting up 2FA, the user must provide a valid TOTP code from their authenticator app to confirm activation.

routes/auth.js

// Verify TOTP code and enable 2FA
router.post("/verify-2fa", async (req, res) => {
  const { token } = req.body;
  try {
    const user = await User.findById(req.user.id);
    if (!user) return res.status(404).json({ message: "User not found" });
    if (!user.twoFactorSecret) return res.status(400).json({ message: "2FA is not enabled" });
 
    // Verify the TOTP token
    const isValid = authenticator.verify({ token, secret: user.twoFactorSecret });
    if (!isValid) return res.status(400).json({ message: "Invalid 2FA token" });
 
    user.twoFactorEnabled = true;
    await user.save();
 
    res.status(200).json({ message: "Two-Factor Authentication enabled successfully" });
  } catch (error) {
    res.status(500).json({ message: error.message });
  }
});

In this code:

  1. The user submits the TOTP token (code) generated by their authenticator app.
  2. If valid, twoFactorEnabled is set to true, confirming that 2FA is enabled.

Adding 2FA to the Login Process

To secure the login process, add an additional verification step for users with 2FA enabled.

Step 1: Update Login Route to Check for 2FA

Modify the login route to handle users with 2FA enabled.

routes/auth.js

const jwt = require("jsonwebtoken");
 
// Login Route with 2FA check
router.post("/login", async (req, res) => {
  const { email, password, token } = req.body;
 
  try {
    const user = await User.findOne({ email });
    if (!user) return res.status(404).json({ message: "User not found" });
 
    const isPasswordValid = await user.comparePassword(password);
    if (!isPasswordValid) return res.status(400).json({ message: "Invalid credentials" });
 
    // Check if 2FA is enabled
    if (user.twoFactorEnabled) {
      // Verify 2FA token
      if (!token) return res.status(400).json({ message: "2FA token required" });
 
      const isValid = authenticator.verify({ token, secret: user.twoFactorSecret });
      if (!isValid) return res.status(400).json({ message: "Invalid 2FA token" });
    }
 
    // Generate access token
    const accessToken = jwt.sign({ id: user._id, role: user.role }, process.env.JWT_SECRET, { expiresIn: "15m" });
    res.status(200).json({ user, accessToken });
  } catch (error) {
    res.status(500).json({ message: error.message });
  }
});

In this modified login route:

  1. 2FA Check: If 2FA is enabled, the user must provide a valid TOTP token along with their password.
  2. Token Verification: The token is verified using the user’s TOTP secret.

Testing the 2FA Process

  1. Enable 2FA: Access the /enable-2fa endpoint to generate a QR code and secret for the user.
  2. Scan the QR Code: Use Google Authenticator or a similar app to scan the QR code.
  3. Verify 2FA: Access the /verify-2fa endpoint, submitting a valid TOTP code to enable 2FA.
  4. Log In with 2FA: Test the login route by submitting the email, password, and a valid TOTP code.

Best Practices for 2FA Implementation

  1. Use a Short Expiration for Tokens: Use short expiration times (e.g., 30 seconds) for TOTP tokens to minimize the risk if compromised.
  2. Secure Secret Storage: Store TOTP secrets securely in your database and ensure they are protected.
  3. Offer Backup Codes: Provide backup codes for users in case they lose access to their 2FA app. 4

. Limit Verification Attempts: Implement rate limiting to prevent brute-force attacks on 2FA codes. 5. Allow 2FA Reset: Offer a secure way to reset 2FA for users who have lost access to their authenticator app, usually through identity verification.


Conclusion

Implementing Two-Factor Authentication (2FA) with TOTP and Google Authenticator in a Node.js application enhances security by adding an additional verification layer. By generating a TOTP secret for each user, allowing them to scan it with an authenticator app, and verifying the token during login, you can provide a secure 2FA experience for your users.

Integrate these 2FA techniques into your project to protect user accounts and improve the security of your Node.js application.