DEV Community

MongoDB Guests for MongoDB

Posted on • Edited on

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

This article was written by Moses Anumadu.

Building Password Authentication with Node.js and MongoDB

Most database-driven applications require users to be authenticated. This is not a surprise. The surprise might come when you hear about the number of cyberattacks carried out daily and their complexity. According to Akamai Technologies, there are over 300 million and over 500 million credential-stuffing attacks per day. Given this volume of attacks, it is reasonable for applications to add more layers of authentication (MFA) to the traditional email-and-password authentication.

This three-part tutorial will guide you through building a production-style authentication system that goes beyond the basic signup and login using Node.js, Express, and MongoDB Atlas.

In this first part (Part 1) of the three-part series, we will implement the foundation of the authentication that we will build upon throughout the rest of the series. We will implement the following:

  • A secure password hashing with bcrypt.
  • User registration with email and password.
  • User logs in with email and password.

In Part 2, we will continue building on the existing codebase. We will add secure session management to the code by implementing the following:

  • Login verification
  • TOTP-based MFA setup and verification
  • QR code onboarding for TOTP setup
  • Half-authenticated login flows

And in Part 3, we will make it an authentication that you can deploy or use in your real-world application by implementing the following:

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

Pre-requisites

To follow along with this tutorial, ensure you have your development environment set up for the stack above and:

  • An understanding of Node.js and Express
  • An Understanding of MongoDB
  • A MongoDB Atlas account
  • Postman for testing API endpoints

Alright, let's get started.

Project Setup

To get started, we need to create a fresh Node.js project and install all the necessary dependencies. In your directory of choice, create the project using the command below:

mkdir devrel-node-api-authentication
cd devrel-node-api-authentication
npm init -y
Enter fullscreen mode Exit fullscreen mode

An advanced authentication system like the one we are building relies on several dependencies in Node.js. We’ll install a few packages to handle those responsibilities. From your terminal, navigate to the project directory and then install dependencies using the command below

npm install express mongoose dotenv bcryptjs jsonwebtoken otplib qrcode && npm install --save-dev nodemon
Enter fullscreen mode Exit fullscreen mode

Let's take a moment to go over all the packages we just installed. Express provides the web server and routing system for building our API, while Mongoose letting us interact with MongoDB through models and schemas. dotenv helps us load environment variables such as database credentials and secret keys. bcryptjs securely hashes passwords before storing them in the database and jsonwebtoken enables us to create and verify JWT tokens for authentication. For MFA functionality, otplib generates and validates time-based one-time passwords (TOTP), while qrcode converts our MFA setup information into a scannable QR code for authenticator apps. Finally, nodemon improves the development experience by automatically restarting the server whenever code changes are detected.

After the installation, we need to update the script section in our package.json file with the code below:

"scripts": {
  "dev": "nodemon src/server.js"
}
Enter fullscreen mode Exit fullscreen mode

This would enable us to serve the project using npm run dev

Creating the Project Structure

We are making progress. Let's proceed by creating a structure for the project. In your project, create the following files and folders. The project structure should look like the sample below.

src/
├── config/
├── controllers/
├── middleware/
├── models/
├── routes/
├── utils/
├── app.js
└── server.js 
Enter fullscreen mode Exit fullscreen mode

Configuring MongoDB Atlas

Next, let's configure our application to interact with our MongoDB Atlas cluster. Ensure you have your Atlas connection string. If you do not have an Atlas account, follow the link to sign up and create an Atlas cluster, then obtain your connection string. With that said, create a


 file in the project root directory and update the content with the code below:


