How to Implement TOTP 2FA in Rails using ROTP
Monday, October 4th 2021
Today, we're going to expand upon the work we did for implementing API key authentication in Rails and add two-factor authentication into our app's authentication flow. We're going to cover how to implement a flexible second factor model, which can be extended to support other types of second factors such as backup codes as well as U2F hardware keys (which we'll cover later in a follow up post.)
But first, an important note — friends don't let friends use SMS 2FA.
Thank you for coming to my TED talk.
Reviewing our current authentication flow
In the previous post, we covered how to implement API key authentication and standard CRUD operations on API keys. API keys are generated by sending the user's email and password, which we authenticate before generating a new API key and finally sending it to the user. API key tokens are securely hashed before being stored in the database.
We ended up implementing the following CRUD routes:
POST /tokens
to generate new tokensDELETE /tokens/:id
to revoke a tokenGET /tokens
to list all tokens
That's what we'll be working with. The code is available here on GitHub.
Creating a second factor table
To kick things off, let's create a new second_factors
table:
$ rails g migration CreateSecondFactors
Then fill the migration with the following:
class CreateSecondFactors < ActiveRecord::Migration[6.1] def change create_table :second_factors do |t| t.references :user, null: false t.text :otp_secret, null: false, index: { unique: true } t.boolean :enabled, null: false, default: false t.timestamps end endend
Then we'll apply the migration:
$ rails db:migrate
Creating a second factor model
Before we go any further, we'll need to add the ROTP gem to our
app's Gemfile
so that we can generate and verify one-time-passwords, or OTPs:
+gem 'rotp'
Next, we'll create our SecondFactor
model:
class SecondFactor < ApplicationRecord OTP_ISSUER = 'keygen.example' encrypts :otp_secret belongs_to :user before_create :generate_otp_secret validates :user, presence: true scope :enabled, -> { where(enabled: true) } def verify_with_otp(otp) totp = ROTP::TOTP.new(otp_secret, issuer: OTP_ISSUER) totp.verify(otp.to_s) end private def generate_otp_secret self.otp_secret = ROTP::Base32.random endend
Note the verify_with_otp
method. We'll be using a timed-one-time-password, or TOTP. These
one-time passwords will refresh, or "rotate", every 30 seconds. Our method here is concerned with
verifying the provided TOTP, while an authenticator app will be the one actually generating
the TOTP for the end-user.
Next, we'll update our User
model. We're going to define a has-many relationship for a
user's second factors, to be able to support multiple second factor types, e.g. TOTP,
backup codes, or hardware keys.
Let's go ahead and add the second_factors
association:
class User < ApplicationRecord has_many :api_keys, as: :bearer+ has_many :second_factors has_secure_password end
Managing second factors
Next up, we'll want to create a few CRUD (create-read-update-destroy) routes for managing a user's second factors. To do that, we'll want to add a new route:
Rails.application.routes.draw do resources :api_keys, path: 'api-keys', only: %i[index create destroy]+ resources :second_factors, path: 'second-factors' end
Then we'll create our SecondFactorsController
:
class SecondFactorsController < ApplicationController prepend_before_action :authenticate_with_api_key! def index
render json: SecondFactorResource.new(current_bearer.second_factors) end def show
second_factor = current_bearer.second_factors.find(params[:id]) render json: SecondFactorResource.new(second_factor) end def create second_factor = current_bearer.second_factors.new # Verify second factor if enabled, otherwise verify password. if current_bearer.second_factor_enabled? raise UnauthorizedRequestError, message: 'second factor must be valid', code: 'OTP_INVALID' unless current_bearer.authenticate_with_second_factor(otp: params[:otp]) else raise UnauthorizedRequestError, message: 'password must be valid', code: 'PWD_INVALID' unless current_bearer.authenticate(params[:password]) end second_factor.save! render json: SecondFactorResource.new(second_factor), status: :created end def update
second_factor = current_bearer.second_factors.find(params[:id]) # Verify this particular second factor (which may not be enabled yet). raise UnauthorizedRequestError, message: 'second factor must be valid', code: 'OTP_INVALID' unless second_factor.verify_with_otp(params[:otp]) second_factor.update!(enabled: params[:enabled]) render json: SecondFactorResource.new(second_factor) end def destroy
second_factor = current_bearer.second_factors.find(params[:id]) # Verify user's second factor if currently enabled. if current_user.second_factor_enabled? raise UnauthorizedRequestError, message: 'second factor must be valid', code: 'OTP_INVALID' unless current_bearer.authenticate_with_second_factor(otp: params[:otp]) end second_factor.destroy endend
Let's start with the create part in CRUD. Our create method is going to initialized a new second factor for the user, and then either verify the user's current second factor, or verify their password, before saving the second factor to the database.
Before we test it out, we're going to need to add a couple methods to our User
model:
class User < ApplicationRecord has_many :api_keys, as: :bearer has_many :second_factors + def second_factor_enabled? + second_factors.enabled.any?+ end + def authenticate_with_second_factor(otp:) + return false unless+ second_factor_enabled?+ + # We only allow a single 2FA key right now, but we may allow more later,+ # e.g. multiple 2FA keys, backup codes, or U2F.+ second_factor = second_factors.enabled.first+ + second_factor.verify_with_otp(otp)+ end has_secure_password end
The first method will be used to check if the user has a second factor enabled, and the second method will let us authenticate a user's enabled second factor. To test it out, we'll go ahead and generate an API key so we can make a few subsequent requests:
$ curl -X POST http://localhost:3000/api-keys \# < HTTP/1.1 201 Created# {# "id": 1,# "token": "e6e11a88df4db29958214ac9c48868f0",# "created_at": "2021-10-04T14:22:56.532Z",# "updated_at": "2021-10-04T14:22:56.532Z"# }
Next, let's use that token
to add a second factor for the current user:
$ curl -X POST http://localhost:3000/seconds-factors \ -H 'Authorization: Bearer e6e11a88df4db29958214ac9c48868f0'# < HTTP/1.1 401 Unauthorized# {# "error": {# "message": "password must be valid",# "code": "PWD_INVALID"# }# }
Ah yeah —
Our controller requires a password if a second factor has not been added yet. Let's adjust
our request to include the user's password
:
$ curl -X POST http://localhost:3000/seconds-factors \ -H 'Authorization: Bearer e6e11a88df4db29958214ac9c48868f0' \ -d password=secret# < HTTP/1.1 201 Created# {# "id": 1,# "otp_secret": "V63MJH44EZXT5FAQSRZFVQVSNCI62UDJ",# "enabled": false,# "created_at": "2021-10-04T14:26:30.352Z",# "updated_at": "2021-10-04T14:26:30.352Z"# }
Success! See that otp_secret
? Somehow, we'll need to get that value into our TOTP authenticator app.
Most authenticator apps will let you input the secret by hand, but that's error prone and a terrible
user-experience. Instead, what we can do is utilize a QR code drawing library to render the otp_secret
client-side, which can be scanned and stored in the TOTP authenticator app.
(Don't have a TOTP authenticator app? Check out Authy.)
But we can't just send the raw otp_secret
to the client — QR code scanners don't understand random
strings.
We'll actually need to generate a provisioning URI, which is a format QR code scanners can
understand. Let's adjust our SecondFactor
model to do so:
class SecondFactor < ApplicationRecord OTP_ISSUER = 'keygen.example' belongs_to :user before_create :generate_otp_secret validates :user, presence: true scope :enabled, -> { where(enabled: true) } + def provisioning_uri + return nil if+ enabled?+ + totp = ROTP::TOTP.new(otp_secret, issuer: OTP_ISSUER)+ + totp.provisioning_uri(user.email)+ end def verify_with_otp(otp) totp = ROTP::TOTP.new(otp_secret, issuer: OTP_ISSUER) totp.verify(otp.to_s) end private def generate_otp_secret self.otp_secret = ROTP::Base32.random end end
Then we'll want to send that provisioning_uri
to the client, instead of the otp_secret
. Then
we'll use that value within a QR code rendering component. For example, with react-qr-svg
,
that'd look something like this:
import { QRCode } from 'react-qr-svg' <QRCode value={provisioning_uri} />
So let's go ahead and retrieve our second factor and check out the provisioning_uri
:
$ curl -X GET http://localhost:3000/seconds-factors/1 \ -H 'Authorization: Bearer e6e11a88df4db29958214ac9c48868f0'# < HTTP/1.1 200 OK# {# "id": 1,# "otp_secret": "V63MJH44EZXT5FAQSRZFVQVSNCI62UDJ",# "provisioning_uri": "otpauth://totp/keygen.example:zeke%40keygen.example?secret=V63MJH44EZXT5FAQSRZFVQVSNCI62UDJ&issuer=keygen.example",# "enabled": false,# "created_at": "2021-10-04T14:26:30.352Z",# "updated_at": "2021-10-04T14:26:30.352Z"# }
Again, we'd want to render that URI into a QR code that the end-user can scan and add to their authenticator app. This will also be used in the second factor verification process, which we'll cover in a minute.
Adjusting our authentication flow
Next, we're going to want to update our authentication flow to verify a user's second factor before authenticating the user. In our case, our only authentication endpoint is the one where you generate a new API key, covered in an earlier post.
Let's open up our ApiKeysController
and verify the user's second factor:
class ApiKeysController < ApplicationController prepend_before_action :authenticate_with_api_key!, only: %i[index destroy] def index
render json: ApiKeyResource.new(current_bearer.api_keys) end def create authenticate_with_http_basic do |email, password| user = User.find_by(email: email) + # Request or verify user's second factor if enabled. + if user&.second_factor_enabled?+ otp = params[:otp]+ raise UnauthorizedRequestError, message: 'second factor is required', code: 'OTP_REQUIRED' unless+ otp.present?+ + raise UnauthorizedRequestError, message: 'second factor is invalid', code: 'OTP_INVALID' unless+ user.authenticate_with_second_factor(otp: otp)+ end if user&.authenticate(password) api_key = user.api_keys.create!(token: SecureRandom.hex) render json: ApiKeyResource.new(api_key), status: :created and return end end render status: :unauthorized end def destroy
api_key = current_bearer.api_keys.find(params[:id]) api_key.destroy end end
We're added a couple assertions to our authentication flow:
- When the user has a second factor, we assert that an
otp
parameter be provided. - When the user has a second factor, we assert that the
otp
parameter is valid.
Why the 2 separate steps for this? Simple: to make things easier on the front-end side of things. Sending 2 different error codes, one for when the OTP is required but missing, and one where the OTP was provided but invalid, allows us to adjust our login UI accordingly.
For example, when the user is logging in and we get back an OTP_REQUIRED
error code, we can
prompt the user for their TOTP using a friendly UI. But if we receive the OTP_INVALID
error
code, we can display an error message instead.
Okay, so we've added a second factor — what happens when we authenticate?
*drum roll...*
$ curl -X POST http://localhost:3000/api-keys \# < HTTP/1.1 201 Created# {# "id": 2,# "token": "d389df3ee7a31ab3510b0f944a291c0a",# "created_at": "2021-10-04T14:22:56.532Z",# "updated_at": "2021-10-04T14:22:56.532Z"# }
Hmm. Shouldn't we have been prompted for a second factor?
Well, no. Because our second factor isn't enabled yet.
Let's open up our SecondFactorsController
and look at how we can enable our user's second
factor. We'll be working within the update
method:
class SecondFactorsController < ApplicationController prepend_before_action :authenticate_with_api_key! def index
render json: SecondFactorResource.new(current_bearer.second_factors) end def show
second_factor = current_bearer.second_factors.find(params[:id]) render json: SecondFactorResource.new(second_factor) end def create
second_factor = current_bearer.second_factors.new # Verify second factor if enabled, otherwise verify password. if current_bearer.second_factor_enabled? raise UnauthorizedRequestError, message: 'second factor must be valid', code: 'OTP_INVALID' unless current_bearer.authenticate_with_second_factor(otp: params[:otp]) else raise UnauthorizedRequestError, message: 'password must be valid', code: 'PWD_INVALID' unless current_bearer.authenticate(params[:password]) end second_factor.save! render json: SecondFactorResource.new(second_factor), status: :created end def update second_factor = current_bearer.second_factors.find(params[:id]) # Verify this particular second factor (which may not be enabled yet). raise UnauthorizedRequestError, message: 'second factor must be valid', code: 'OTP_INVALID' unless second_factor.verify_with_otp(params[:otp]) second_factor.update!(enabled: params[:enabled]) render json: SecondFactorResource.new(second_factor) end def destroy
second_factor = current_bearer.second_factors.find(params[:id]) # Verify user's second factor if currently enabled. if current_user.second_factor_enabled? raise UnauthorizedRequestError, message: 'second factor must be valid', code: 'OTP_INVALID' unless current_bearer.authenticate_with_second_factor(otp: params[:otp]) end second_factor.destroy endend
Here, we're verifying the current second factor using an OTP, to assert that the end-user has correctly
set up their authenticator app. To test, we'll want to send a PATCH
request to update our second
factor's enabled
attribute:
$ curl -X PATCH http://localhost:3000/seconds-factors/1 \ -H 'Authorization: Bearer e6e11a88df4db29958214ac9c48868f0' \+ -d enabled=1 # < HTTP/1.1 401 Unauthorized # { # "error": { # "message": "second factor must be valid", # "code": "OTP_INVALID" # } # }
Well, that didn't work. It didn't work because our update controller requires us to send an OTP, in order to, like I mentioned a second ago, verify that we correctly set up our second factor within an authenticator app. Because remember, once this second factor is enabled, the user will not be able to authenticate without an OTP moving forward. So if things are not set up correctly, then the user gets locked out of their account.
To test, you can go ahead and take this time to render the provisioning URI into a QR code and scan it with your authenticator app if that works for you, but for the sake of time, we're going to use the Rails console instead:
$ rails c> s = SecondFactor.first> totp = ROTP::TOTP.new(s.otp_secret)> totp.now# => 737880
Now, anytime we want a new OTP, we can call totp.now
in our Rails console. But we have to be quick!
The OTPs only last for about 30 seconds.
So let's try again, but with an OTP this time:
$ curl -X POST http://localhost:3000/seconds-factors \ -H 'Authorization: Bearer e6e11a88df4db29958214ac9c48868f0' \ -d enabled=1 \+ -d otp=737880 # < HTTP/1.1 200 OK # { # "id": 1, # "enabled": true, # "created_at": "2021-10-04T14:26:30.352Z", # "updated_at": "2021-10-04T15:12:13.328Z" # }
We can see the enabled
attribute now shows enabled, so let's go ahead and try authenticating
one more time:
$ curl -X POST http://localhost:3000/api-keys \# < HTTP/1.1 401 Unauthorized# {# "error": {# "message": "second factor is required",# "code": "OTP_REQUIRED"# }# }
Great, we got the expected OTP_REQUIRED
code! But what happens when we provide an invalid
OTP? (Hopefully it throws an error.)
$ curl -X POST http://localhost:3000/api-keys \+ -d otp=000000 # < HTTP/1.1 401 Unauthorized # { # "error": { # "message": "second factor is invalid", # "code": "OTP_INVALID" # } # }
Perfect — our expected OTP_INVALID
code. And lastly, what happens when we provide an OTP
that's valid? Let's generate a new OTP code with totp.now
in our Rails console session
and then try it out:
$ curl -X POST http://localhost:3000/api-keys \+ -d otp=621061 # < HTTP/1.1 201 Created # { # "id": 3, # "token": "1d66e6186fafecc09609fc45b207f5bd", # "created_at": "2021-10-04T15:18:08.917Z", # "updated_at": "2021-10-04T15:18:08.917Z" # }
Success! We've successfully implemented TOTP 2FA verification into our app's normal authentication flow. This is great because not only is TOTP 2FA free, but it's more secure than SMS 2FA.
Fixing a vulnerability
Before we close, we need to resolve a vulnerability in our OTP implementation. Right now, OTP tokens can be reused. We need to ensure that OTPs (one-time-passwords) are actually, as the name would suggest, one-time passwords.
To do so, we'll need to add a new column to our second_factors
table:
$ rails g migration AddOtpVerifiedAtToSecondFactors
We'll add an otp_verified_at
column to keep track of the last verification time:
class AddOtpVerifiedAtToSecondFactors < ActiveRecord::Migration[6.1] def change add_column :second_factors, :otp_verified_at, :datetime, null: true endend
Next, we'll apply our migration:
$ rails db:migrate
Lastly, we'll want to adjust our OTP verification method to utilize the otp_verified_at
column, and also automatically update it when a successful verification occurs:
class SecondFactor < ApplicationRecord OTP_ISSUER = 'keygen.example'
belongs_to :user before_create :generate_otp_secret validates :user, presence: true scope :enabled, -> { where(enabled: true) } def provisioning_uri
return nil if enabled? totp = ROTP::TOTP.new(otp_secret, issuer: OTP_ISSUER) totp.provisioning_uri(user.email) end def verify_with_otp(otp) totp = ROTP::TOTP.new(otp_secret, issuer: OTP_ISSUER)- - totp.verify(otp.to_s) + ts = totp.verify(otp.to_s, after: otp_verified_at.to_i) + + update(otp_verified_at: Time.at(ts)) if+ ts.present?+ + ts end private
def generate_otp_secret self.otp_secret = ROTP::Base32.random end end
And that's it! ROTP will now assert that any provided TOTP token was generated after otp_verified_at
,
preventing OTP reuse.
Let's recap before you go.
Conclusion
Today we've covered how to implement TOTP 2FA into our app's authentication flow. Using the ROTP gem made this relatively painless. We also identified and fixed a vulnerability in our 2FA implementation.
If you have password management routes, such as password resets, you'll want to make sure that you're verifying a user's second factor before allowing such actions.
The full example app is available on GitHub.
Until next time.
If you find any errors in my code, or if you can think of ways to improve things, ping me via Twitter.