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 |
---|---|---|
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:
- An user submits a form. If a validation object or property is already set in the app.locals object, we delete it.
- 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('/');
});