Skip to content

Instantly share code, notes, and snippets.

@kaleguy
Last active January 18, 2016 07:53
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save kaleguy/451dd16a9df25ab7e9e0 to your computer and use it in GitHub Desktop.
Save kaleguy/451dd16a9df25ab7e9e0 to your computer and use it in GitHub Desktop.
Two Factor Authentication with Drywall and Twilio

#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 a Text Message with Twilio

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();
};

An Example Implementation

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: Signup Screen

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:

Signup Screen

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);

Create the SMS Verification Page

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') &times;
          |<%- err %>
        |<% }); %>
        |<% if (success) { %>
        div.alert.alert-info.alert-dismissable
          button.close(type='button', data-dismiss='alert') &times;
          | 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;
}

Update the Schema

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment