loading...
Cover image for Active Admin 2FA with OneLogin

Active Admin 2FA with OneLogin

fedeagripa profile image fedeagripa ・5 min read

ActiveAdmin 2FA with OneLogin

In this blog post, you’ll learn how to configure two factor authentication with OneLogin for your admin panel.
2FA

1) Some key concepts to security

  • SLO (Single log out): SLO is a process that allows users to be logged out in one place and spread it over multiple applications

  • SSO (Single sign in): SSO is a process that allows users to authenticate into multiple services after logging into a primary service

  • Callback URL: It is a local URL in your app where a third party auth provider sends you auth data like confirmations or logout requests

2) How to start implementing two factor authentication?

First take a look at this lovely gem, you will notice that all the examples are for the User class, but don't worry we will show some examples for admin specifically

The specific lines you need to add are just a few ones:

First add the devise saml_authenticable module from our choosen gem, this will inject most of the needed capabilities.

# app/models/admin_user.rb

devise :recoverable, :rememberable, :trackable, :validatable, :lockable, :saml_authenticatable

Then tell saml_authenticable module which SAML fields you want to map to your model ones

# config/attribute-map.yml

"urn:mace:dir:attribute-def:email": "email"

Almost done
Add some configuration to your devise initializer to configure the communication between your third party provider and devise.
You can customize the named routes generated in case of named route collisions with other Devise modules or libraries. Set the saml_route_helper_prefix to a string that will be appended to the named route.
If saml_route_helper_prefix = 'saml' then the new_user_session route becomes new_saml_user_session

# config/initializers/devise.rb

config.saml_route_helper_prefix = 'saml'
callback = Rails.env.development? ? 'http://localhost:3000' : ENV['SAML_CALLBACK_ADDRESS']
# SAML configuration
config.saml_create_user = true
config.saml_update_user = true
config.saml_default_user_key = :email
config.saml_session_index_key = :session_index
config.saml_use_subject = true
config.idp_settings_adapter = nil
config.saml_configure do |settings|
  settings.assertion_consumer_service_url     = "#{callback}/admin/saml/auth"
  settings.assertion_consumer_service_binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
  settings.name_identifier_format             = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"
  settings.issuer                             = "#{callback}/admin/saml/metadata"
  settings.authn_context                      = ""
  settings.idp_slo_target_url                 = "https://company.onelogin.com/trust/saml2/http-redirect/slo/#{Rails.env.development? ? '1234' : ENV['SLO_TARGET']}"
  settings.idp_sso_target_url                 = "https://company.onelogin.com/trust/saml2/http-post/sso/#{Rails.env.development? ? 'you_sso_string' : ENV['SSO_TARGET']}"
  settings.idp_cert_fingerprint               = Rails.env.development? ? 'your_cert_fingerprint' : ENV['IDP_CERT_FINGERPRINT']
  settings.idp_cert_fingerprint_algorithm     = 'http://www.w3.org/2000/09/xmldsig#sha256'
end

Finally, we need to modify devise to use our new login strategy. To achieve this we will modify session management so it redirects to our third party provider like this:

# app/controllers/admin_users/sessions_controller.rb

module AdminUsers
  class SessionsController < Devise::SessionsController
    # As you are overwriting devise session controller you need this to allow to login with user & pass (dev mode)
    prepend_before_action :require_no_authentication, only: [:new, :create]

    layout 'active_admin_logged_out'
    helper ::ActiveAdmin::ViewHelpers

    def new
      unless Rails.env.development? || Rails.env.test?
        return redirect_to :new_saml_admin_user_session
      end

      super
    end
  end
end
# config/initializers/active_admin_devise.rb
module ActiveAdmin
  module Devise
    def self.controllers
      {
        sessions: "admin_users/sessions",
        passwords: "active_admin/devise/passwords",
        unlocks: "active_admin/devise/unlocks",
        registrations: "active_admin/devise/registrations",
        confirmations: "active_admin/devise/confirmations"
      }
    end

    def self.controllers_for_filters
      [
        ::AdminUsers::SessionsController,
        SessionsController,
        PasswordsController,
        UnlocksController,
        RegistrationsController,
        ConfirmationsController,
      ]
    end
  end
end

Is that all?

Well.... not really, sorry for getting you excited ¯\(ツ)

As you can see in the initializer there are some conditionals for development, that's because there is no dev out there that wants to do a 2FA every time they want to access ActiveAdmin locally.
So we still need to add some code to conditionally enable 2FA depending on the environment, and a flag (because we don't want to block access to admin if something happens to OneLogin also)

We need to check:

  • Usual admin login page works in development
  • Turning the feature off uses the old admin login and it works
  • How logout works

To perform the first two points I ended up adding the next lines to our overridden sessions_controller, seems that they are lost from super as you override it

# app/controllers/admin_users/sessions_controller.rb

prepend_before_action :require_no_authentication, only: [:new, :create]
prepend_before_action :allow_params_authentication!, only: :create
prepend_before_action :verify_signed_out_user, only: :destroy
prepend_before_action(only: [:create, :destroy]) { request.env["devise.skip_timeout"] = true }

How logout works

At this point you have 3 options to logout:

  • destroy your app session
  • destroy saml session
  • force logout from your third party auth partner

For this specific case, we needed to perform the first one only, because logging someone out of OneLogin means they will be logged out of a lot of apps, and that was not desired.
Actually it was a pain in the neck to do this, because devise_saml_authenticable gem adds routes using class_eval approach directly to Devise engine, leaving you with almost no way to configure which routes you really want or not. You will be asking yourself Why would I like to remove a route?, well... that's because at this point you have 2 admin/logout routes in your app, and we know this is not a good practice at all.
I ended up with this solution as the "cleanest":

# config/initializers/devise.rb

ActionDispatch::Routing::Mapper.class_eval do
  protected
  def devise_saml_authenticatable(mapping, controllers)
    if ::Devise.saml_route_helper_prefix
      prefix = ::Devise.saml_route_helper_prefix
      resource :session, only: [], controller: controllers[:saml_sessions], path: '' do
        get :new, path: 'saml/sign_in', as: "new_#{prefix}"
        post :create, path: 'saml/auth', as: prefix
        get :metadata, path: 'saml/metadata'
        match :idp_sign_out, path: 'saml/idp_sign_out', as: "idp_destroy_#{prefix}", via: [:get, :post]
      end
    else
      resource :session, only: [], controller: controllers[:saml_sessions], path: '' do
        get :new, path: 'saml/sign_in', as: 'new'
        post :create, path: 'saml/auth'
        get :metadata, path: 'saml/metadata'
        match :idp_sign_out, path: 'saml/idp_sign_out', via: [:get, :post]
      end
    end
  end
end

You can add last lines at the end of your devise initializer, or even better create a new initializer that run after devise one.

3) Conclusions

Yas
A project that was estimated to last over a month or so, ends up tacking only 2 weeks because we found a great gem that solves most of our problems (besides some gem implementations not being done in the best way).
Work does not end here, besides sharing this I'm planning to contributing back to this awesome gem to improve its quality and support missing points we mentioned. And my goal with this post besides showing how to solve a security problem is to encourage others to contribute back when you see you can do it, without contributions like this, this gem wouldn't exist and neither would this post.

Posted on by:

fedeagripa profile

fedeagripa

@fedeagripa

I'm a software engineer (yup, i'm a penguin too) from Uruguay, I love to code mainly in Rails, but happy to try new tech

Discussion

markdown guide