How to setup Two Factor Authentication(2FA) in Node.js without third-party applications

emmanuelo

Emmanuel Onyeyaforo

Posted on May 25, 2023

How to setup Two Factor Authentication(2FA) in Node.js without third-party applications

Introduction

In today's interconnected world, the internet plays a vital role in our daily lives. While it offers convenience, vast wealth, and access to information, it also poses significant security risks which if not taken care of can double down on the cons rather than the pros.

This article will delve into how to set up 2FA for your Node.js applications without third-party applications, equipping devs with knowledge and strategies to protect applications with the aid of 2FA in the digital realm.

What is Two Factor Authentication(2FA)?

Two-Factor Authentication (2FA), also known as 2-step verification, is a security method used to enhance the protection of online accounts and systems of applications. It adds an extra layer of security by prompting users to provide two different types of credentials to verify their identity.

This means that in addition to a password, users must provide another form of authentication. By demanding both factors during the authentication process, even if an attacker obtains a user's password, they would still need access to the second factor in order to gain unauthorized entry to the account.

The second factor can be in the form of shortcodes called One-Time Passwords (OTPs) delivered via email or phone number. This implementation significantly increases security and poses a more significant challenge for malicious individuals attempting to compromise accounts through methods such as phishing, password theft, or brute-force attacks.

Prerequisites

Before we proceed, you should have a code editor and a basic understanding of Node.js and its package manager, npm.
If you don't have Node.js installed, you can follow the official installation guide for your operating system:

~ Node.js Installation Guide

Basic knowledge of express.js, mongoose, and any database management system is also required. For the sake of this article, we will be using MongoDB.

Setting Up the Project

Step 1 - Create a Node.js Application

To get started with our app in Node.js, you'll first need to create a Node.js application. Begin by creating a fresh directory for your application, which you can name "node-with-2FA" or any preferred name. Next, open your terminal and type in the following command to create the directory:

mkdir node-with-2FA
Enter fullscreen mode Exit fullscreen mode

Next, navigate to the newly created directory using the following command:

cd node-with-2FA
Enter fullscreen mode Exit fullscreen mode

Once you're in the node-with-2FA directory, run the following command to create a new Node.js project using the Node Package Manager (NPM):

npm init
Enter fullscreen mode Exit fullscreen mode

When you run this command, you will be prompted with several questions regarding your project, including its name, version, and description. You have the option to either stick with the default values or modify them according to your preferences.

Once your Node.js project is created, proceed to create a new file named server.js within the main directory of your project and install the needed dependencies which include dotenv, express, mongoose, bcrypt and cors. This is what this file would look like:

require('dotenv').config({ path: "./config.env" });
const express = require('express');
const connectDB = require('./config/db');
const errorHandler = require('./middlewares/error')
const cors = require('cors')

// Connect DB
connectDB();
const app = express();
app.use(express.json());

// authentication routes
app.use('/auth', require('./routes/auth'));

// user routes
app.use('/user, require('./routes/user));

app.get("/", (req, res) =>
  res.json({ success: true, message: "node-with-2FA running!" })
);


app.use(errorHandler);

const PORT = process.env.PORT || 4000
const server = app.listen(PORT, () => console.log(`Server is listening on port ${PORT}`));

process.on("unhandledRejections", (err, promise) => {
  console.log(`Logged Error: ${err}`);
  server.close(() => process.exit(1));
});
Enter fullscreen mode Exit fullscreen mode

Step 2 - Creation and adding other required folders and files

In the node-with-2FA base folder, open your terminal and type in the following command to create other needed directories:

mkdir models middlewares controllers 
Enter fullscreen mode Exit fullscreen mode

In our mode directory or folder, we create a file named userModel.js. Here is what the file would look like:

const crypto = require("crypto")
const mongoose = require('mongoose');
const bcrypt = require("bcryptjs")
const jwt = require("jsonwebtoken")


