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:
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 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:
new_verify_url
after sign up, andHere’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 %>
For the User model, we want to make sure we cover the following:
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.
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.
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.
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!
Writer. Musician. Adventurer. Nerd.
Purveyor of GIFs and dad jokes.