Ognjen Regoje bio photo

MY NAME IS
Ognjen Regoje
BUT YOU CAN CALL ME OGGY


I make things that run on the web (mostly).
More /ABOUT me.

me@ognjen.io Twitter LinkedIn Instagram Github

Rails Authentication: Devise with LDAP with multiple configs and database authenticatable

Update two years later: In the two years since this was made (and this post written) there have been no issues related to the login. Both the database and the LDAP logins still work perfectly. Given that I still remember the pain of making this, feel free to get in touch if you need any help.

Getting LDAP integration to work with Devise is quite trivial. The LDAP Authenticatable gem works very well out of the box and getting it up and running requires only minimal effort. You do have to take note that in order for bind to work the admin_user setting has to be fully qualified name of the admin user. That means that it has to include the entire directory structure from the username downwards.

In a recent project however, I had to integrate with a client’s ActiveDirectory that had servers segmented by role. The server that the user was authenticated with also needed to be used to determine what roles the user should have in the application. In addition, the client required the database authentication to remain in tact so that they would be able to use accounts local to the application.

A quick Google session would find the solution for having multiple configurations for LDAP servers – however not for entirely separate servers that are to be sequentially tried till the user is authenticated.

Another quick Google session would help you find a way to preserve database authentication while still having the LDAP auth in tact.

Integrating the two however took a little bit of work so it shall be documented here for future reference and in hopes that it will help someone else.

Firstly, we will modify devise.rb to cater for LDAP and database simultaneously.

#config/devise.rb

# ==> LDAP Configuration
config.ldap_logger = true
config.ldap_create_user = true
config.ldap_config = "#{Rails.root}/config/ldap.yml"
# config.ldap_check_group_membership = false
# config.ldap_check_group_membership_without_admin = false
# config.ldap_check_attributes = false
config.ldap_use_admin_to_bind = true
# config.ldap_ad_group_check = false
# Above is the configuration used for LDAP authenticatable.

# Next we add a strategy that will serve as a replacement for database
# authenticatable. We use unshift here so that it's the first strategy
# that is tried because the ldap gem does not allow for strategies
# to follow it. It will just fail
config.warden do |manager|
  manager.default_strategies(:scope => :user).unshift :local_override
  # We call the strategy local_override and will define it next.
end

Following is how we define the strategy to be used. It’s very similar to the original Devise strategy except it checks whether the user trying to log in is actually from AD – once a user is authenticated via LDAP it will be copied to the database and can then authenticate locally and we don’t want that.

#config/initializers/local_override.rb

require 'devise/strategies/authenticatable'

module Devise
  module Strategies
    class LocalOverride < Authenticatable
      def valid?
        true
      end

      def authenticate!
        if params[:user]
          user = User.find_by_username(params[:user][:username])

          if user && !user.ad_user? && user.valid_password?(params[:user][:password])
            # Check if the user exists, is able to log in and the password is valid.
            success!(user)
          else
            fail
          end
        else
          fail
        end
      end
    end
  end
end

Warden::Strategies.add(:local_override, Devise::Strategies::LocalOverride)
# Register the strategy with Warden so that Devise can use it.

# src: https://gist.github.com/r00k/906356

Next, we move on to changing the way ldap_authenticatable works with mulitple configs. We start by changing the structure of ldap.yml file – the config file that devise_ldap_authenticatable uses.

#ldap.yml
development:
  -
  # Changed the structure of the file to an array of configurations.
    host: 168.192.1.1
    port: 389
    attribute: sAMAccountName
    base: dc=ad,dc=example,dc=com
    admin_user: cn=username,ou=ExampleOU,dc=ad,dc=example,dc=com
    # Note the fully qualified name.
    admin_password: adminPassword
    ssl: false
    # Everything above is standard.

    ad_type: "firstUserType"
    # New field. This is used to determine what the
    #user type is if your servers are segmented by role.
  -
    host: 168.192.1.2
    port: 389
    attribute: sAMAccountName
    base: dc=ad2,dc=example,dc=com
    admin_user: cn=username2,ou=ExampleOU,dc=ad2,dc=example,dc=com
    admin_password: adminPassword2
    ssl: false
    ad_type: "secondUserType"

Given that the configuration is no longer what the gem expects we need to change the relevant methods. The most significant here is the Connection class. It’s been altered to actually accept a config and not try to load one itself.

