Simple Two-Factor Auth with Shield
Shield is a simple authentication gem I tend to reach for first when developing small Sinatra or Cuba apps. The code is short, reliable, and easy to understand. It’s not a kitchen sink solution, it does one thing well: lets users log in with a password.
As an experiment, I wondered what it might look like to layer a simple two-factor authentication scheme on top of Shield. If you are unfamiliar with two-factor authentication, it typically follows that a user will first log in to a site with their password as usual, and if successful, must pass a second step of entering a PIN from a SecureID type token, or out-of-band delivery message such as SMS. Popular sites implementing two-factor auth include PayPal, Twitter, and Google.
Let’s break down some of the components of what a simple system might look like.
First, we’ll create a basic User model with Ohm, a persistance library backed by Redis.
class User < Ohm::Model
include Shield::Model
attribute :email
attribute :crypted_password
index :email
def self.fetch(email)
User.find(email: email).first
end
end
This is pretty straightforward. We’ve established a user with an email, crypted password, and the fetch method Shield expects per its implementation. Moving on, we’ll tweak the Shield helpers a little and setup some of our own for handling the second factor.
helpers do
include Shield::Helpers
alias_method :initially_authenticated, :authenticated
def authenticated(model)
if user = initially_authenticated(model)
user.id.to_s == session["#{model}_secondary_auth"].to_s && user
end
end
def challenge_authentication(model)
ChallengeAuthentication.new(initially_authenticated(model))
end
def send_challenge(model)
challenge_authentication(model).push
end
def challenge_accepted(model, challenge)
if challenge_authentication(model).check!(challenge)
user = initially_authenticated(model)
session["#{model}_secondary_auth"] = user.id.to_s
end
end
end
There’s a few interesting things to note here. First, we alias Shield’s authenticated method out to initially_authenticate. We’ll use this to check if a user passes the initial password authentication step. Next, we define our new authenticated method, which will rely on password authentication, and a second check against the session to see if the user has passed the secondary authentication step. Sprinkle in some methods around checking our challenge authentication (more details on that in a minute) and our helpers are good to go.
Now let’s move on to some simple Sinatra app and routing setup. First, we’ll handle 401 Unauthorized errors by redirecting to the /login path:
error 401 do
redirect '/login'
end
Next, add in the /login routes Shield typically expects, however, instead of redirecting on success to the main page, we’ll redirect the browser on to a verification step:
get '/login' do
erb :login
end
post '/login' do
if login(User, params[:login], params[:password])
remember(initially_authenticated(User)) if params[:remember_me]
send_challenge(User)
redirect '/login_verification'
else
redirect '/login'
end
end
Note that when a user passes the first login stage, we’ll send them the challenge for the verification step.
Now we’ll get into the meat of the routing and diverge from the vanilla Shield login flow a bit. Setup the verification handling:
get '/login_verification' do
error(401) unless initially_authenticated(User)
erb :login_verification
end
post '/login_verification' do
error(401) unless initially_authenticated(User)
if challenge_accepted(User, params[:challenge])
redirect '/'
else
redirect '/login_verification'
end
end
For either action to succeed, first the user must be initially authenticated. If so, we’ll verify the challenge presented, in this case a randomly assigned 6 digit PIN, matches up. Then we’ll let the user proceed on to the app.
The core of the challenge authentication is handled in a ChallengeAuthentication class:
class ChallengeAuthentication
EXPIRE_TIME = 300
attr_reader :user
def initialize(user)
@user = user
end
def redis
Ohm.redis
end
def push
challenge = generate_challenge
redis.setex key, EXPIRE_TIME, challenge
deliver_challenge(challenge)
end
def check(challenge)
return false if user.nil? || challenge.to_s.empty?
# Should be a secure compare to prevent timing attacks
redis.get(key) == challenge
end
def check!(challenge)
!! ( check(challenge) && redis.del(key) )
end
def key
[user.class.name, user.id, 'challenge'].join(':')
end
# Returns a 6 digit challenge phrase
def generate_challenge
(SecureRandom.random_number * 1_000_000).to_i
end
def deliver_challenge(challenge)
# send an out of band challenge like SMS or Pushover here
end
end
The flow of the challenge check is fairly simple. First, we can push a new challenge by setting a random 6 digit pin for the user, and deliver that out of band. This can be easily accomplished via SMS with a provider such as Twilio, or my favorite push notification service Pushover. Checkout my Rushover gem as a simple client for sending to Pushover. We’ll toss that PIN into Redis with a 5 minute expiration; this provides a simple limited window for which the PIN is valid. Likewise, if you want to implement this on top of a SQL ORM, you could add challenge and expiration timestamps on to your User model.
For checking that the user has provided a valid challenge PIN, we can compare against the value in Redis if it exists. Upon match, we’ll delete the PIN from redis to invalidate it and confirm the challenge is accepted.
You can find the complete code for this example as a gist. Hopefully it’s easy enough to follow, and now you can provide an extra level of security for your social cat-video apps!
Until next time…
—Jun 05, 2013