Hello there, I'm loving working with Rails API as my back-end and React as a front-end - both in a single rails app. In my previous article, we saw how to configure rails and react using web packer gem. We also created an Articles API and showed the articles listing in our react app.

Today, we'll work on a more advanced topic of adding Two Factor Authentication(a.k.a 2FA) to our user authentication. This article is not a continuation of my previous article above, but the setup is the same, although this tutorial will work for other configurations like Rails API as standalone and React as a separate standalone app.

If you've not set up your user authentication, I highly recommend going through Scotch IO's User Authentication API Tutorial. This article is inspired by DriftingRuby's 2FA tutorial. Enough of introduction, let's get started.

Prerequisites:

  1. We'll use Ruby 2.6.1 and Rails 5.2.3. Although this should work for some old versions too.
  2. You should have already set up your basic users authentication. I've used devise gem, but you can also use your custom implementation. But make sure, you have helper method current_user which returns the authenticated user.

2FA back-end setup:

  1. Add active_model_otp gem to Gemfile

    # Gemfile
    gem 'active_model_otp', :git => 'https://github.com/heapsource/active_model_otp.git'
    
    • Now run bundler to install the gem:
      $ bundle i
      
  2. Now, we'll add otp_secret_key column the user's table which will hold random string which users can add to their Google Authenticator app to get 2FA codes. So let's generate the rails migration:

    $ rails g migration add_otp_secret_key_to_users
    
    • Additionally, we'll also add otp_module column, which will be a boolean which will tell whether 2FA is enabled for a particular user or not. So make sure your migration file looks like this:
      class AddOtpSecretKeyToUsers < ActiveRecord::Migration[5.2]
        def change
          add_column :users, :otp_secret_key, :string
          add_column :users, :otp_module, :integer, default: 0
        end
      end
      
  3. Then in the user model - app/models/user.rb:

    class User < ApplicationRecord
      ...
      
      # from active_model_otp gem
      has_one_time_password
      
      # if 2fa is enabled, it will be 1(true), if disabled it will be 0(false)
      enum otp_module: { disabled: 0, enabled: 1 }, _prefix: true
      attr_accessor :otp_code_token
      
      ...
    end
    
  4. Now, let's generate the multi_factor_authentication controller.

      $ rails g controller api/v1/multi_factor_authentication
    
  5. Let's add the code in app/controllers/api/v1/multi_factor_authentication_controller.rb file:

    class Api::V1::MultiFactorAuthenticationController < ApplicationController
      
      # Enable 2FA method
      def verify_enable
        # take the user input in `otp_param` variable
        otp_param = params[:multi_factor_authentication][:otp_code_token]
        if otp_param && current_user && current_user.authenticate_otp(otp_param, drift: 60).present?
          # Everything is correct, now enable users 2FA
          current_user.otp_module_enabled!
          send_response = {
            status: 200,
            data: {
              message: "2FA enabled for this user"
            }
          }
          json_response(send_response)
        else
          # Something is wrong, show the error
          send_response = {
            status: 422,
            errors: {
              message: "2FA could not be enabled for this user"
            }
          }
          json_response(send_response, :unprocessable_entity)
        end
      end
    
      def verify_disabled
        otp_param = params[:multi_factor_authentication][:otp_code_token]
        if otp_param && current_user && current_user.authenticate_otp(otp_param, drift: 60).present?
          # Everything is correct, now disable users 2FA
          current_user.otp_module_disabled!
          # Regenrate the OTP Secret key and save the user
          current_user.otp_regenerate_secret
          current_user.save
          send_response = {
            status: 200,
            data: {
              message: "2FA disabled for this user"
            }
          }
          json_response(send_response)
        else
          send_response = {
            status: 422,
            errors: {
              message: "2FA could not be disabled for this user"
            }
          }
          json_response(send_response, :unprocessable_entity)
        end
      end
    end
    
    • .authenticate_otp method is provided by active_model_otp gem.
    • current_user.authenticate_otp(otp_param, drift: 60) -> Takes otp_code as first param and drift of 60 means, wait for 60 seconds for the old otp_code to be invalid.
    • In the above code, json_response method is my custom method which will send the JSON response to the user. It's located at app/controllers/concerns/response.rb:
      module Response
        def json_response(object, status = :ok)
          render json: object, status: status
        end
      end
      
  6. Now, when the user's 2FA is enabled, the user must send otp_code along with its email and password to sign in. To add this functionality, you'll need to edit your sessions controller(create action). Everyone has a different type of sessions handling, so I'll show you the logic on how to authenticate:

    # Your sessions controller
    def create
      # find user with the email provided
      user = User.find_by(email: auth_params(:email))
      
      # If the email is valid and user is found, Check if 2FA is enabled
      if user && user.otp_module_enabled?
        # If 2fa is enabled, check if it is present
        if !otp_code_token
          # raise an error
        end
        
        # Authenticate the user with email and pass
        auth_user = authenticate(auth_params(:email), auth_params(:password))
        
        ## check if provided otp_code is valid or not.
        ## Run this only if `auth_user` is present
        auth_user_with_token = auth_user.authenticate_otp(auth_params(:otp_code_token), drift: 60).present? if auth_user
        
        # Now send the response
        if auth_user && auth_user_with_token
          send_response = {
            status: 200,
            data: {
              message: "Successfully Authenticated"
            }
          }
          json_response(send_response)
        else
          # raise authentication error
        end
      else
        # If 2FA is not enabled, do normal authentication
        
        # Authenticate the user with email and pass
        auth_user = authenticate(auth_params(:email), auth_params(:password))
        
        if auth_user
          send_response = {
            status: 200,
            data: {
              message: "Successfully Authenticated"
            }
          }
          json_response(send_response)
        else
          # raise authentication error
        end
      end
    end
    
    private
    
    def auth_params
      params.require(:session).permit(:email, :password, :otp_code_token)
    end
    
    def authenticate
      user = User.find_for_authentication(email: email)
      
      # if user password is valid, return the user, if invalid - return nil
      user&.valid_password?(password) ? user : nil
    end
    
  7. The last step is to add the endpoint to the routes.rb. file:

    # config/routes.rb
    
    namespace :api, defaults: { format: :json } do
      scope module: :v1 do
        ...
        
        post 'auth/enable2fa', to: 'multi_factor_authentication#verify_enable'
        post 'auth/disable2fa', to: 'multi_factor_authentication#verify_disabled'
        
        ...
      end
    end
    