#config/initializers/ldap_connection_override.rb
module Devise
module LDAP
  class Connection
    def initialize(params = {}, ldap_config)
      # We removed the loading of the config.
      ldap_options = params
      ldap_config["ssl"] = :simple_tls if ldap_config["ssl"] === true
      ldap_options[:encryption] = ldap_config["ssl"].to_sym if ldap_config["ssl"]
      @ldap = Net::LDAP.new(ldap_options)

      @ldap.host = ldap_config["host"]
      @ldap.port = ldap_config["port"]
      @ldap.base = ldap_config["base"]
      @attribute = ldap_config["attribute"]
      @allow_unauthenticated_bind = ldap_config["allow_unauthenticated_bind"]

      @ldap_auth_username_builder = params[:ldap_auth_username_builder]

      @group_base = ldap_config["group_base"]
      @check_group_membership = ldap_config.has_key?("check_group_membership") ? ldap_config["check_group_membership"] : ::Devise.ldap_check_group_membership
      @required_groups = ldap_config["required_groups"]
      @required_attributes = ldap_config["require_attribute"]

      @ldap.auth ldap_config["admin_user"], ldap_config["admin_password"] if params[:admin]
      @ldap.auth params[:login], params[:password] if ldap_config["admin_as_user"]

      @login = params[:login]
      @password = params[:password]
      @new_password = params[:new_password]
    end
  end
end
end

Following that we need to add the looping mechanism that will progressively try the different configurations in the authentication function and we need to change other functions that are commonly used to accept a config directly and not open one themselves.

#config/initializers/ldap_adapter_override.rb

require "net/ldap"
module Devise
  module LDAP
    module Adapter
      def self.valid_credentials?(login, password_plaintext)
        ldap_configs = YAML.load(ERB.new(File.read(::Devise.ldap_config || "#{Rails.root}/config/ldap.yml")).result)[Rails.env]
        options = {:login => login,
                   :password => password_plaintext,
                   :ldap_auth_username_builder => ::Devise.ldap_auth_username_builder,
                   :admin => ::Devise.ldap_use_admin_to_bind}
        ldap_configs.each do |ldap_config|
          resource = Devise::LDAP::Connection.new(options, ldap_config)
          if resource.authorized?
            # AdTypes is explained below.
            AdTypes.set_list(login, ldap_config["ad_type"])
            return true
            break
          end
        end
        false
      end

      # This function is used by get_ldap_param
      def self.ldap_connect(login, ldap_config)
        options = {:login => login,
                   :ldap_auth_username_builder => ::Devise.ldap_auth_username_builder,
                   :admin => ::Devise.ldap_use_admin_to_bind}

        resource = Devise::LDAP::Connection.new(options, ldap_config)
      end

      # This function will be used in your user model to retrieve additional information
      # after the user logs in for the first time.
      def self.get_ldap_param(login,param, ldap_config)
        resource = self.ldap_connect(login, ldap_config)
        resource.ldap_param_value(param)
      end


    end
  end
end
#app/models/user.rb
def ldap_before_save
  # AdTypes is a static class that keeps track of which user logged in to which AD.
  # So far this is the best solution to actually get the ad_type param all the way
  # from the valid_credentials? function to the user that is actuall logged in.
  self.user_type = AdTypes.get_list[self.username]

  self.ad_user = true

  # Loop through the configs once again and return the appropriate one for the
  # current user type. You would probably wrap this in a function of it's own
  # so that you can call self.ldap_config but given I use it only here specifically
  # for this purpose it is not necessarry.
  ldap_configs = YAML.load(ERB.new(File.read(::Devise.ldap_config || "#{Rails.root}/config/ldap.yml")).result)[Rails.env]
  cur_config = ldap_configs.select{|x| x["ad_type"] == self.user_type}.first

  unless cur_config.nil?
    self.email = Devise::LDAP::Adapter.get_ldap_param(self.username,"mail", self.user_type).first
  end
end
#config/initializers/ad_types.rb

# This is the static module that keeps track of which user logged in from which
# server. I'm sure there is a better way to do this but this is the simplest and
# cleanest I came up with in the time I had.
module AdTypes
  @@list = Hash.new

  def self.get_list
    @@list
  end

  def self.set_list(key, value)
    @@list[key] = value
  end
end

Links:

#devise #ldap #lecturer-preformance-evaluation #rails #technical