Menu

In this article we will see how to implement two-factor authentication in Node.js with ExpressJS.

We will use MongoDB as a database and define two collections, one for users and one for authentication codes.

The first collection has the following document schema in Mongoose:

'use strict';

const mongoose  = require('mongoose');

const { Schema }  = mongoose;

const UserSchema = new Schema({
    username: String,
    password: String,
    email: String
    

},{collection: 'users'});

module.exports = mongoose.model('user', UserSchema);

The authentication codes will instead have the following schema:

'use strict';

const mongoose  = require('mongoose');

const { Schema }  = mongoose;

const AuthTokenSchema = new Schema({
    value: String,
    expires: {
        type: Number,
        default: Date.now()
    }

},{collection: 'authtokens'});

module.exports = mongoose.model('authtoken', AuthTokenSchema);

Since we will be sending the authentication code via email, we need a wrapper class that encloses the logic of the Nodemailer module.

'use strict';

const nodemailer = require('nodemailer');


class Mail {
    constructor({ from, settings }) {
        this.settings = settings;
        this.options = {
            from: from,
            to: '',
            subject: '',
            text: '',
            html: ''

        };
    }

    send(to, subject, body) {
        if(nodemailer && this.options) {
            let self = this;
            const transporter = nodemailer.createTransport(self.settings);

            self.options.to = to;
            self.options.subject = subject;
            self.options.text = body;

            if(transporter !== null) {
                return new Promise((resolve, reject) => {
                    transporter.sendMail(self.options, (error, info) => {
                        if(error) {
                            reject(false);
                        } else {
                            resolve(true);
                        }
                    });
                });
            }
        }
    }
}

module.exports = Mail;

We also need to manage the sessions and flash error messages to be returned after an HTTP redirect. We then install the required modules.

npm install express-session connect-flash --save

To show a flash message in the views we can use the following approach.

<% if(message.length > 0) { %>
  <div class="alert alert-danger"><%= message %></div>
<% } %>

To conclude our setup, we need to create a simple middleware that prevents access to certain routes if the user is not authenticated.

'use strict';

module.exports = (req, res, next) => {
    if(!req.session.user) {
        return res.redirect('/');
    }
    next();
};

The first step to implement is login. If the user authenticates successfully, we create the authentication code and send it by e-mail saving the user in the current session.

'use strict';

const crypto = require('crypto');
const express = require('express');
const router = express.Router();
const User = require('../models/user');
const AuthToken = require('../models/authtoken');
const Mail = require('../classes/Mail');
const { mail: mailSettings } = require('../config');
const auth = require('../middleware/auth');

// Login page
router.get('/', (req, res, next) => {

    res.render('index', {
        message: req.flash('message')
    });
});

// Logout route

router.get('/logout', auth, (req, res, next) => {
    delete req.session.user;
    res.redirect('/');
});

// Route for inserting the authentication code

router.get('/auth', auth, (req, res, next) => {

    res.render('auth', {
        message: req.flash('message')
    });
});

// User's dashboard

router.get('/dashboard', auth, (req, res, next) => {
    res.render('dashboard');
});


// Login processing

router.post('/auth', async (req, res, next) => {

    const { username, password } = req.body;
    const encPassword = crypto.createHash('md5').update(password).digest('hex');

    try {
        const user = await User.findOne({ username: username, password: encPassword});

        if(user) {
            const code = Math.floor(Math.random() * (999999 - 100000) + 100000).toString();

            const authToken = new AuthToken({
                value: code
            });

            authToken.save();

            req.session.user = user;

            res.redirect('/auth');

            const mail = new Mail({
                from: mailSettings.from,
                settings: mailSettings.settings
            });

            await mail.send(user.email, 'Your Auth Code', `Your Auth Code is: ${code}`);
        } else {
            req.flash('message', 'Invalid login.');
            res.redirect('/');
        }
    } catch(err) {
        res.sendStatus(500);
    }
});

//...

module.exports = router;

You will notice that the sending of the e-mail in the above code occurs after the HTTP redirect: we made this to prevent the user from waiting longer than necessary due to the processing of the SMTP transaction.

When the user logs in successfully, he is redirected to the page containing the authentication code verification form. The route that checks the code is as follows:

router.post('/verify-auth', auth, async (req, res, next) => {
    const { code } = req.body;
    
    try {
        const authToken = await AuthToken.findOne({ value: code });

        if(!authToken) {
            req.flash('message', 'Invalid Auth Code.');
            return res.redirect('/');
        }

        const expiresIn = 1000 * 60 * 60 * 15; // 15 minutes
        
        if((Date.now() - authToken.expires) > expiresIn) {
            req.flash('message', 'Invalid Auth Code.');
            return res.redirect('/');
        }

        await authToken.remove(); // Removes the authentication code from the database

        res.redirect('/dashboard');

    } catch(err) {
        res.sendStatus(500);
    }
    
});

If you wish to refine the implementation, you can use an external gateway such as Clickatell to send an SMS with the authentication code instead of the e-mail.

Share: