Ruby on Rails

This guide will demonstrate how to integrate Authsignal in a Ruby on Rails app in two scenarios, Multi-factor Authentication flows on “Sign in” and on a example user action e.g. “Withdrawing Money”.

This guide uses the most widely used Authentication gem Devise/Warden as an example, the Stimulus.JS as the client side library to handle challenge flows, and assumes that you have these libraries already configured.

Installation

Add the Authsignal Ruby gem into your Gemfile:

Github: https://github.com/authsignal/authsignal-ruby

Rubygems: https://rubygems.org/gems/authsignal-ruby

gem 'authsignal-ruby'

Add the @authsignal/browser JavaScript client:

npm install @authsignal/browser

Add the Authsignal initialization code block into config/initializers/authsignal.rb:

require 'authsignal'

Authsignal.setup do |config|
    config.api_secret_key = ENV["AUTHSIGNAL_SECRET_KEY"]
end

Initialize the @authsignal/browser) client anywhere your Javascript gets loaded. This could be in app/javascript/application.js. Doing this initializes the Authsignal cookie.

window.authsignal = new Authsignal({tenantId: "YOUR_TENANT_ID"})

Allowing your users to enroll

The first step is allow your user to enroll authenticators. This step assumes you have already setup at least one Authenticator for your tenant in the admin portal.

Authsignal’s ruby SDK allows you to check a user’s enrollment status and provides the URL for your user to manage their authenticators.

The following is an example of a controller action that redirects the user to the Authsignal enrollment and management flow and sets a redirect url when the user completes the self-service flows.

The most important thing to note is that in order to trigger a flow which allows the self service enrollment and management screens you need to add the following attribute to the track_action input redirect_to_settings: true Read more on enrolling authenticators

class MfaController < ApplicationController

    def index
        result = Authsignal.track({
                                action: "enrollment",
                                redirect_url: root_url,
                                user_id: user.id,
                                email: user.email,
                                device_id: authsignal_cookie,
                                user_agent: auth.request.user_agent,
                                ip_address: auth.request.ip,
                                redirect_to_settings: true})

        redirect_to result[:url], allow_other_host: true
    end
end

Devise/Warden - (Sign In Scenario)

This step in the guide implements MFA challenge flows in a typical Devise Sign in scenario and uses the authsignal-ruby SDK. If Authsignal returns a challenge and the user is enrolled with authentication factors, we will redirect the user to a challenge flow and on completion of the challenge, complete the login process.

Insert the following after_authentication hook into config/initializers/warden.rb. This block fires after a successful login and makes the track call.

Warden::Manager.after_authentication do |user,auth,opts|
		# Using this cookie will help with rules that require device tracking
    # This cookie uses the authsignal-browser SDK to instantiate
    authsignal_cookie = auth.request.cookies["__as_aid"]

    begin
    result = Authsignal.track({
                                action: "signIn",
                                redirect_url: Rails.application.routes.url_helpers.complete_mfa_url(idempotencyKey: idempotencyKey),
                                user_id: user.id,
                                email: user.email,
                                device_id: authsignal_cookie,
                                user_agent: auth.request.user_agent,
                                ip_address: auth.request.ip})
    rescue => e
      auth.logout
    end

    case result[:state]
    when "BLOCK"
        # If Authsignal rules give back a BLOCK decision, then do
        # not log the user in and log out
        auth.logout
        throw(:warden, :message => "Your account is blocked")
    when "CHALLENGE_REQUIRED"
        auth.env["authsignal_devise.response"] = result
    end
end

Override the Devise Sessions controller by creating a new controller file in app/controllers/users/sessions_controller.rb:

class Users::SessionsController < Devise::SessionsController
    def create
        super do |resource|

            # If There's a challenge flow initiated
            if request.env["authsignal_devise.response"]
                session[:authsignal_user_id] = resource.id
                sign_out(resource)
                redirect_to request.env["authsignal_devise.response"][:url], allow_other_host: true
                return
            end
        end
    end

    def complete_mfa
        token =  params[:token]
        user_id = session[:authsignal_user_id]
        user = User.find(user_id)
        session[:authsignal_user_id] = nil

        result = Authsignal.validate_challenge(
                      token:  token,
                      user_id: user.id
                  )

        if result[:is_valid] && result[:action] == "signIn"
            sign_in user
            redirect_to after_sign_in_path_for(user)
            return
        end

        flash[:alert] = "Failed Step Up Authentication"
        redirect_to root_path
    end
end

Register the newly created Sessions controller and the new complete_mfa action into your routes.rb file:

devise_for :users, :controllers => {:registrations => "users/registrations", :sessions => "users/sessions"}

devise_scope :user do
  get 'users/complete_mfa', to: 'users/sessions#complete_mfa', as: :complete_mfa
end

You now have your sign-in flows protected with Authsignal.

User Action Scenario

Authsignal is designed to be dropped into any part of your user journey, not just sign-in. The next part of the guide will show how to use the Challenge flow pop-up via the @authsignal/browser) JavaScript client, in conjunction with the server-side track action call.

It assumes that that you are using Stimulus as the client-side library for handling browser-based Javascript, but this approach could be used with any client-side library or framework (React, Vue). The flow follows the convention described in the How Authsignal Works section.

Server-side

class WithdrawalController < ApplicationController
    before_action :authenticate_user!
    skip_before_action :verify_authenticity_token, only:[:create, :complete]

    def create
        # Using this cookie will help with rules that require device tracking
        authsignal_cookie = auth.request.cookies["__as_aid"]

        result = Authsignal.track({
                                    action: "withdrawal",
                                    user_id: current_user.id,
                                    email: current_user.email,
                                    device_id: authsignal_cookie,
                                    user_agent: request.user_agent,
                                    ip_address: request.ip
                                    custom: {
                                        withdrawal_amount: params[:amount]
                                    }
                                })

        case result[:state]
        when "BLOCK"
            render :json => { error: "Withdrawal has been blocked" }, :status => :forbidden
            return
        when "CHALLENGE_REQUIRED"
            render :json => { url: result[:url],
                              idempotency_key: result[:idempotency_key]
                               error: "Challenge Required" }, :status => :unprocessable_entity
            return
        when "ALLOW"
            complete_withdrawal
            render :json => { success: true }
            return
        end
    end

    def complete
        token =  params[:token]
        # This call fetches the action that was previously created
        # The response specifies whether the user has completed the challenge successfully

          result = Authsignal.validate_challenge(
              token: token,
              user_id: current_user.id
          )

        # Complete your action if the challenge was successful
        if ["CHALLENGE_SUCCEEDED", "ALLOW"].include?(result[:state]) && result[:action] == "withdrawal"
            complete_withdrawal
            render :json => { success: true }
            return
        else
            render :json => { error: "Challenge has not been completed" }, :status => :forbidden
            return
        end
    end

    private
    def complete_withdrawal
        # Business logic to complete your withdrawal step
    end
end

Here is an example of a server-side action that simulates a “Withdraw” money flow, which is a typical use case where you might want to protect your user with a step-up challenge. There are two actions in this controller: create which calls track and complete which is called after the user finishes a challenge flow. These are all called via a JSON request from the stimulus client-side.

Client side

Rails View

<div data-controller="withdrawal">
  <input data-withdrawal-target="amount" type="text" />
  <button type="button" data-action="click->withdrawal#withdraw">Withdraw</button>
  <div></div>
</div>

Stimulus Controller

// withdrawal_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["amount"];

  async withdraw() {
    const amount = { amount: this.amountTarget.value };
    const response = await fetch("/withdrawal", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ amount }),
    });

    const result = await response.json();

    // If the url is returned then we need to trigger
    // the authsignal challenge flow
    if (result.url) {
      const { idempotency_key } = result;

      // This step brings up the challenge pop up
      // When the challenge flow completes the pop up will close and the promise
      // will resolve
      const challengeFlow = await window.authsignal.launch(result.url, {
        mode: "popup",
      });

      // Return the idempotency key from the initial result,
      // as part of the complete request
      if (challengeFlow) {
        const complete_step_response = await fetch("/withdrawal/complete", {
          method: "POST",
          headers: {
            Accept: "application/json",
            "Content-Type": "application/json",
          },
          body: JSON.stringify({ amount, idempotency_key }),
        });
        const complete_step_result = await complete_step_response.json();

        const { success } = complete_step_result;

        success
          ? alert("Great your user has successfully completed the action")
          : alert("Great your user has  not successfully completed the action");
      }
    }
  }
}