How to build a one-time passcode protected conference line with Twilio Verify and Ruby
Phil Nash
Posted on August 12, 2020
We've seen how to build a conference line and then protect it with a static passcode. However, passcodes can be guessed or leaked, especially if they are reused over time. An alternative is to make a list of numbers that are permitted to join the call. But, since spoofing phone numbers is relatively easy, this still may not protect you.
A one-time passcode (OTP) sent to a caller's phone or email, can verify they are who they say they are and increase the security of your conference line once more.
In this post we will take the Rails application we previously developed and add a conference line secured in two ways. We will:
- Ensure that the caller is a known participant by checking their caller ID against a list of permitted phone numbers
- Send them an OTP using Twilio Verify which they then have to enter correctly to ensure they aren't spoofing the number
What you'll need
In order to code along with this tutorial you will need:
- Ruby and Bundler installed
- A Twilio account (if you don't have one yet, sign up for a new Twilio account here and receive $10 credit when you upgrade)
- A Twilio phone number that can receive incoming calls
- ngrok for testing webhooks with our local application
Once you've got all that, we can get started.
On the Rails
We will use the existing Rails application that we've added a couple of conference lines to so far. We'll continue to add to that app in this post. If you don't already have the application, download or clone it from GitHub.
git clone https://github.com/philnash/conference-calls-on-rails.git -b passcode-protected-conference
cd conference-calls-on-rails
Install the dependencies:
bundle install
Start the server to make sure everything is working as expected:
bundle exec rails server
While the server is running, you can make a POST request to the open conference call webhook endpoint to see it working. It should look a bit like this:
$ curl --data "" localhost:3000/calls
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="alice">Welcome to the conference call, let's dial you in right away.</Say>
<Dial>
<Conference>Thunderdome</Conference>
</Dial>
</Response>
This response is in TwiML and tells Twilio what to do with a call. If we set a phone number's incoming call webhook to the /calls
endpoint within this application, then anyone who calls will be welcomed and then entered into the conference call.
In the last blog post we added an endpoint that would enforce passcode protection on your conference call. This version is available at the /protected_calls/welcome
and /protected_calls/verify
routes.
Let's get started building verified called protection.
Protecting a call with a list of permitted callers
We're going to set the list of permitted callers using environment variables. To make that easier to manage we'll use the envyable gem. Open the Gemfile
and add the gem to the development and test group:
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 "envyable", "~> 1.2"
end
Install the dependency then run the envyable install task:
bundle install
bundle exec envyable install
The install task creates a config/env.yml
file, open that up and fill in two variables:
-
PERMITTED_CALLERS
: a comma separated list of numbers in e.164 format that will be allowed into your conference call -
MODERATOR
: the number of your conference call moderator, this will be the caller that starts and ends the conference call
PERMITTED_CALLERS: "+61422222222,+61433333333"
MODERATOR: "+61411111111"
Next, generate a new controller for our verified conference calls:
bundle exec rails generate controller verified_calls
Open your new controller located in app/controllers/verified_calls_controller.rb
. The first thing we need to do in this controller is disable cross site request forgery (CSRF) protection. This controller will be used to respond to incoming webhooks, which cannot include a CSRF token. We can protect webhook endpoints in our Rails application by validating Twilio's request signature using Rack middleware instead.
To disable the CSRF protection, add the following line to the controller class:
class VerifiedCallsController < ApplicationController
skip_before_action :verify_authenticity_token
end
We're going to add two actions to this controller, one that will check whether the caller is on our permitted list of callers and the other to enter them into the conference. This could be done within one action, but when we add OTP verification later we will need two actions.
Create the welcome
action. We'll use the helpers from the twilio-ruby library to generate the TwiML response and render it as XML.
class VerifiedCallsController < ApplicationController
skip_before_action :verify_authenticity_token
def welcome
twiml = Twilio::TwiML::VoiceResponse.new
render :xml
end
end
In the welcome
action we will check whether the caller is permitted to join the conference by checking against the PERMITTED_CALLERS
and MODERATOR
environment variables. We can write a private method to make what it is doing more obvious.
class VerifiedCallsController < ApplicationController
skip_before_action :verify_authenticity_token
def welcome
twiml = Twilio::TwiML::VoiceResponse.new
render :xml
end
private
def participant_permitted?(from)
is_moderator?(from) || ENV["PERMITTED_CALLERS"].split(",").include?(from)
end
def is_moderator?(from)
from == ENV["MODERATOR"]
end
end
Use the participant_permitted?
method to check that the caller's number, the From
parameter in the request, is allowed and then redirect to the next path. If the number isn't recognised, we will use [<Say>](https://www.twilio.com/docs/voice/twiml/say)
to deliver a message to the caller and [<Hangup>](https://www.twilio.com/docs/voice/twiml/hangup)
to end the call.
def welcome
twiml = Twilio::TwiML::VoiceResponse.new
if participant_permitted?(params["From"])
twiml.redirect(verified_calls_verify_path)
else
twiml.say(voice: "alice", message: "Sorry, I don't recognize the number you're calling from.")
twiml.hangup
end
render xml: twiml
end
We will use the path verified_calls_verify_path
which we are yet to define. We will need the verify
action for that. For now, this action won't verify anything, it will just add the caller to the conference. We'll use some of the <Conference>
TwiML attributes available here to control the call more effectively. The boolean attributes start_conference_on_enter
and end_conference_on_exit
are useful for moderated conferences. In this case, all participants will be held listening to hold music until the moderator dials in and joins the conference. The call will also be terminated once the moderator hangs up. Place this method after the welcome
method.
def verify
twiml = Twilio::TwiML::VoiceResponse.new
twiml.say(voice: "alice", message: "Thank you, joining the conference now.")
twiml.dial do |dial|
dial.conference(
"Verified",
start_conference_on_enter: is_moderator?(params["From"]),
end_conference_on_exit: is_moderator?(params["From"]),
)
end
render xml: twiml
end
Let's define the routes for these actions now. Open config/routes.rb
and add POST
routes for both actions in this controller.
Rails.application.routes.draw do
resources :calls, only: [:create]
post 'protected_calls/welcome', to: 'protected_calls#welcome'
post 'protected_calls/verify', to: 'protected_calls#verify'
post 'verified_calls/welcome', to: 'verified_calls#welcome'
post 'verified_calls/verify', to: 'verified_calls#verify'
end
Testing out the line so far
Let's hook this application up to a phone number and test that it's working. Then we can add OTP verification to make the line extra secure.
Make sure that your own phone number is set as either in either the MODERATOR
or PERMITTED_PARTICIPANTS
variables in config/env.yml
Start the application with:
bundle exec rails server
The application will start up on localhost:3000. Next, start ngrok so that we can get a public URL for this application to use with Twilio's webhooks.
ngrok http 3000 --host-header "localhost:3000"
You will get a URL like https://RANDOM_SUBDOMAIN.ngrok.io
, put it together with the path to the welcome action we just wrote to make https://RANDOM_SUBDOMAIN.ngrok.io/verified_calls/welcome
. Open the Twilio console and edit the phone number you chose to use for this application. Add the URL as the voice webhook for when calls come in and save the number.
Now call your number; you will be greeted and allowed to enter the conference call.
Remove your number from the permitted participants and call up again. This time you will be rejected from the conference call.
Securing the conference call with an OTP
Now we're going to make sure that callers are definitely who they say they are by using Twilio Verify to send them an OTP that they will have to enter.
For this feature, we are going to need to store some more details in the environment. We're going to make requests to the API, so we'll need the Account Sid and Auth Token from your Twilio console. Add them to config/env.yml
like so:
PERMITTED_CALLERS: "+61422222222,+61433333333"
MODERATOR: "+61411111111"
TWILIO_ACCOUNT_SID: YOUR_ACCOUNT_SD
TWILIO_AUTH_TOKEN: YOUR_AUTH_TOKEN
We also need a Verify Service Sid. To get one, create a new Verify Service in your Twilio console.
Grab the Verify Service Sid and add it to config/env.yml
too.
PERMITTED_CALLERS: "+61422222222,+61433333333"
MODERATOR: "+61411111111"
TWILIO_ACCOUNT_SID: YOUR_ACCOUNT_SD
TWILIO_AUTH_TOKEN: YOUR_AUTH_TOKEN
VERIFY_SERVICE_SID: YOUR_VERIFY_SERVICE_SID
Head back to app/controllers/verified_calls_controller.rb
. The plan now is for the welcome
action to check whether the caller is permitted to join the conference and, if they are, start a verification and ask them to enter the passcode.
Starting an SMS verification with the Twilio Ruby library looks like this:
client = Twilio::REST::Client.new(ENV["TWILIO_ACCOUNT_SID"], ENV["TWILIO_AUTH_TOKEN"])
service = client.verify.services(ENV["VERIFY_SERVICE_SID"])
service.verifications.create(to: NUMBER, channel: "sms")
When they enter the code Twilio will make a request to the verify
action, passing the digits of the code as the Digits
parameter. We'll then use the code to check the verification and if it is approved send the caller into the conference call. If the code is incorrect we'll direct them back to the welcome
action and ask them to enter the code again.
Checking a verification looks like this:
client = Twilio::REST::Client.new(ENV["TWILIO_ACCOUNT_SID"], ENV["TWILIO_AUTH_TOKEN"])
service = client.verify.services(ENV["VERIFY_SERVICE_SID"])
check = service.verification_checks.create(to: NUMBER, code: CODE)
check.status == "approved"
Let's start by adding some more private helper methods: one to authorise an API client with your Account Sid and Auth Token and return the Verify service using the Verify Service Sid, and one to use the Verify service to start the verification for the caller and one to check the result:
private
def verify_service
client = Twilio::REST::Client.new(ENV["TWILIO_ACCOUNT_SID"], ENV["TWILIO_AUTH_TOKEN"])
client.verify.services(ENV["VERIFY_SERVICE_SID"])
end
def start_verification(number)
verify_service.verifications.create(to: number, channel: "sms")
end
def verification_approved?(number, code)
verify_service.verification_checks.create(to: number, code: code).status == "approved"
end
def participant_permitted?(from)
In the welcome
action, replace the redirect with the call to start_verification
using the caller's number and then use <Gather>
and <Say>
to request the user input the code.
def welcome
twiml = Twilio::TwiML::VoiceResponse.new
if participant_permitted?(params["From"])
start_verification(params["From"])
twiml.gather(action: verified_calls_verify_path) do |gather|
gather.say(voice: "alice", message: "Please enter the code to enter the conference followed by the hash.")
end
else
twiml.say(voice: "alice", message: "Sorry, I don't recognize the number you're calling from.")
twiml.hangup
end
render :xml
end
When the caller enters the verification code and presses the hash, Twilio will send the request to the verify
action. In the verify
action we gate the conference entrance with the verification check. If the check succeeds we enter the conference as before and if it fails we redirect back to the welcome
action and start again.
def verify
twiml = Twilio::TwiML::VoiceResponse.new
if verification_approved?(params["From"], params["Digits"])
twiml.say(voice: "alice", message: "Thank you, joining the conference now.")
twiml.dial do |dial|
dial.conference(
"Verified",
start_conference_on_enter: is_moderator?(params["From"]),
end_conference_on_exit: is_moderator?(params["From"]),
)
end
else
twiml.say(voice: "alice", message: "Sorry, that code is incorrect.")
twiml.redirect(verified_calls_welcome_path)
end
render :xml
end
Make sure you are in the permitted callers list again and restart the application. Call it up and you will receive an SMS with a verification code. If you enter the code incorrectly you will be asked for it again. Enter the code correctly and you will be entered into the conference, safe in the knowledge that you are indeed who you said you were.
What's next after protecting a conference line with an OTP?
Over these blog posts you've seen how to build an open conference line in Rails, how to protect that conference line with a passcode and now how to protect it with a list of permitted callers and Twilio Verify. The code for the application, which has all 3 types of conference line, can be found on GitHub.
To go further, you could expand this application with a database and save different conferences with different lists of permitted callers and moderators.
Twilio Verify can be used to protect more than conference calls with an SMS verification code. Check out how you can:
- Expand your OTP channels with email
- Verify your users' phone numbers in your Rails web application
- Sanitize phone numbers before sending mass alerts
Any further questions? I'd be glad to help, just drop me a note on Twitter at @philnash or over email at philnash@twilio.com.
Posted on August 12, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.