Setting Up TOTP Two-Factor Authentication in Ruby on Rails Application

Two-factor authentication should be a standard feature in today's world. This guide will walk you through implementing it into your Rails application with help of Devise and a few other helpful gems.

No items found.
Author:

Mikołaj Bogucki

Prerequisites

This guide assumes that you are already familiar with the basic concepts of Ruby on Rails and Devise and have already set up an app with Devise.

— Existing Ruby on Rails project

— Installed and configured Devise

— Tailwind CSS in the assets pipeline (only for the view part)

— Turbo enabled

I’ll be using dry-rb monads and result matcher, so if you want this code to work without additional customization, you’ll need to install dry-monads and dry-matcher

Let’s start building — Basic configuration

We’ll base our implementation on a great gem called devise-two-factor providing basics of two-factor authentication.

Setting up active record encryption

Devise-two-factor uses ActiveRecord encrypted attributes to add an extra layer of security to the OTP secret. If you have already set up Active Record encryption, skip this step.

If you haven’t, you must generate a key set and configure your application to use it with Rails encrypted credentials or from another source, such as environment variables.

Then you can load the key set using Rails credentials.

Or set up environment variables if that is more convenient for your project.

Devise-two-factor setup

Add devise-two-factor gem to your project.

And then run the devise-two-factor generator (usually your MODEL will be User).

This generator will create a migration which adds a few fields to your model.

Edit the modelapp/models/MODEL.rb

— Addtwo_factor_authenticatable to the model Devise config

— Remove database_authenticatable from the model Devise config

Check if those changes were successful — this generator may fail if your model is complicated.

Keep in mind that loading both :database_authenticatable and :two_factor_authenticatable in a model is a security issue and should be avoided.

After running the generator, we have to run database migrations.

Devise configuration

The next step is configuring Devise params sanitizer to permit otp_attempt in sign_in params.

If you are using recoverable Devise strategy, make sure to disable automatic login after the password reset. Leaving this behavior enabled will result in a security flaw.

The last configuration is the allowed OTP drift, which defines the maximum allowed difference between the client and server clock.

Let’s test it before continuing to the next steps

First, we have to add the :otp_attempt field to the login page.

You can generate custom Devise views if you don’t have them in your app yet.

Then, let’s enable two-factor authentication for a user using the rails console.

Now, start the app and go to the login page. You can get the OTP code by calling #current_otp on User instance.

If everything is set up correctly, you will be able to log in using your OTP, but you won’t be able to log in without it.

Additional model fields

We’ll add a few more fields to our model to better track changes made to an account.

Thanks to this, we’ll know when the user has enabled two-factor authentication and track what two-factor method they use (in case we want to add other methods like SMS and email in the future).

Account Settings

Now, we’ll add an option to turn two-factor authentication on and off in the account settings.

New options in account settings

First, let’s create a new part of the account settings page for our two-factor settings. Most commonly, the account settings view is located in app/views/devise/registrations/edit.html.erb file; however, its location may differ depending on your custom Devise configuration.

This example uses Tailwind for styling; for now, links are just placeholders; we’ll replace them with proper paths after creating the controller.

Authenticator App settings when it’s disabled


*Authenticator App settings when it’s disabled*

Authenticator App settings when it’s enabled


*Authenticator App settings when it’s enabled*

Skeleton controller

Let’s create a skeleton controller with empty methods for now. As this tutorial progresses, we will add more functionality to it.

Next, add this controller and its methods to routes in whatever way suits you best.

Now is a good time to update paths in our account settings view.

Enabling two-factor authentication app

Let’s consider what a two-factor enabling flow should look like. Below, you’ll find an example flow from one of Lunar Logic apps

Your requirements might differ, so customize this flow according to your needs (e.g. sending a confirmation email, confirming it with a code sent via SMS, etc.).

Password reconfirmation view

We’ll begin by creating a form object for our password reconfirmation view.

