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
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:
- Make sure the user is marked as “unverified” until they confirm their number
- 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).
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
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
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:
- Making sure we visit
new_verify_urlafter sign up, and
- The verification form
Here’s the basics of
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
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.
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 %>
For the User model, we want to make sure we cover the following:
- Set the PIN upon user creation
- Create a method where the PIN can be reset
- 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
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.
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
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.
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
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.
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!