const UserSchema = new mongoose.Schema({
    username: { type: String, required: 
              [true, "Please provide a username"], trim: true 
              },
    email: {
        type: String, required: [true, "Please provide an email"],
        unique: true,
        Match: [ //regex for email here,
            "Please provide a valid email"
        ]
    },
    two_fa_status: { type: String, default: 'off' },
    OTP_code: { type: String, default: null },
    password: {
        type: String,
        required: [true, "Please add a password"],
        minlength: 6, select: false
    },
},
    { timestamps: true }
);

UserSchema.pre("save", async function (next) {
 if (!this.isModified("password")) {
        next();
    }
    const salt = await bcrypt.genSalt(10);
    this.password = await bcrypt.hash(this.password, salt);
    next();
});

UserSchema.methods.getSignedToken = function () {
    return jwt.sign({ id: this._id }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRESIN })
}

UserSchema.methods.matchPasswords = async function (password) {
    return await bcrypt.compare(password, this.password);
}

const User = mongoose.model("user", UserSchema)

module.exports = User
Enter fullscreen mode Exit fullscreen mode

Note: The JWT_SECRET and JWT_EXPIRESIN should be tucked away in your .env file on the root of your folder.
bcrypt here is used to encrypt the user's password.

Next in our models folder, we create an otp.js file which will look like this:

const mongoose = require("mongoose");
const Schema = mongoose.Schema;


const otpSchema = new Schema({
    userId: {
        type: Schema.Types.ObjectId,
        required: true,
        ref: "user",
        index:true,
unique:true,
sparse:true
    },
    otp: { type: String, required: true },
    createdAt: { type: Date, default: Date.now, expires: 3600 },
});


module.exports = mongoose.model("otp", otpSchema);
Enter fullscreen mode Exit fullscreen mode

Step 4 - Setting up middleware(s):

Next, we set up a middleware to protect user endpoints where authorization is required.

Within the middlewares folder create a file named auth.js which would look like this code block below:

const jwt = require("jsonwebtoken");
const User = require('../models/user');
const ErrorResponse = require('../utils/errorResponse'); //from your already setup error responses.

