SMS Verification With Rails

While working recently on a side project, I came across the task of “SMS validation”. The project allows users to sign up with their mobile phone number, and have certain text messages sent to their phone on a schedule they determine. Here’s how the feature request came in:

As a user, I need my phone number verified before being allowed to send out SMS messages to myself

Database

First off, we need to figure out what part in our sign-up process this shows up in. We don’t want the user to be able to create any SMS jobs without making sure their phone number is a) valid, and b) their own. So let’s do two things:

  1. Make sure the user is marked as “unverified” until they confirm their number
  2. Ensure they are not able to leave the sign up process and return to subvert this security check

So we start with the basics: the database. We know we’ll want the “verified” boolean, but we’ll also want two other bits of data: storing the pin andwhen was the pin sent at (so we disallow old pins from being used).

$ rails generate migration add_verified_to_users pin:integer pin_sent_at:datetime verified:boolean

Then we modify the resulting migration to set the default for “verified”:

1
2
3
4
5
6
7
class AddPinAndVerifiedToUsers < ActiveRecord::Migration
  def change
    add_column :users, :pin, :integer
    add_column :users, :pin_sent_at, :datetime
    add_column :users, :verified, :boolean, default: false
  end
end

Controller

Now that we have our data fields, let’s make sure our controllers route the user to the correct place based on whether or not they’ve been verified. First, let’s look at applciation_controller.rb:

1
2
3
4
5
6
7
before_filter :redirect_if_unverified

def redirect_if_unverified
  if logged_in? && !current_user.verified?
    redirect_to new_verify_url, notice: "Please verify your phone number"
  end
end

For boolean fields, Rails provides a handy ? method on the column name. So if you’re boolean was awesome, you now have user.awesome? availabe to you. So we tap into that and check to see if the user has yet been verified. If not, we redirect them to our new_verify_url. Here’s the routing for that:

1
2
3
get "/verify" => 'verify#new', as: 'new_verify'
put '/verify' => 'verify#update', as: 'verify'
post '/verify' => 'verify#create', as: 'resend_verify'

NOTE: you can create similar routing with resource, but I wanted to name the POST method as resend_verify. We’ll see that next.

We’re still missing two things at the controller level:

  1. Making sure we visit new_verify_url after sign up, and
  2. The verification form

Here’s the basics of users_controller.rb:

1
2
3
4
5
6
7
8
9
10
def create
  @user = User.new(user_params)

  if @user.save
    auto_login(@user)
    redirect_to new_verify_url
  else
    render :new
  end
end

We’re simply changing the redirect after user creation from something like redirect_to root_url to redirect_to new_verify_url.

Next, we create a separate controller for verification. It’s temping here to shove all of this logic into users_controller.rb, but resist the urge. The behavior of verification is its own thing, and as such, belongs in its own controller (single responsibility principle).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class VerifyController < ApplicationController
  skip_before_filter :redirect_if_unverified

  # GET /verify
  def new
  end

  # PUT /verify
  def update
    if Time.now > current_user.pin_sent_at.advance(minutes: 60)
      flash.now[:alert] = "Your pin has expired. Please request another."
      render :new and return
    elsif params[:pin].try(:to_i) == current_user.pin
      current_user.update_attribute(:verified, true)
      redirect_to root_url, notice: "Your phone number has been verified!"
    else
      flash.now[:alert] = "The code you entered is invalid."
      render :new
    end
  end

  # POST /verify
  def create
    current_user.send_pin!
    redirect_to new_verify_url, notice: "A PIN number has been sent to your phone"
  end

end

First off, we make sure we skip our redirect_if_unverified filter or else we’ll end up in a redirect loop. def new is the basics of our form. def update is the business.

In this method, we’re checking to make sure that the user’s pin was sent less than an hour ago. We return early from this method since this is a non-starter for us. Next, we check to see if the pin exists (using try) and that it matches the user’s pin. If not, we return an error immediately.

You could probably rework this method a few different ways, but I’m happy with how readable it is. Having the last else block also allows for a clean “fall-through” in case we add other validation checks above it.

Finally, def create will be used for a “Send me another PIN” link in the view:

1
<%= link_to 'Resend code', resend_verify_path, method: :post %>

Model

For the User model, we want to make sure we cover the following:

  1. Set the PIN upon user creation
  2. Create a method where the PIN can be reset
  3. Fire off a background job to send out the SMS to the user

For number 1, let’s use ActiveRecor’s callbacks to fire a method when we create a user:

1
after_save :send_pin!, if: "phone_number_changed?"

Next, let’s create two helper methods and the send_pin! method.

1
2
3
4
5
6
7
8
9
10
11
12
13
def reset_pin!
  self.update_column(:pin, rand(1000..9999))
end

def unverify!
  self.update_column(:verified, false)
end

def send_pin!
  reset_pin!
  unverify!
  SendPinJob.perform_later(self)
end

Whenever we send the PIN, we need to make sure we update the pin column and reset the user to be unverified. send_pin! relies the other two helper methods to do these tasks, then enqueues a background job to send out the SMS message.

“You really shouldn’t enqueue a background job with an object. Use an ID.” Meh. Rails has a great new feature called “Global ID” which allows you to enqueue jobs with the ActiveRecord object itself.

“But what about pin_sent_at?” Well, let’s let our upcoming background job handle this since we don’t want the model setting this in case there’s a delay in actually sending out the job.

Background Job

Easy peasy.

1
2
3
4
5
6
7
8
9
class SendPinJob < ActiveJob::Base

  def perform(user)
    nexmo = Nexmo::Client.new(key: ENV["NEXMO_KEY"], secret: ENV["NEXMO_SECRET"])
    resp = nexmo.send_2fa_message(to: user.phone_number, pin: user.pin)
    user.touch(:pin_sent_at)
  end

end

We’re using Nexmo here, but regardless of the SMS provider, the basics are the same: send the message and update pin_sent_at. Rails’ touch method is a good fit here.

Note that I’m assiging the response from the Nexmo API as resp but not doing anything with it yet. One of my next TODO’s is to only touch pin_sent_at if the response is successful.

Re-Send SMS

So what happens if the user doesn’t get their PIN or if they’re too late in entering it? No problem! As seen earlier, the “Resend Code” link makes a POST to the verify_controller.rb, which re-uses our send_pin! method:

1
2
3
4
def create
  current_user.send_pin!
  redirect_to new_verify_url, notice: "A PIN number has been sent to your phone"
end

This will then ensure the user is unverified and is sent a new pin.

Conclusion

Overall, I really enjoyed working on this task. It took awhile to make sure we covered all our use-cases (user leaving mid-verification, expired PIN, re-send, etc), but the process is seamless now. From a security standpoint, creating a 4-digit pin is not the most secure thing here. However, the user must already be logged in to verify their phone, so we’re relying on our session security already. Furthermore, we’re just checking to make sure the phone number was entered correctly so that we’re not sending out messages to bad numbers.

I hope you enjoyed this exercise. Have you ever implemented something similar? See any changes I could make in the samples above? Let me know!

Hi there, I'm Jon.

Writer. Musician. Adventurer. Nerd.

Purveyor of GIFs and dad jokes.