Now we’ll create a new view that will be used to reconfirm user credentials.

And update SecondFactorController.

In case of a real app, the form is rendered in a modal using turbo


*In case of a real app, the form is rendered in a modal using turbo*

TOTP initialization

Before enabling two-factor authentication, we must display the OTP secret to the user. We can do that by displaying it as plain text or rendering a provisioning QR code. Let’s create a view dedicated to this purpose.

First, we will need a form object for this view.

Then we can create a view.

Then, we will create a TOTP initialization service. This service will check if the user provided the correct password and generate a new OTP secret — we will need it to create a QR code and display it to the user.

Next, we will use this service in the controller.

But wait, one thing is missing — provisioning QR code generation. Even though devise-two-factor provides a method for generating the provisioning URI, we need another gem to generate the QR code. We will use rqrcode.

Now we have to update our controller.

And here is how the step 3 of our flow looks like.

User can scan the QR code or enter the secret to add a new account to the authenticator app


*User can scan the QR code or enter the secret to add a new account to the authenticator app*

Google Authenticator screenshot


*Google Authenticator screenshot*

Enabling two-factor authentication

So now, after the user scans the QR code, adds the OTP secret to their authentication app, and enters the code into our form, we can proceed with enabling two-factor authentication.

Let’s create a new service to reduce clutter and decouple things from our controllers. I am using dry-rb monads and pattern matchers to keep the code readable and make error handling easier.

After creating the service we can use it in our controller.

🎉 Step 5 of our flow is ready!

Authenticator App settings when it’s enabled


*Authenticator App settings when it’s enabled*

Our controller so far.

Disabling two-factor authentication

What how should a two-factor disabling flow look like? You can begin with a straightforward one like this

Make sure to tailor it to your needs (ex. additional password reconfirmation, sending code via SMS or email)

One time code entry form

First let’s create form object for OTP confirmation.

Then we will use it in OTP confirmation view.

And in the controller.

Our form should be ready now!

Form is displayed inside modal using turbo


*Form is displayed inside modal using turbo*

Disabling two-factor authentication

As usual, we’ll begin by creating a service.

Then we’ll use it in the second factor controller.

And now we can disable two-factor authentication 🎉

Authenticator App settings when it’s disabled


*Authenticator App settings when it’s disabled*

Complete controller should be similar to this.

Showing OTP field only to users who have OTP enabled

We can achieve that in multiple ways, but in this tutorial, we’ll do that in a lazy way.

To begin we have to modify Devise SessionController. If you don’t have custom Devise controllers you can set them up by running Rails generator and following its instructions.

Let’s create OTP attempt partial.

Add an outlet where :otp_attempt field will appear.

Then, we will render :otp_attempt from SessionsController using turbo if the user has enabled two-factor authentication.

Result

Login page without OTP attempt field


*Login page without OTP attempt field*

OTP attempt field appears if user has enabled two factor authentication


*OTP attempt field appears if user has enabled two-factor authentication*

Improvements to consider

Backup Codes

We should provide users with a secondary way of two-factor authentication in case they lose a primary device or it malfunctions. Backup codes are a good and easy solution to this problem. If you are interested in details of implementing backup codes please see this post.

SMS Authentication

Even though sending one-time codes through SMS is less secure than the Authenticator App, it’s still prevalent among many users. You can consider adding it as a standalone two-factor authentication method or a backup measure in case the user loses access to the Authenticator App.

Admin Panel

Administration / Customer support should have an option to disable two-factor authentication. However, verifying user identity before doing so is of the utmost importance—do not skimp on staff training; humans are the weakest link, after all.

Want To Discuss
Your Next Project?

Our team is excited to work with you and create something amazing together.

let's talk

let's talk

More articles

We don’t just build software, we understand your business context, challenges, and users needs so everything we create, benefits your business.

thumbnail image for blog post
plus sign
thumbnail image for blog post
plus sign
thumbnail image for blog post
plus sign
Arrow