exports.protect = async (req, res, next) => {
    let token;
    if(
        req.headers.authorization &&
        req.headers.authorization.startsWith("Bearer")
        ) {
        token = req.headers.authorization.split(" ")[1]
    }

    if(!token) {
        return next(new ErrorResponse("Not authorized to access this route", 401));
    }


    try {
        const decoded = jwt.verify(token, process.env.JWT_SECRET);


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


        if(!user) {
            return next(new ErrorResponse("No user found with this id", 404));
        }


        req.user = user;


        next();
   } catch (error) {
        return next(new ErrorResponse("Not authorized to access this route", 401));
    }

Enter fullscreen mode Exit fullscreen mode

Step 5 - Setting up app routes

We mkdir routes on the root folder and add two files (auth.routes.js and user.routes.js). This will contain all our app routes for authentication and user access respectively.

//auth.routes.js
router.route("/login").post(login)
router.route("/resend-otp/:id").post(resendOTP)
router.route("/verify2FA/:id").post(verifyOTP)

//user.routes.js
router.route('/user').get(protect, getUser);
router.route("/activate2FA/:id").post(protect, activate2FA)
router.route("/deactivate2FA/:id").post(protect, deactivate2FA)

Enter fullscreen mode Exit fullscreen mode

Step 6 - Setting up Controllers:

Here in the controllers, we can write app logic and functionalities on how we want the application to behave as well as route precursors.

Next, Create a file auth.controllers.js where we can add the SignUp, Login, OTP requests and verification logics as below. Since our logics' core is centered on the login and OTPs, we would omit the SignUp section.

In Login logic, Users would receive a prompt in their email bearing the OTP code which has a limited validity time, this is sent via an email transport (or mobile number depending on the transport one chooses) such as nodemailer to the registered user’s inbox if their 2FA status is switched on, thus providing us with that extra layer of security users need in our applications.

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

    if (!email || !password) {
        return next(new ErrorResponse('Please provide an email and password', 400))
    }

    try {
        const user = await User.findOne({ email }).select("+password")

        if (!user) {
            return next(new ErrorResponse('Invalid Credentials', 401))
        }

        const isMatch = await user.matchPasswords(password);

        if (!isMatch) {
            return next(new ErrorResponse('Invalid Credentials', 401))
        }

        if (user.two_fa_status) {

            const otp = await new OTP({
                userId: user._id,
                otp: generateCode()
            }).save();

            user.OTP_code = otp.otp
            await user.save();

            await sendEmail({
                to: user.email,
                subject: "One-Time Login Access",
                text:otpMessage(otp, user)
            });

            await otp.remove();

            return res.json({ otp: otp.otp, success: false, otpStatus: user.two_fa_status, id: user._id })
        }

        sendToken(user, 200, res);

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


//OTP verification
On OTP verification, when a user provides the received OTP which matches with OTP_code on the database, OTP verification is successful else an error would be thrown.


exports.verifyOTP = async (req, res, next) => {
    const user = await User.findById(req.params.id);
    const { otp } = req.body


    try {
        if (!user) return res.status(400).send({ message: "invalid user" });

        const lastUpdatedTime = new Date(user.updatedAt);
        lastUpdatedTime.setMinutes(lastUpdatedTime.getMinutes() + 5);
        const currentTime = (new Date(Date.now()))

        if (lastUpdatedTime <= currentTime) {
            console.log('yes')
            user.OTP_code = null
            await user.save();
        }

        if (otp !== user.OTP_code) {
            return next(new ErrorResponse('invalid token, please try again', 400))
        }


        user.OTP_code = null
        await user.save();
        return sendToken(user, 200, res);


    } catch (error) {
        return next(new ErrorResponse('Internal Server', 500))

    }
};


//Resending OTP
Resending OTP function is prompted, when a user did not receive the code or the code has expired.

exports.resendOTP = async (req, res, next) => {
    const { id } = req.params
    const user = await User.findById(`${id}`);

    try {
        if (user.two_fa_status === 'on') {


            const otp = await new OTP({
                userId: user,
                otp: generateCode()
            }).save();

            user.OTP_code = otp.otp
            await user.save();

            await sendEmail({
                to: user.email,
                subject: "One-Time Login Access",
                text:otpMessage(otp, user)
            });

            await otp.remove();

            return res.json({ message: 'one-time login has been sent your email', otpStatus: user.two_fa_status, })
        }
        return res.json(user)


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

Enter fullscreen mode Exit fullscreen mode

Step 7 - Toggling 2FA Status:

Here lies the toggle trigger to activate or deactivate 2FA mode when a user is logged into the app.

exports.activate2FA = async (req, res, next) => {
    const { password } = req.body;
    const { id } = req.params;

    try {
        const user = await User.findById({ _id: id }).select('+password');

        if (!user) {
            return next(new ErrorResponse("User not found", 400))
        }

        const isMatch = await user.matchPasswords(password);
        if (!isMatch) {
            return next(new ErrorResponse("Incorrect password", 400))
        }
        user.two_fa_status = "on"
        await user.save();


        return res.json({ status: user.two_fa_status, message: '2FA activation was successful' });


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


exports.deactivate2FA = async (req, res, next) => {
    const { password } = req.body;
    const { id } = req.params;


    try {
        const user = await User.findById({ _id: id }).select('+password');

        if (!user) {
            return next(new ErrorResponse("User not found", 400))
        }

        const isMatch = await user.matchPasswords(password);
        if (!isMatch) {
            return next(new ErrorResponse("incorrect password", 400))
        }
        user.two_fa_status = "off"
        await user.save();

        return res.json({ status: user.two_fa_status, message: '2FA de-activation was successful' });

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

Enter fullscreen mode Exit fullscreen mode

Conclusion

In addition to securing our web and mobile apps, 2FA is widely recommended and supported by many online services, including email providers, social media platforms, financial institutions, and more. It is a crucial security measure that helps protect sensitive information and prevents unauthorized access to personal accounts. It is highly recommended to enable 2FA wherever it is available to enhance the security of your online presence. The repo to the snippets can be accessed here. To see these code snippets in action, click here.

💖 💪 🙅 🚩
emmanuelo
Emmanuel Onyeyaforo

Posted on May 25, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related