← Blog
Phone Authentication in Ruby
- Authentication
- Phone
- Redis
- Ruby
- Ruby on Rails
- Security
- SMS
Using SMS to authenticate a user has the following benefits:
- Everybody has a phone.
- Users don’t have to remember passwords.
- Protect against robots & duplicate accounts.
- Familiarity.
You should not use SMS as a sole authentication method for systems that require high security.
What I am going to cover in this article is how to make an API that generates “phone tokens” that can be used to sign-in / sign-up. This way you don’t need to create a user record before a phone is verified.
User stories
Below are 2 user stories we are going to cover.
As a returning user.
I want to sign-in.
I go to the login screen and enter my phone.
I receive an SMS with the verification code, enter the code.
I am logged in.
As a new user.
I want to sign-up.
I go to the login screen and enter my phone.
I receive an SMS with the verification code, enter the code.
I enter my name and email and hit submit.
I am registered and logged in.
To make the 2nd story (sign up) work, upon phone verification we want to return a secure phone token that, together with name and email, can be exchanged to create a new account.
Phone token is going to be an encoded phone string signed by our secret key as proof that it originated from our system.
Pseudo-code API (Controller Code)
An important bit is that both sign-in and sign-up flows start with entering a phone number and getting a phone token. Your app can then check if a user with such phone already exists, if it does, then simply login, otherwise proceed to sign up.
# ask user for a phone and send an SMS code
POST /send-phone-verification { phone: '+19178456780' }
PhoneVerification.send_verification(phone: params[:phone])
=> {}
# verify the phone entering the code from SMS,
# get encoded phoneToken in return
POST /verify-phone { phone: '+19178456780', code: '123456' }
phone_token = PhoneVerification.code_to_phone_token(
phone: params[:phone],
code: params[:code]
)
=> { phoneToken: phone_token }
# exchange the phoneToken to a user information
POST /sign-in { phoneToken: 'xxxxxxxxxxxxx' }
trusted_phone = PhoneVerification.phone_token_to_phone(params[:phone_token])
user = User.find_by_phone(trusted_phone)
=> { user: user }
# OR
# use the phone token to create a new user with verified phone
POST /sign-up { phoneToken: 'xxxxxxxxxxxxx', name: 'John Doe', email: 'john@example.com' }
trusted_phone = PhoneVerification.phone_token_to_phone(params[:phone_token])
user = User.find_or_create_by!(phone: trusted_phone) do |u|
u.name = params[:name]
u.email = params[:email]
end
=> { user: user }
Implementation
In order to implement this we are going to need the following:
- Redis
- Service to send an SMS (e.g. Twilio)
- TextEncryptor
Without further ado, let’s jump into code
class PhoneVerification
class << self
# time that user has before a code expires
EXPIRATION = 5.minutes
# an SMS can't be sent more frequently than that
TIME_BEFORE_RESEND = 30.seconds
# how many times can a user enter an invalid code
MAX_ATTEMPTS = 5
# if a user has entered an invalid code
# this is how long he has to wait before sending a new one
TIME_BEFORE_RETRY = 10.minutes
# once a phone is verified, a phone token
# needs to be used within that time frame
PHONE_TOKEN_EXPIRATION = 1.hour
# how many digits there are in the verification code
CODE_LENGTH = 6
# Sends a verification code to a given phone number
def send_verification(phone:)
raise StandardError, "Phone can't be blank" if phone.blank?
raise StandardError, "Please enter a valid phone" unless PhoneValidator.valid?(phone)
# check if a code was already sent to this phone number
existing_code = redis.get(phone_key(phone))
if existing_code
# if a code was already sent, we need to check
# that time has passed before re-sending it
# we don't want to allow users to send too many SMS
# because they cost money and could be abused
too_early = redis.get(resend_key(phone)).present?
raise StandardError, "Can't resend a code this soon" if too_early
# verify that the maximum number of attempts was not yet reached
attempts = redis.get(attempts_key(phone)).to_i
raise StandardError, "You reached the maximum number of attempts, please wait" if attempts > MAX_ATTEMPTS
# if time has passed, we re-send the same code
code = existing_code
else
# generate N digit code
code = SecureRandom.random_number(10 ** CODE_LENGTH).to_s.rjust(CODE_LENGTH, "0")
# save this code in redis under the given phone number
redis.set(phone_key(phone), code, ex: EXPIRATION, nx: true)
# set attempts to 0
redis.set(attempts_key(phone), "0", ex: EXPIRATION, nx: true)
end
# reset a timer for being able to send a code for a given phone number
redis.set(resend_key(phone), "true", ex: TIME_BEFORE_RESEND, nx: true)
content = "Verification code: #{code}"
# actually send the SMS using twilio or some other service
Messenger.call(phone: phone, message: content)
end
# Verifies that a code is valid
# and returns an encoded phone token
# that proves the phone was verified
def code_to_phone_token(phone:, code:)
raise StandardError, "Phone can't be blank" if phone.blank?
raise StandardError, "Please enter a valid phone" unless PhoneValidator.valid?(phone)
raise StandardError, "Code can't be blank" if code.blank?
real_code = redis.get(phone_key(phone))
if real_code.nil?
raise StandardError, "The code has expired"
end
attempts = redis.get(attempts_key(phone)).to_i
attempts += 1
# if number of attempts has exceeded the threshold
# then don't let a code to be sent until some time passes
if attempts > MAX_ATTEMPTS
# prolong code and attempts expiration,
# user won't be able to send new code until this time passes
redis.set(phone_key(phone), real_code, ex: TIME_BEFORE_RETRY)
redis.set(attempts_key(phone), attempts.to_s, ex: TIME_BEFORE_RETRY)
raise StandardError, "You reached the maximum number of attempts, please wait"
end
is_valid = ActiveSupport::SecurityUtils.secure_compare(code, real_code)
unless is_valid
# prolong phone key expiration
redis.set(phone_key(phone), real_code, ex: EXPIRATION)
# save updated attempts count
redis.set(attempts_key(phone), attempts.to_s, ex: EXPIRATION)
raise StandardError, "The code is invalid"
end
# contents of the phone token
# using this token we can always lookup a user
# by phone and make sure the token hasn't expired
payload = {
phone: PhoneValidator.clean(phone),
iat: Time.now.to_i
}
# the only way to generate this token is if you know the secret key
TextEncryptor.encrypt(JSON.dump(payload))
end
# Exchange phone token to a phone,
# later this phone can be used to sign-in / sign-up a user
def phone_token_to_phone(phone_token)
payload = begin
JSON.parse(TextEncryptor.decrypt(phone_token)).symbolize_keys
rescue
raise StandardError, "The phone token is invalid"
end
# make sure the token hasn't expired yet
issued_at = Time.at(payload[:iat])
if issued_at < PHONE_TOKEN_EXPIRATION.ago
raise StandardError, "The phone token is no longer valid"
end
payload[:phone]
end
private
def phone_key(phone)
"phone_verification_#{PhoneValidator.clean(phone)}"
end
def resend_key(phone)
"#{phone_key(phone)}_resend"
end
def attempts_key(phone)
"#{phone_key(phone)}_attempts"
end
def redis
RedisClient.instance
end
end
end
You can read the full code at https://github.com/TheRusskiy/rails-phone-auth.
Thank you for reading!
p.s. I recommend against using SMS as a sole means of authentication for security-critical apps such as banking, admin dashboards and other systems where gaining access to a single account can give a lot of value, only use it in conjunction with passwords. A dedicated attacker can spoof SMS of a specific user.