Testing the endpoint:

PC: Pixabay
  1. First, run the rails server in one terminal:

    $ rails s
    
    • Then in a new terminal, run the rails console to test if things are working correctly:
      $ rails c
      
  2. From the rails console, we'll create a new user and copy it's otp_secret_key:

    # rails console
    User.create(email: "a@b.com", password: "12345678", password_confirmation: "12345678")
    ## this will create a user
    
    u = User.first
    u.otp_secret_key
    # => some random string
    
    ## Let's check if 2FA is enabled or not.
    u.otp_module
    # => "disabled"
    
  3. Setting up Google Authenticator:

    • Download the Google Authenticator app for Android or IOS
    • Add a new site by clicking the + icon -> Select enter by key
    • Now Add an Account name anything you want: Ex: "My App."
    • Then type down the otp_secret_key you copied above from rails console.
    • Now you'll see random codes which regenerate every 60 seconds.
  4. Optional: When you use curl commands against the rails app, you might encounter Invalid Authenticity token error. If this happens, Add below line in your app/controller/application_controller.rb file:

    skip_before_action :verify_authenticity_token
    
  5. Create a new file at the root of your project named data.json. This file will contain data that we send as a request to our rails application. Add the following JSON:

    {
      "session":{
        "email": "a@b.com",
        "password": "12345678"
      }
    }
    
  6. Now let's make a CURL request, open a new terminal, and point it to the root of your project:

    # `cd` to your project
    cd path/to/project/root
    
    $ curl -X POST -H "Content-Type: application/json" -d @data.json http://localhost:3000/auth/login
    
    • You should see successfully authenticated message. This is normal authentication.
  7. Enable 2FA for user:

    • Change data.json file:
      {
        "multi_factor_authentication":{
          "otp_code_token": "123456"
        }
      }
      
    • Make sure you add the OTP code from your Google Authenticator app.
    • I'm assuming that you use the Authorization header to authenticate the user.
    • Run the CURL command:
      $ curl -X POST -H "Content-Type: application/json" -H "Authorization: yourtoken" -d @data.json http://localhost:3000/auth/enable2fa
      
    • Now you'll see the message that 2FA is enabled.
    • Confirm that users 2FA is enabled in rails console:
      # rails console
      reload!
      
      u = User.first
      u.otp_module
      # => "enabled"
      
  8. To disable 2FA, follow above step in point 7 and then run:

    $ curl -X POST -H "Content-Type: application/json" -H "Authorization: yourtoken" -d @data.json http://localhost:3000/auth/disable2fa
    
    • This will disable 2FA, and you'll see a success message.

The End

That's it for now. Adding 2FA to your rails application is quite essential nowadays as it improves security significantly. I hope this tutorial helped you at least getting the insight on how you can implement 2FA in your app.