Simple OTP Authentication with Rails API and Twilio - PART II
From our previous article, we looked at how to integrate the Twilio service with our Rails API project leveraging on Sidekiq and Redis. In this article, we would be creating the following:
- An Authentication/Verification service.
- A worker file for handling our background process.
- An send otp code route to test on Postman.
- Integrating Rspec and writing test for our authentication service.
Let's get started. To begin, open the rails project from the last article. twilio_otp_authentication
.
Next, let's add the service SID, Account SID and Auth token to our .env
file created in the last article. The .env file should look like this:
REDIS_URL=redis://127.0.0.1:6379/0
AUTH_TOKEN=auth_token_from_project_console
SERVICE_SID=service_sid_from_verify_service_page
ACCOUNT_SID=account_sid_from_project_console
Next, let's integrate RSpec to our project. To do that, we would add rspec-rails
and factory_bot_rails
gems to the development and test section of our Gemfile. Our Gemfile would look like this:
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
gem 'rspec-rails', '~> 3.9'
gem 'factory_bot_rails', '~> 5.1', '>= 5.1.1'
end
Next, run bundle install
bundle install
Next, run the following on your terminal to generate the rspec and rails helper files
rails generate rspec:install
Next, go to the rspec folder and create a new folder called support
.
Next, create a file factory_bot.rb
in the support folder with the following content
# frozen_string_literal: true
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
end
Next, Go to your rails_helper.rb
in the spec folder and uncomment the line
Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f }
Next, we would stub the Twilio verify service to help us with testing. To do this, we would create a file called fake_twilio.rb
file in our lib folder with the following content
class FakeTwilio
def initialize(_account_sid, _auth_token)
end
def verify
@verify ||= Verify.new
end
end
class Verify
def initialize
end
def services(sid)
@services = Services.new
end
end
class Services
def initialize
end
def verifications
@verifications = Verifications.new
end
def verification_checks
@verification_checks = VerificationChecks.new
end
end
class Verifications
def initialize
end
def create(to:, channel:)
return { to: to, channel: channel }
end
end
class VerificationChecks
def initialize
end
def create(to:, code:)
return OpenStruct.new(status: 'approved')
end
end
Next we would add the stub to our spec_helper.rb
file in our rspec folder. To do that, let's use the code below
config.before(:each) do
stub_const("Twilio::REST::Client", FakeTwilio)
end
Next, open application.rb file in the config folder. Add the following line just under config.load_defaults 5.2
.
config.eager_load_paths << Rails.root.join('lib')
Before we move folder, let add faker gem to help us autogenerate user data for our User attribute. Add this line in the development and test section.
gem 'faker'
Next, let's add a User factory. To do that, create a factory
folder in the spec
folder. Create a users.rb file in the factory folder with the following content.
FactoryBot.define do
factory :user do
name { Faker::Name.name }
phone_number { Faker::PhoneNumber.phone_number }
country_code { '+234' }
end
end
Next, let's create the users controller request test file. To do that, let's create a controller folder in the spec folder. In the controller folder, let's create a users_controller_spec.rb file. With the following content
require 'rails_helper'
RSpec.describe UsersController, type: :request do
it 'should send otp code to user phone number' do
user = build(:user)
post '/send_otp_code', params: {user: {phone_number: user.phone_number, country_code: user.country_code}}
payload = JSON.parse(response.body)
expect(response.status).to eq 200
expect(payload["message"]).to eq("An activation code would be sent to your phonenumber!")
expect(payload["country_code"]).to eq user.country_code.to_s
expect(payload["phone_number"]).to eq user.phone_number.to_s
end
end
Next, run rspec
.
On your terminal
rspec
This would give you an error that looks like this
From the error above, we need to add a POST /send_otp_code to our route.rb file. Let's go ahead and do that. Add this line inside your route.rb file in the config folder.
post '/send_otp_code', as: 'user_send_otp_code', to: 'users#send_code'
Next, run rspec again on your terminal.
rspec
This should throw another error like this
From the error above, we need to add the send_code action to our UserController. Let's go ahead and do that.
Open the controllers folder inside app folder. Open the users controller file and add the send_code action as shown below
def send_code
response = VerificationService.new(
user_params[:phone_number],
user_params[:country_code]
).send_otp_code
render json: {
phone_number: user_params[:phone_number],
country_code: user_params['country_code'],
message: response
}
end
The entire file should look like this
class UsersController < ApplicationController
def send_code
response = VerificationService.new(
user_params[:phone_number],
user_params[:country_code]
).send_otp_code
render json: {
phone_number: user_params[:phone_number],
country_code: user_params['country_code'],
message: response
}
end
private
def user_params
params.require(:user).permit(
:name, :email, :country_code, :phone_number
)
end
end
Next, run rspec again on the terminal
rspec
We should have another error uninitialized constant UsersController::VerificationService
. The terminal should look like this
We are making progress. Next let's create the VerificationService class. To do that, create a folder called services
inside the app folder and create a file called verification_service.rb
. The content of the file should look like this
class VerificationService
def initialize(phone_number, country_code)
@phone_number = phone_number
@country_code = country_code
@phone_number_with_code = "#{@country_code}#{@phone_number}"
end
def send_otp_code
SendPinWorker.perform_async(
@phone_number_with_code
)
'An activation code would be sent to your phonenumber!'
end
end
From this code we are getting this phone number and country code from params and then concatenating it to give us a complete valid phone number and we using a worker class to process the send pin code request. This prevents the SMS flow from happening on the main thread.
Next, let's run rspec again on the terminal
rspec
We should get another error on the terminal uninitialized constant VerificationService::SendPinWorker
. Next we would create the SendPinWorker class. To do that let's create a folder called workers
in the app folder. Next create the file, send_pin_worker.rb
with following content.
class SendPinWorker
include Sidekiq::Worker
def perform(phone_number_with_code)
account_sid = ENV['ACCOUNT_SID']
auth_token = ENV['AUTH_TOKEN']
service_sid = ENV['SERVICE_SID']
client = Twilio::REST::Client.new(account_sid, auth_token)
verification_service = client.verify.services(service_sid)
verification_service
.verifications
.create(to: phone_number_with_code, channel: 'sms')
end
end
From the above code snippet, we are initializing the Twilio Rest Client class with the account_sid and auth_token and then we are calling the verify services method with the service_sid. The verification service would trigger a create function to send the otp code to the provided phone number.
Next, let's run rspec again on the terminal
Hurrayππππ. We should see the test passing now.
Next, we would test our endpoint on Postman. To install postman on your machine. Check out this link to download Postman
Next, start the rails server, redis server and sidekiq To do that run the following on the terminal
rails s
Run this on another terminal
bundle exec sidekiq
Run this on another terminal
redis-server
Next, open postman and add the the url and the body of the request as shown in the image below
Next, hit send
to make the request. Check the response body for a response as shown below
Next, check your Messaging app on your phone device, you should have gotten a 4 digit code. Amazing right?
Next, we are going create another method in our class to verify the 4 digit code we just received. But before we do that, remember TDD π. Let add our test that verifies the 4 digit code and creates a new user on our database when code is verified. To do that, Open the users_controller_spec.rb file in the controller folder in our spec folder. Let add another assertion to test that the user get's created when the 4 digit code is verified.
Add the following code to your users_controller_spec.rb file
it 'should create a new verified user' do
user = build(:user)
post '/users', params: {user: {phone_number: user.phone_number, country_code: user.country_code, otp_code: "1234"}}
payload = JSON.parse(response.body)
auth_token = payload["auth_token"]
user_token = JsonWebToken.decode(auth_token)
expect(response.status).to eq 201
expect(payload["user"]["phone_number"]).to eq(user.phone_number)
expect(payload["user"]["country_code"]).to eq(user.country_code)
expect(user_token["user_id"]).to eq(User.first.id)
expect(payload["message"]).to eq "Phone number verified!"
end
Next, stop your rails server and run rspec
You should get an error on your terminal No route matches [POST] "/users"
Let's add the route to our route.rb file. Open the route.rb file in the config folder and add the following line under the first route created earlier.
resources :users, only: [:create]
run rspec
again on the terminal. We should get another error, The action 'create' could not be found for UsersController
. Let's add the create action to our UsersController.
def create
if verify_otp_code?
user = User.find_or_create_by(
country_code: user_params[:country_code],
phone_number: user_params[:phone_number]
)
token = JsonWebToken.encode(user_id: user.id)
render json: {
user: user,
auth_token: token,
message: 'Phone number verified!'
}, status: :created
else
render json: { data: {}, message: 'Please enter a valid phone number' },
status: :unprocessable_entity
end
rescue Twilio::REST::RestError
render json: { message: 'otp code has expired. Resend code' }, status: :unprocessable_entity
end
def verify_otp_code?
VerificationService.new(
user_params[:phone_number],
user_params[:country_code]
).verify_otp_code?(params['otp_code'])
end
From the code above, we are verifying the 4 digit code. If it is approved, we are creating a new user. Next, we are encoding the user id using JWT and returning the token with user information in our response. We are also rescuing any Twilio error.
run rspec
again on the terminal. You should see the error, undefined method `verify_otp_code?' for #<VerificationService:0x00007f84b44d5608>
. From the error, let's create the verify_otp_code method in the VerificationService class. Do that, add the following lines of code. In the initialize method of the service, add these
@account_sid = ENV['ACCOUNT_SID']
@auth_token = ENV['AUTH_TOKEN']
@service_sid = ENV['SERVICE_SID']
client = Twilio::REST::Client.new(@account_sid, @auth_token)
@verification_service = client.verify.services(@service_sid)
Next, create the verify_otp_code method as shown below
def verify_otp_code?(otp_code)
verification_check = @verification_service
.verification_checks
.create(to: @phone_number_with_code, code: otp_code)
verification_check.status == 'approved'
end
The entire VerificationService file looks like this
class VerificationService
def initialize(phone_number, country_code)
@phone_number = phone_number
@country_code = country_code
@account_sid = ENV['ACCOUNT_SID']
@auth_token = ENV['AUTH_TOKEN']
@service_sid = ENV['SERVICE_SID']
client = Twilio::REST::Client.new(@account_sid, @auth_token)
@verification_service = client.verify.services(@service_sid)
@phone_number_with_code = "#{@country_code}#{@phone_number}"
end
def send_otp_code
SendPinWorker.perform_async(
@phone_number_with_code
)
'An activation code would be sent to your phonenumber!'
end
def verify_otp_code?(otp_code)
verification_check = @verification_service
.verification_checks
.create(to: @phone_number_with_code, code: otp_code)
verification_check.status == 'approved'
end
end
Next, run rspec again.
You should get another error like this NameError: uninitialized constant UsersController::JsonWebToken
. Let's create the JsonWebToken class. To do that, we need to install jwt gem. Add this line to your Gemfile
gem 'jwt'
Run bundle install
bundle install
Next, let's create our JsonWebToken class in our lib folder. create a file json_web_token.rb
with the following content.
class JsonWebToken
SECRET_KEY = Rails.application.secrets.secret_key_base. to_s
# Token expires 5 years from now
def self.encode(payload, exp = 48.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, SECRET_KEY)
end
def self.decode(token)
decoded = JWT.decode(token, SECRET_KEY)[0]
HashWithIndifferentAccess.new decoded
end
end
From the code snippet above, I am creating two method encode and decode. The encode method convert the user.id into a JSON Web Token. The decode method converts it back to a hash that contains the user_id and the expiry information. Also, the token would expiry in 48 hours.
Next, let's run rspec
on the terminal again. The test should pass now. As shown in the image below.
Next, start the rails server, sidekiq and redis-server all in different terminals as mentioned earlier.
Next, we would test the endpoint on Postman. To do that, launch Postman and use the send_otp_code route to get the pin code. Next use the create user route to create a new user as shown in the image below:
STEP 1
STEP 2
STEP 3
Oops!π an error.
Let's fix this. This is because we are using phone_number as an integer but the integer value is about 10 digits which is above the range for the integer datatype. We need to bring in the big boys π. We need the bigint data type. To change our datatype for phone_number, we need a new migration file. Run the code below to generate a new migration file
rails g migration ChangeDatatypeInPhoneNumber
Next, open the migration file inside db > migrate
. Add the following line in the change method
change_column :users, :phone_number, :bigint
The migration file should look like this
class ChangeDatatypeInPhoneNumber < ActiveRecord::Migration[5.2]
def change
change_column :users, :phone_number, :bigint
end
end
Run rails db:migrate
Let's go to postman and repeat the steps(1-3) mention above. We should see our response on postman as shown below
And that is it!!!. π π π π π π. Wow! This is some lengthy article but I hope this explains the process. In the third part of this article, I would be showing you how to integrate this to our React Native Mapbox app so we can have some basic authentication flow before seeing our map. To check out the article that explains how to create our react native with Mapbox app, click on this link. See you in the next one!