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 tokens
  • DELETE /tokens/:id to revoke a token
  • GET /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
end
end

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'
 
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
end
end

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
end
end

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 retreive 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:

  1. When the user has a second factor, we assert that an otp parameter be provided.
  2. 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
end
end

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 \
-u [email protected]:secret \
+ -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 \
-u [email protected]:secret \
+ -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
end
end

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.