require "ohm" | |
require "redis" | |
require "securerandom" | |
require "shield" | |
require "sinatra" | |
Ohm.connect db: 14 | |
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 | |
User.find(email: 'john@example.com').first || User.create(email: 'john@example.com', password: 'secret') | |
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 | |
use Rack::Session::Cookie, secret: SecureRandom.hex(64) | |
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 | |
error 401 do | |
redirect '/login' | |
end | |
# Handle initial password login | |
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 | |
# Handle second factor authentication if password auth succeeds | |
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 | |
get '/' do | |
error(401) unless authenticated(User) | |
"You're in!" | |
end | |
__END__ | |
@@login | |
<form action='/login' method='post'> | |
<input type='text' name='login' placeholder='Email'> | |
<input type='password' name='password' placeholder='Password'> | |
<input type='submit' name='proceed' value='Login'> | |
</form> | |
@@login_verification | |
<form action='/login_verification' method='post'> | |
<input type='text' name='challenge' placeholder='Challenge'> | |
<input type='submit' name='proceed' value='Login'> | |
</form> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment