OTP 2FA in Ruby on Rails with ROTP

Implementing two factor auth with one time passwords is a great way to add additional security for your users.

What is OTP 2FA?


2FA or two factor authentication is when a system requires two steps to log a user in e.g. username and password, then a code sent via email or SMS.

OTP 2FA or one time password 2FA is a more user friendly solution as the code is generated on the users device by sharing a secret between the application and the user. To configure OTPs the user will first scan a QR code that contains the secret, with an application like Google Authenticator or 1Password. Once configured the users device will be able to generate an OTP which will be valid for a specified window of time. Normally 15-30 seconds.

2FA is the difference between having a storm trooper guarding your bat cave or just a keypad

Getting set up


With the rtop gem OTP 2FA is really simple to setup.

I’m going to assume you have a user setup with some pre-existing authentication system, most probably Devise. 

Let’s install rotp and rqrcode so we can generate qrcodes to scan from your favorite password manager.

# Gemfile
gem 'rotp'
gem 'rqrcode'

Configuring ROTP


To configure ROTP we need to add an `otp_secret` column and a `last_otp_at` column (integer, not date) to our User table which will be used to generate the next OTP for validation. 

bin/rails generate migration add_otp_secret_to_users otp_secret:string last_otp_at:integer
bin/rails db:migrate

Generating the secret and verifying the user's first OTP


Now we’re going to need to allow users to configure OTPs. Where you do this in your app is up to you, from a UX point of view there are two options

  1. OTP protection is mandatory
    • After signing up to the app, redirect the user to an OTP configuration page where they can setup their OTP Secret
  2. OTP protection is optional
    • Allow the user to turn on or off OTP protection
      • After turning on, redirect to an OTP configuration page where they can setup their OTP Secret
      • After turning off, delete the OTP Secret

To create an `otp_secret` we need to generate a base32 string using the ROTP lib and then verify the user has setup their authenticator correctly before saving the secret to ensure we don’t lock the user out of their account.

I will do this all in the controller so it’s easier to demo but I recommend abstracting somewhere else, for example form objects, service objects, or interactors.

# app/controllers/otp_secrets_controller.rb
class OtpSecretsController < ApplicationController
  def new
    @otp_secret = ROTP::Base32.random
    totp = ROTP::TOTP.new(
      @otp_secret, issuer: 'YourAppName'
    )
    @qr_code = RQRCode::QRCode
      .new(totp.provisioning_uri(current_user.email))
      .as_png(resize_exactly_to: 200)
      .to_data_url
  end

  def create
    @otp_secret = params[:otp_secret]
    totp = ROTP::TOTP.new(
      @otp_secret, issuer: 'YourAppName'
    )

    last_otp_at = totp.verify(
      params[:otp_attempt], drift_behind: 15
    )

    if last_otp_at
      current_user.update(
        otp_secret: @otp_secret, last_otp_at: last_otp_at
      )
      redirect_to(
        after_otp_configuration_path,
        notice: 'Successfully configured OTP protection for your account'
      )
    else
      flash.now[:alert] = 'The code you provided was invalid!'
      @qr_code = RQRCode::QRCode
        .new(totp.provisioning_uri(current_user.email))
        .as_png(resize_exactly_to: 200)
        .to_data_url
      render :new
    end
  end
end      

The view


Now we need to create a form to display the new action above

# app/views/otp_secrets/new.html.erb
<%= form_tag otp_secrets_path, method: :post do %>
  <%= hidden_field_tag :otp_secret %>
  <%= image_tag @qr_code %>
  <%= text_field_tag :otp_attempt, label: "Verify (enter a one-time password)" %>
<% end %>

Now you should have something that looks like this

Scan it, it really works


That's it for setting up the user. Now we just need to take care of forcing the user to enter their OTP code when signing in

Verifying the OTPs on sign-in


If you are forcing all users to secure their accounts with OTPs it's as simple as adding a text input to the sign in form and validating it, however, if OTPs are not mandatory you will need to check if OTPs are enabled during sign-in and defer sign-in until the user successfully verifies an OTP.

The logic to validate OTPs is pretty simple. We've already done it once, we need to modify it a little to include the last_otp_attempt

last_otp_at = totp.verify(
  otp_attempt, after: user.last_otp_at, drift_behind: 15
)
if last_otp_at
  user.update(last_otp_at: last_otp_at)
else
	# Return an error to the user
end

Additional thoughts


That's all there is to it. This article is enough to get you up and running however there are some other things to consider here:

  1. `otp_secret` should be encrypted to protect your users if your database is leaked. I use attr_encrypted.
  2. You should do some validation to ensure that the otp secret hasn't been tampered with, I like to user ActiveSupport::MessageEncryptor so I can be sure that what I've sent to the user is what is returning and then also decode and re-encode the secret to ensure it's really a base32 string.
  3. If you are using a two-step sign-in process, you should ensure that your users either:
    • are not actually signed in until you successfully validate an OTP.
    • or you can create a before_action to check that they have validated an OTP after their last sign in. The later being much more costly as it will be processed during each request.

Like 21 likes
Joe Woodward
I'm Joe Woodward, a Ruby on Rails fanatic working with OOZOU in Bangkok, Thailand. I love Web Development, Software Design, Hardware Hacking.
Share:

Join the conversation

This will be shown public
All comments are moderated

Comments

badar
January 14th, 2022
google-authenticator provide me qr code but it does not match my project generated qr code

Joe Woodward
January 14th, 2022
Hi Badar. You need to confirm the secret for the google-authenticator matches with the one generated by the rails app. If they match then you should be able to generate an OTP code that the rails app will accept.

Get our stories delivered

From us to your inbox weekly.