Node.js: implementing a login verification code with ExpressJS and MongoDB

Node.js: implementing a login verification code with ExpressJS and MongoDB

A login verification code is a security mechanism used by many websites to protect accounts from malicious activity. In this article we're going to see how to implement such a mechanism with ExpressJS and MongoDB.

A login verification code is a security mechanism used by many websites to protect accounts from malicious activity. In this article we're going to see how to implement such a mechanism with ExpressJS and MongoDB.

How it works

When you log in for the first time into an application, your browser's User-Agent string is saved and associated with your account's profile. Later on, when you try to log in with a different browser, the app sends you a verification code via email that you have to enter in a dedicated form in order to prove that you're performing a legitimate login request.

The app simply detects that the browser's User-Agent string is changed and reacts accordingly.

Implementation

App structure

Our sample app is made up of the following endpoints:

Method Path Description
GET / Shows a welcome page to the authenticated user. Shows a Login and Sign Up forms if the user is not authenticated.
GET /logout Logs out the user and makes a redirect to the home page.
POST /signup Creates a new user if he/she's not already registered by getting email and password. Stores the User-Agent string in the user's profile. Makes a redirect to the home page if the operation's successful.
POST /login Logs the user in and creates a reference to the user in the current session. If a user is trying to log in with a different browser, it creates a verification code , stores it in the user's profile, sends an email to the user with that code and shows the verification code form. Otherwise, it redirects the user to the home page.
POST /auth Checks if the submitted verification code has been associated with a given user. If so, logs the user in and creates a reference to the user in the current session. Finally, it redirects the user to the home page.

Helper functions

We create a utils/index.js file in order to define our helper functions.

The first function to be defined takes a string and returns its hashed version. This is used to encrypt user's passwords.


const crypto = require('crypto');

module.exports = {
    hashString(str, type = 'md5' ) {
        return crypto.createHash(type).update(str).digest('hex');
    }
};

The second function defines a MongoDB instance by using the Promise-based version of the native MongoDB driver's connection method.


const mongo = require('mongodb').MongoClient;

module.exports = {
    async db(url) {
        try {
           const client = await mongo.connect(url, {
            useNewUrlParser: true,
            useUnifiedTopology: true
          });
          return client;
        } catch(err) {
            return err;
        }
    }
};

The third function checks whether the user is logging in with a different browser by comparing the User-Agent header of the incoming request with the string saved in the user's profile array.


module.exports = {
    hasSameBrowser(request, browser) {
        return browser.includes(request.header('User-Agent'));
    }
};

Our last helper function creates the random verification code.


module.exports = {
    authCode(length = 8) {
        return Math.random().toString(36).substring(2, length);
    }
};

User model

We need a class to represent an user. Every user must have the following fields:

Property Type Description
email String User's email.
password String User's hashed password.
login.browser Array Array of user agent strings.
tokens Array Valid verification tokens.

We can create such a class in models/User.js.


const {hashString} = require('../utils');

class User {
    constructor(request, email, password) {
        this.data = {
            email: email,
            password: hashString(password, 'md5'),
            login: {
                browser: [request.header('User-Agent')]
            },
            tokens: []
        }
    }

    get getData() {
        return this.data;
    }
}

module.exports = User;

This class has been designed to work within an Express route: the request object is passed to the constructor so that we can get the user agent string directly from the corresponding HTTP header.

Sending emails

We can wrap with Promises the inner functionalities of NodeMailer in the classes/Email.js class file as follows:


const nodemailer = require('nodemailer');

class Email {
    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(Error('Failed'));
                        } else {
                            resolve('OK');
                        }
                    });
                });
            }
        }
    }
}

module.exports = Email;

Displaying validation errors in views

Our application doesn't make use of JSON APIs, so we need to figure out where we can temporarily store our validation messages for the views. A simple solution is to use the locals object of the Express app.