```bash
MONGO_URI_BASE=mongodb+srv://USERNAME:PASSWORD@cluster0.xxxxx.mongodb.net

JWT_ACCESS_SECRET=supersecretaccesskey
JWT_REFRESH_SECRET=supersecretrefreshkey

ACCESS_TOKEN_EXPIRES_IN=15m
REFRESH_TOKEN_EXPIRES_DAYS=7
MFA_TOKEN_EXPIRES_IN=5m

PORT=5000
Enter fullscreen mode Exit fullscreen mode

Now that we have our Atlas connection string, let's proceed to connect our application to MongoDB using Mongoose. Create src/config/db.js and update the file with the code below.

const mongoose = require("mongoose");

const databaseName = "devrel-node-auth-db";
const appName = "devrel-tutorial-nodejs-authentication-geeksforgeeks";

const connectDB = async () => {
try {
  const mongoUri = `${process.env.MONGO_URI_BASE}/${databaseName}?retryWrites=true&w=majority&appName=${appName}`;

  await mongoose.connect(mongoUri);

  console.log("MongoDB connected");
  console.log(`Database: ${databaseName}`);
  console.log(`App Name: ${appName}`);
} catch (error) {
  console.error("MongoDB connection failed:");
  console.error(error.message);

  process.exit(1);
}
};

module.exports = connectDB;

Enter fullscreen mode Exit fullscreen mode

Notice that the database name and appName are defined directly inside the codebase rather than inside the environment file. This keeps infrastructure-level configuration consistent across environments.

Creating the Express App

Now that we have the database configuration done, let's proceed to create an Express application. To keep things organized, we will create the app instance and register global middlewares in src/app.js. Update the file with the code below.

const express = require("express");
const authRoutes = require("./routes/authRoutes");
const app = express();

app.use(express.json());

app.use("/api/auth", authRoutes);
module.exports = app;

Enter fullscreen mode Exit fullscreen mode

In src/app.js, we imported and used the authRoutes.js file. Let's go ahead and create this file before we proceed.
In src/routes/ directory, create the authRoutes.js file and update it with the code below:

const express = require("express");

const router = express.Router();

router.get("/health", (req, res) => {
 res.status(200).json({ message: "Auth routes are working" });
});

module.exports = router;

Enter fullscreen mode Exit fullscreen mode

We created a simple /health route to verify that the application setup was correct.

Creating the Server Entry Point

Next, in copy and paste the code below into the src/server.js file.

const dotenv = require("dotenv");
const app = require("./app");
const connectDB = require("./config/db");

dotenv.config();

const PORT = process.env.PORT || 5000;

const startServer = async () => {
 await connectDB();
 app.listen(PORT, () => {
   console.log(`Server running on port ${PORT}`);
 });
};

startServer();

Enter fullscreen mode Exit fullscreen mode

Now, we can test the configuration so far. In your project directory, start the development server using the command below:

npm run dev
Enter fullscreen mode Exit fullscreen mode

If everything is configured correctly, you should see the following from your terminal:

MongoDB connected
Server running on port 5000
Enter fullscreen mode Exit fullscreen mode

Creating the User Model

With all the configurations done, let's jump to the exciting part. So, the first step in authentication is creating a user. We need to implement code to create a user and securely store the password using bcrypt for Node.js in our MongoDB collection.

Create src/models/User.js file and update the file with the code below:

const mongoose = require("mongoose");

const userSchema = new mongoose.Schema(
 {
   email: {
     type: String,
     required: true,
     unique: true,
     lowercase: true,
     trim: true,
   },

   passwordHash: {
     type: String,
     required: true,
   },

   mfaEnabled: {
     type: Boolean,
     default: false,
   },

   mfaSecret: {
     type: String,
     default: null,
   },
 },
 {
   timestamps: true,
 }
);

module.exports = mongoose.model("User", userSchema);

Enter fullscreen mode Exit fullscreen mode

From the code above, we created a User model. Mongoose was used to create our User schema and User model. One more thing to note is that, according to the schema, we are storing passwordHash instead of password. PasswordHash ensures that the password is not stored in plain text but is hashed using bcrypt.

Building User Registration

Let's proceed to the actual logic to create a new user. In src/controllers/authController.js, copy and paste the code below.

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

//Now add the registration controller:

const register = async (req, res) => {
  try {
    const { email, password } = req.body;

    // Basic validation
    if (!email || !password) {
      return res.status(400).json({
        success: false,
        message: "Email and password are required",
      });
    }

    // Password length validation
    if (password.length < 6) {
      return res.status(400).json({
        success: false,
        message: "Password must be at least 6 characters",
      });
    }

    // Prevent duplicate accounts
    const existingUser = await User.findOne({ email });

    if (existingUser) {
      return res.status(409).json({
        success: false,
        message: "User already exists",
      });
    }

    // Hash password
    const passwordHash = await bcrypt.hash(password, 12);

    // Create user
    const user = await User.create({
      email,
      passwordHash,
    });

    return res.status(201).json({
      success: true,
      message: "User registered successfully",
      user: {
        id: user._id,
        email: user.email,
      },
    });
  } catch (error) {
    console.error(error);

    return res.status(500).json({
      success: false,
      message: "Server error",
    });
  }
};
module.exports = { register };
Enter fullscreen mode Exit fullscreen mode

From the code above, you can see that we validated for empty email and password values, password length, and prevented duplicate accounts by checking whether the email already exists. The next important thing to note is that the password was hashed using bcrypt
const passwordHash = await bcrypt.hash(password, 12);.

Bcrypt automatically handles salting, secure hashing, and computational cost. The second parameter (12) represents the salt rounds value. The higher this number, the more difficult it is for an attacker to crack this password.

Updating Authentication Routes

Let's create a route link for the Register logic. We already created the src/routes/authRoutes.js file. Now open the file and update the content with the code below.

const express = require("express");

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

const router = express.Router();

router.post("/register", register);

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

Testing Registration

It's time to test what we have built so far. For this tutorial, I will be testing with Postman on the web and tunneling my local environment using Ngrok. Feel free to test with what works for you or follow along with me.

If your server is not already running, navigate to your project directory and start the server with the command below;

npm run dev
Enter fullscreen mode Exit fullscreen mode

In a different terminal window, start Ngrok using the command below:

Ngrok http 5000
Enter fullscreen mode Exit fullscreen mode

You should expect an output similar to the image below:

Ngrok tunnels our localhost:5000 URL to https://b76b-197-157-218-195.ngrok-free.app or something similar. The Ngrok URL will be used to test in Postman, not our localhost URL.

To test, send a POST request to the register route: /api/auth/register. Also, add the following to the request body:

{
  "email": "test_user@example.com",
  "password": "password123"
}
Enter fullscreen mode Exit fullscreen mode

Your output should be similar to the image below:

Try creating a duplicate user with the same record, and notice you should get a response similar to the image below

If you inspect your MongoDB Atlas database, you should now see a users collection containing the new user document with an encrypted password.

Building Password Authentication

Now, users can register. Let's proceed to Login. Registered users should be able to securely login to the system using their email and password. Still in src/controllers/authController.js, just below the register function, copy and paste the code below.

const login = async (req, res) => {
  try {
    const { email, password } = req.body;

    // Basic validation
    if (!email || !password) {
      return res.status(400).json({
        success: false,
        message: "Email and password are required",
      });
    }

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

    // Prevent email enumeration attacks
    if (!user) {
      return res.status(401).json({
        success: false,
        message: "Invalid credentials",
      });
    }

    // Compare password with stored hash
    const isPasswordValid = await bcrypt.compare(
      password,
      user.passwordHash
    );

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

    return res.status(200).json({
      success: true,
      message: "Login successful",
      user: {
        id: user._id,
        email: user.email,
        mfaEnabled: user.mfaEnabled,
      },
    });
  } catch (error) {
    console.error(error);

    return res.status(500).json({
      success: false,
      message: "Server error",
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

Also, update the export, add the register function we just created, like so:

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

In the code, we did a number of validations. The first one checked for empty strings, then we checked if this user exists in the database. Did you notice that both invalid email and invalid password return the same response: "Invalid credentials"? This prevents attackers from discovering which email addresses exist in the system. After that, we compared the input password with the password in the database. Also note that the password was not decrypted, it was instead compared against the encrypted version already stored in our database.

Adding the Login Route

Next, let's add a route for the login. Replace the code in src/routes/authRoutes.js with the code below to create a route for both /register and /login.

const express = require("express");

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

const router = express.Router();

router.post("/register", register);
router.post("/login", login);

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

Testing Login

To test, send a POST request to the login route: /api/auth/login. Also, add the following to the request body:

{
  "email": "test_user@example.com",
  "password": "password123"
}
Enter fullscreen mode Exit fullscreen mode

We are making progress. We now have a very basic authentication system with Login and Register features. This is obviously nowhere near battle-ready for today's internet. So, we will proceed and strengthen this system with Multi-Factor Authentication (MFA).

Conclusion

In this part, we kept it simple and implemented user registration and login. Users can register with an email and a password; a JWT token is not issued yet at this stage. The system now only knows that the user's email and password are valid. To achieve this, we implemented the following:

  • A secure password hashing with bcrypt.
  • User registration with email and password.
  • User logs in with email and password. In the next part ( 2), we will build upon the foundation we create here by adding secure session management using:
  • login verification
  • TOTP-based MFA setup and verification
  • QR code onboarding
  • half-authenticated login flows See you in part 2.

Top comments (0)