#Two Factor Authentication with Drywall and Twilio
Two factor authentication is where you check that the user both knows something (e.g. a password) and has something (e.g. a cellphone). One way to do the latter is to send the user an SMS message.
If you have a site with users, there are a number of possible ways you could add two factor authentication.
- Password + SMS code at signup
- Password + SMS code at login
- In a normal session, popup SMS code verification request
We're building a site with drywall at www.saltroadapp.com. This site is for a coupon app.
In case you're new to drywall, drywall is a nifty project that makes it easy to build a membership site with node.js and host on heroku or another service.
We're mostly concerned with ensuring that there is only one account per user, so that users can't double dip on coupons. It isn't critical that we prevent one user from using another's account. So we added two factor authentication at signup, but not at login. However, if something unusual happens, like a user redeems a coupon in NYC and 5 minutes later redeems one in San Francisco, we'll pop up the request for an SMS verification code in the middle of a session. So we're implementing the first and third ways in the list above.
Your use case may be different. The following code shows how we implemented two-factor authentication, but you may need to change things for your site.
Sending an SMS message with Twilio is simple. The following code is all you need. If your use case doesn't follow the longer example below, you can build your own two factor authentication starting with the following:
var sendVerificationSMS = function(req, res, options) {
var client, onMessageSent, sendMessage;
//require the Twilio module and create a REST client
client = require('twilio')('ACCOUNT_SID', 'AUTH_TOKEN');
onMessageSent = function(error, message, mtarget) {
if (error) {
console.error('Dagnabit. We couldn\'t send the authentication message to ' + mtarget);
options.onError();
} else {
console.log('Message sent! Message id: ' + message.sid);
options.onSuccess();
}
};
// phone number is username
sendMessage = function() {
console.log("Sending message to " + options.phonenum);
return client.sendMessage({
to: '+1' + options.phonenum,
from: '+11234567', // your Twilio phone number
body: "Here's your Authentication Code: " + options.code
}, onMessageSent);
};
sendMessage();
};
In our case, we're trying to limit accounts to one per person. There isn't a really fool proof way to do this, so we settled on one user per phone number. To make this obvious to the user and to make things simple, we just changed the Username label on the signup (and signin) pages to "Mobile Phone Number". For your app you might want to keep the username and add a phone number.
Again, this example might not correspond to how you want your own site to work. We're basically limiting users to people who have cell phones.
So the first page of signup looks like this:
All we've done so far is change "Pick A Username" label to "Mobile Phone Number" in the corresponding jade file.
Once you've submitted your information, you get the the standard drywall page saying that a verification email has been sent. However, once you click on the link in the verification email, rather than getting logged in, you get a new page that looks like this:
When this page has loaded, it has already sent the SMS code to the user. The user enters the code, and then they can proceed to the app.
##Edit Route.js
To get this page into the signin process, the first thing you will need to do is edit ensureAccount in routes.js like so:
function ensureAccount(req, res, next) {
if (req.user.canPlayRoleOf('account')) {
if (req.app.config.requireAccountVerification) {
if (req.user.roles.account.isVerified !== 'yes'){
if (!/^\/account\/verification\//.test(req.url)) {
return res.redirect('/account/verification/')
} else {
return next();
}
}
if (req.user.roles.account.isSMSVerified !== 'yes' && !/^\/account\/verification_sms\//.test(req.url)) {
return res.redirect('/account/verification_sms/');
}
}
return next();
}
res.redirect('/');
}
That takes care of the login logic, now you also need to add the routes:
app.get('/account/verification_sms/', require('./views/account/verification_sms/index').init);
app.post('/account/verification_sms/', require('./views/account/verification_sms/index').resendVerification);
app.get('/account/verification_sms/:token/', require('./views/account/verification_sms/index').verify);
So now there is a new page at /account/verification/sms/. To create this page first you need to make two new folders:
- views/account/verification_sms/
- public/views/account/verification_sms
The folder at the first location will have two files: index.jade and index.js. They are similar to the existing drywall files at views/account/verification/, except of course, the index.js file has code to send a message via SMS/Twilio.
views/account/verification/sms/index.jade
extends ../../../layouts/account
block head
title SMS Verification Required
style(type="text/css").
.errormessage {display:none}
#authCode {
font-size:20px;
font-family:courier;
margin-left:10px;
margin-bottom:8px;}
#btnAuth {
margin-bottom:50px;
margin-top:30px;
}
.errormessage {
margin-top:6px;
color:red;
}
block neck
link(rel='stylesheet', href='/views/account/verification_sms/index.min.css?#{cacheBreaker}')
block feet
script(src='/views/account/verification/sms_index.min.js?#{cacheBreaker}')
block body
div.row
div.col-sm-6
div.page-header
h1 One More Step!
div.alert.alert-warning.
Our memberships are really valuable, so
we use two step account verification. We've just
sent a confirmation code to your phone.
div#authForm
label Enter code:
input#authCode(type="text" name="authCode" size="8" onkeydown="app.checkInput(event)")
div#zerolengthmessage.errormessage.
Please enter code.
div#wrongsizemessage.errormessage.
Your verification code has 6 digits.
div#failuremessage.errormessage.
The code you entered doesn't match the one we sent... check
the code and try again, or <a href="/contact">contact us</a>.
div
button#btnAuth(class="btn btn-success btn-lg", type="button" onclick="app.checkAuthCode()").
Verify Account
div#verify
div.col-sm-6.special
div.page-header
h1 You're Almost Done
i.fa.fa-mobile.super-awesome
script(type='text/template', id='tmpl-verify')
form
div.alerts
|<% _.each(errors, function(err) { %>
div.alert.alert-danger.alert-dismissable
button.close(type='button', data-dismiss='alert') ×
|<%- err %>
|<% }); %>
|<% if (success) { %>
div.alert.alert-info.alert-dismissable
button.close(type='button', data-dismiss='alert') ×
| Verification code successfully re-sent.
|<% } %>
|<% if (!success) { %>
div(class!='not-received<%= !keepFormOpen ? "" : " not-received-hidden" %>')
a.btn.btn-link.btn-resend Resend verification code.
div(class!='verify-form<%= keepFormOpen ? "" : " verify-form-hidden" %>')
div.form-group(class!='<%- errfor.username ? "has-error" : "" %>')
label Your Phone Number:
input.form-control(type='text', name='username', value!='<%= username %>')
span.help-block <%- errfor.username %>
div.form-group
button.btn.btn-primary.btn-verify(type='button') Re-Send Confirmation Code
|<% } %>
script(type='text/template', id='data-user') !{data.user}
Not too different from the index.jade file at /views/account/verification/, except this one has a bit of css inside it... you might want to put that where it belongs, in the index.less file :-)
You'll need an index.js file to go with this in the same folder. The Twilio code is at the beginning. Needless to say, you'll have to edit this code to have the correct ACCOUNT_SID and AUTH_CODE you get when you sign up for Twilio.
views/account/verification_sms/index.js
'use strict';
var sendVerificationSMS = function(req, res, options) {
var client, onMessageSent, sendMessage;
//require the Twilio module and create a REST client
client = require('twilio')('ACCOUNT_SID', 'AUTH_TOKEN');
onMessageSent = function(error, message, mtarget) {
if (error) {
console.error('Dagnabit. We couldn\'t send the authentication message to ' + mtarget);
options.onError();
} else {
console.log('Message sent! Message id: ' + message.sid);
options.onSuccess();
}
};
sendMessage = function() {
console.log("Sending message to " + options.phonenum);
return client.sendMessage({
to: '+1' + options.phonenum,
from: '+19171234567',
body: "Here's your Authentication Code: " + options.code
}, onMessageSent);
};
sendMessage();
};
exports.init = function(req, res, next){
// leave this page if they are already verified
if (req.user.roles.account.isSMSVerified === 'yes') {
return res.redirect(req.user.defaultReturnUrl());
}
var workflow = req.app.utility.workflow(req, res);
workflow.on('renderPage', function() {
req.app.db.models.User.findById(req.user.id, 'username').exec(function(err, user) {
if (err) {
return next(err);
}
res.render('account/verification_sms/index', {
data: {
user: JSON.stringify(user), // phone number = user
}
});
});
});
// If the user has a code, that means it has already been sent,
// so just render the page. Otherwise, generate a new code (and send)
workflow.on('generateCodeOrRender', function() {
if (req.user.roles.account.verificationCode !== '') {
return workflow.emit('renderPage');
}
workflow.emit('generateCode');
});
workflow.on('generateCode', function() {
var code = Math.floor(Math.random() * 899999 + 100000)
workflow.emit('patchAccount', code);
});
// put the code in the database and send
workflow.on('patchAccount', function(code) {
var fieldsToSet = {
verificationCode: code,
$inc: {
SMSCount: 1
}
};
req.app.db.models.Account.findByIdAndUpdate(req.user.roles.account.id, fieldsToSet, function(err, account) {
if (err) {
return next(err);
}
sendVerificationSMS(req, res, {
code:code,
phonenum: req.user.username,
onSuccess: function() {
return workflow.emit('renderPage');
},
onError: function(err) {
return next(err);
}
});
});
});
workflow.emit('generateCodeOrRender');
};
exports.resendVerification = function(req, res, next){
if (req.user.roles.account.isSMSVerified === 'yes') {
debug('is sms verified');
return res.redirect(req.user.defaultReturnUrl());
}
var workflow = req.app.utility.workflow(req, res);
// the user name is the phone number, needs to be 7 digits, no spaces
workflow.on('validate', function() {
if (!req.body.username) {
workflow.outcome.errfor.username = 'required';
}
else if (!/^\d\d\d\d\d\d\d\d\d+$/.test(req.body.username)) {
workflow.outcome.errfor.username = 'invalid phone number';
}
if (workflow.hasErrors()) {
return workflow.emit('response');
}
workflow.emit('duplicateUsernameCheck');
});
// the user name is the phone number, make sure it isn't already registered
workflow.on('duplicateUsernameCheck', function() {
req.app.db.models.User.findOne({ username: req.body.username, _id: { $ne: req.user.id } }, function(err, user) {
if (err) {
return workflow.emit('exception', err);
}
if (user) {
workflow.outcome.errfor.username = 'Phone number already taken';
return workflow.emit('response');
}
workflow.emit('SMSAbuseCheck');
});
});
// make sure the user isn't sending a bunch of repeat requests
workflow.on('SMSAbuseCheck', function() {
req.app.db.models.User.findOne({ username: req.body.username, _id: { $ne: req.user.id } }, function(err, user) {
// lock the account on 3 resends... a bit
// primitive way of shutting the problem down,
// this could be made more sophisticated
if (req.user.roles.account.SMSCount > 3) {
workflow.outcome.errfor.username = 'Account locked, please contact us.';
return workflow.emit('response');
}
workflow.emit('patchUser');
});
});
// simple 6 digit verification code to send via SMS
workflow.on('generateCode', function() {
var code = Math.floor(Math.random() * 899999 + 100000)
workflow.emit('patchAccount', code);
});
// the user may have sent a different phone number,
// so we're changing that in the database. Remember,
// the user name is the phone number.
workflow.on('patchUser', function() {
var fieldsToSet = { username: req.body.username };
req.app.db.models.User.findByIdAndUpdate(req.user.id, fieldsToSet, function(err, user) {
if (err) {
return workflow.emit('exception', err);
}
workflow.user = user;
workflow.emit('generateCode');
});
});
// put the verification code in the database, then send to the user
// increment the SMS count so we know how many times this user has
// requested a resend
workflow.on('patchAccount', function(code) {
var fieldsToSet = {
verificationCode: code,
$inc: {
SMSCount: 1
}
};
req.app.db.models.Account.findByIdAndUpdate(req.user.roles.account.id, fieldsToSet, function(err, account) {
if (err) {
return workflow.emit('exception', err);
}
sendVerificationSMS(req, res, {
code:code,
phonenum: req.body.username,
onSuccess: function() {
workflow.emit('response');
},
onError: function(err) {
workflow.outcome.errors.push('Error Sending: '+ err);
workflow.emit('response');
}
});
});
});
workflow.emit('validate');
};
exports.verify = function(req, res, next){
var checkVerificationCode = function(err, account) {
if (err) {
return next(err);
}
if ((account.verificationCode + "") === req.params.token) {
return updateSMSVerified();
} else {
return res.send("ng");
}
};
var updateSMSVerified = function(){
var fieldsToSet = { isSMSVerified: 'yes', verificationCode: '' };
req.app.db.models.Account.findByIdAndUpdate(req.user.roles.account._id, fieldsToSet, function(err, account) {
if (err) {return next(err)}
res.send("ok");
});
}
return req.app.db.models.Account.findById(
req.user.roles.account.id, 'verificationCode', checkVerificationCode
);
};
Now create the two files you need under /public/views/account/verification_sms/: index.js and index.less
/public/views/account/verification_sms/index.js
/* global app:true */
(function() {
'use strict';
app = app || {};
app.checkInput = function(event){
if(event.keyCode != 13) {
$(".errormessage").hide();
}
if (event.keyCode == 13) {
document.getElementById('btnAuth').click()
}
}
app.checkAuthCode = function(){
var authCode = $("input[type='text'][name='authCode']").val();
if (authCode.length === 0){
$("#zerolengthmessage").show();
return;
}
if (authCode.length !== 6){
$("#wrongsizemessage").show();
return;
}
var me = this;
$.ajax({
url: "/account/verification/sms/" + authCode,
data: {},
context: this
}).done(function(data) {
if (data.result === "ok"){
window.location = "/account/";
} else {
$("#failuremessage").show(500);
}
});
}
app.Verify = Backbone.Model.extend({
url: '/account/verification/sms/',
defaults: {
success: false,
errors: [],
errfor: {},
keepFormOpen: false,
username: ''
}
});
app.VerifyView = Backbone.View.extend({
el: '#verify',
template: _.template( $('#tmpl-verify').html() ),
events: {
'submit form': 'preventSubmit',
'click .btn-resend': 'resend',
'click .btn-verify': 'verify'
},
initialize: function() {
this.model = new app.Verify( JSON.parse($('#data-user').html()) );
this.listenTo(this.model, 'sync', this.render);
this.render();
},
render: function() {
this.$el.html(this.template( this.model.attributes ));
},
preventSubmit: function(event) {
event.preventDefault();
},
resend: function() {
this.model.set({
keepFormOpen: true
});
this.render();
},
verify: function() {
this.$el.find('.btn-verify').attr('disabled', true);
this.model.save({
username: this.$el.find('[name="username"]').val()
});
}
});
$(document).ready(function() {
app.verifyView = new app.VerifyView();
});
}());
This is similar to the index.js in /public/views/account/verification/ except that we've added checkAuthCode, which is a simple function for getting the code from the user, sending it to the server, getting a success message, and then forwarding to the app main page.
/public/views/account/verification_sms/index.less
.special {
text-align: center;
}
.super-awesome {
display: block;
margin-top: -15px;
color: #7f7f7f;
font-size: 20em;
}
.not-received-hidden {
display: none;
}
.verify-form-hidden {
display: none;
}
We need to add a few new items to the Account schema in /schema/Account.js:
verificationCode: {type: String, default: ''},
isSMSVerified: {type: String, default: ''},
SMSCount: {type: Number, default:0},
Add those right under verificationToken
in the accountSchema. The first is to store the verification code we send to the user. The second is to keep track of whether the user has been SMS verified. The third is to count how many times the user has requested a resend. Sending SMS messages costs money, so in the code above we've stopped users from getting more than 3 resends. This is a bit simple and arbitrary and you may want to code something a bit more sophisticated.