This object is shared by all the views of an Express app. The procedure for handling forms is as follows:

  1. An user submits a form. If a validation object or property is already set in the app.locals object, we delete it.
  2. We validate data and if there are errors, we set the validation object or property in the app.locals object.

The home page route

If an user is authenticated, this route shows a welcome message with a logout button. Otherwise it shows the Login and Sign Up forms.


app.get('/', (req, res) => {
    if(req.session.user) {
        res.render('index', {
            title: 'Welcome'
        });
    } else {
        res.render('login-signup', {
            title: 'Login or Sign Up'
        });
    }    
});

The logout route

If there's a reference to an user in the current section, this route will delete it and perform a redirect to the home page.


app.get('/logout', (req, res) => {
    if(req.session.user) {
        delete req.session.user;
    }
    res.redirect('/');
});

The sign up route

If there are no validation errors, it creates a new instance of an User model, saves its data in the database if the user's not already present and redirects the user to the home page.


app.post('/signup', async (req, res) => {
    if(typeof req.app.locals.signupErrors === 'object') {
        delete req.app.locals.signupErrors;
    }

    const {email, password} = req.body;
    let errors = {};
    let hasError = false;

    if(!validator.isEmail(email)) {
        errors.email = 'Invalid email.';
        hasError = true;
    }

    if(validator.isEmpty(password)) {
        errors.password = 'Invalid password.';
        hasError = true; 
    }

    if(hasError) {
        req.app.locals.signupErrors = errors; 
    } else {
        try {
            const client = await db(config.dbUrl);
            const database = client.db(config.dbName);
            const users = database.collection('users');
            const existingUser = await users.findOne({email});
            
            if(!existingUser) {
                const newUser = new User(req, email, password);
                await users.insertOne(newUser.getData);
            }

        
        } catch(err) {
            res.send(err);
            return;
        }
        
    }

    res.redirect('/');
});

The login route

During the login process, we need to check either if the user exists or if he/she's logging in with a new browser. If so, we send the verification code via email and we show the verification form. Otherwise, we log the user in normally.


app.post('/login', async (req, res) => {
    if(typeof req.app.locals.loginError === 'string') {
        delete req.app.locals.loginError;
    }

    const data = req.body;

    try {
        const client = await db(config.dbUrl);
        const database = client.db(config.dbName);
        const users = database.collection('users');
        const existingUser = await users.findOne({email: data.email, password: hashString(data.password, 'md5')});
        
        if(!existingUser) {
            req.app.locals.loginError = 'Invalid login.'; 
        } else {
            if(hasSameBrowser(req, existingUser.login.browser)) {
                req.session.user = data.email;
            } else {
                const code = authCode();
                const email = new Email(config.mailFrom, config.mailSettings);

                await users.findOneAndUpdate({email: data.email}, { $push: { tokens: code } });
                await email.send({ to: data.email, subject: 'Verification code', body: `Your verification code: ${code}` });

                return res.render('auth', {
                    title: 'Verification required'
                });
            }    
        }

    
    } catch(err) {
        res.send(err);
        return;
    }

    res.redirect('/');
});

The verification route

Finally, we check if the submitted verification code matches one of the user's generated verification codes. If so, we update the user's user agent list.


app.post('/auth', async (req, res) => {
    if(typeof req.app.locals.authError === 'string') {
        delete req.app.locals.authError;
    }

    const {token} = req.body;

    try {
        const client = await db(config.dbUrl);
        const database = client.db(config.dbName);
        const users = database.collection('users');
        const existingUser = await users.findOne({ tokens: { $in: [ token ]}});
        
        if(!existingUser) {
            req.app.locals.authError = 'Invalid verification code.'; 
        } else {
            await users.findOneAndUpdate({ tokens: { $in: [ token ]}}, { $push: {'login.browser': req.header('User-Agent')}});
            req.session.user = existingUser.email;
        }

    
    } catch(err) {
        res.send(err);
        return;
    }

    res.redirect('/');
});

Source code

GitHub