theforeman/foreman

View on GitHub
app/models/auth_sources/auth_source_ldap.rb

Summary

Maintainability
B
6 hrs
Test Coverage
# redMine - project management software
# Copyright (C) 2006  Jean-Philippe Lang
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.

require 'net/ldap'
require 'timeout'

class AuthSourceLdap < AuthSource
  SERVER_TYPES = { :free_ipa => 'FreeIPA', :active_directory => 'Active Directory',
                   :posix    => 'POSIX'}

  extend FriendlyId
  friendly_id :name
  include Parameterizable::ByIdName
  include Encryptable
  encrypts :account_password
  include Taxonomix

  validates :host, :presence => true, :length => {:maximum => 60}
  validates :attr_login, :attr_firstname, :attr_lastname, :attr_mail, :presence => true, :if => proc { |auth| auth.onthefly_register? }
  validates :attr_login, :attr_firstname, :attr_lastname, :attr_mail, :length => {:maximum => 30}, :allow_nil => true
  validates :account_password, :length => {:maximum => 69}, :allow_nil => true
  validates :port, :presence => true, :numericality => {:only_integer => true}
  validates :server_type, :presence => true, :inclusion => { :in => SERVER_TYPES.keys.map(&:to_s) }
  validate :validate_ldap_filter, :unless => proc { |auth| auth.ldap_filter.blank? }

  before_validation :strip_ldap_attributes
  before_validation :sanitize_use_netgroups
  after_initialize :set_defaults

  scoped_search :on => :name, :complete_value => :true

  default_scope lambda {
    with_taxonomy_scope do
      order("#{AuthSourceLdap.table_name}.name")
    end
  }

  DEFAULT_PORTS = {:ldap => 389, :ldaps => 636 }
  # Loads the LDAP info for a user and authenticates the user with their password
  # Returns : Array of Strings.
  #           Either the users's DN or the user's full details OR nil
  def authenticate(login = account, password = account_password)
    return if login.blank? || password.blank?

    logger.debug "LDAP auth with user #{login} against #{self}"

    conn = ldap_con(login, password)
    return unless conn.valid_user?(login)
    entry = conn.find_user(login).last
    attrs = attributes_values(entry)

    unless conn.authenticate?(login, password)
      auth_result = conn.ldap.ldap.get_operation_result
      logger.warn "Result: #{auth_result.code}"
      logger.warn "Message: #{auth_result.message}"
      logger.warn "Failed to authenticate #{login}"
      return
    end

    attrs.each { |k, v| attrs[k] = v.force_encoding('UTF-8') unless v == '' }

    logger.debug "Retrieved LDAP Attributes for #{login}: #{attrs}"

    attrs
  rescue Net::LDAP::Error => error
    raise ::Foreman::LdapException.new(error, N_("Error while connecting to '%{name}' LDAP server at '%{url}' during authentication" % {:url => host, :name => name}))
  end

  def auth_method_name
    "LDAP"
  end

  def to_config(login = nil, password = nil)
    raise ::Foreman::Exception.new(N_('Cannot create LDAP configuration for %s without dedicated service account'), name) if login.nil? && use_user_login_for_service?
    { :host    => host,    :port => port, :encryption => encryption_config,
      :base_dn => base_dn, :group_base => groups_base, :attr_login => attr_login,
      :server_type  => server_type.to_sym, :search_filter => ldap_filter,
      :anon_queries => account.blank?, :service_user => service_user(login),
      :service_pass => use_user_login_for_service? ? password : account_password,
      :instrumentation_service => ActiveSupport::Notifications,
      :use_netgroups => use_netgroups }
  end

  def encryption_config
    return nil unless tls
    { :method => :simple_tls, :tls_options => { :verify_mode => OpenSSL::SSL::VERIFY_PEER } }
  end

  def ldap_con(login = nil, password = nil)
    if login.present?
      LdapFluff.new(to_config(login, password))
    else
      @ldap_con ||= LdapFluff.new(to_config)
    end
  rescue Net::LDAP::Error => e
    message = _("Error during LDAP connection %{name} using login %{login}: %{error}") % {name: name, login: login, error: e}
    Foreman::Logging.exception(message, e, :level => :warn)
    errors.add(:base, message)
  end

  def update_usergroups(login)
    if use_user_login_for_service?
      logger.info "Skipping user group update for user #{login} as $login is in use, group sync is unsupported"
      return
    end

    unless usergroup_sync?
      logger.info "Skipping user group update for user #{login} as usergroup_sync is disabled"
      return
    end

    logger.debug "Updating user groups for user #{login}"
    user = User.unscoped.find_by_login(login)
    external_names = ldap_con.group_list(login).map(&:downcase)
    group_name_arel = ExternalUsergroup.arel_table[:name].lower

    # User's external user groups localy
    current_external_ids = user.usergroups
                               .where(id: ExternalUsergroup.where(auth_source_id: id).select(:usergroup_id))
                               .pluck(:id)
    # User's external user groups as of auth_source
    correct_external_ids = ExternalUsergroup.where(auth_source_id: id)
                                            .where(group_name_arel.in(external_names))
                                            .pluck(:usergroup_id)

    user.usergroup_ids = (user.usergroup_ids - current_external_ids) + correct_external_ids
    # audit changes
    user.save(validate: false)
  end

  ##
  #  Sync group with external usergroup
  def refresh_usergroup_members(usergroup)
    external_groups = usergroup.external_usergroups.where(auth_source_id: id).to_a
    # Do not sync groups which are not connected with external groups
    return unless external_groups.any?
    logins = external_groups.map(&:users).select { |a| !!a }.flatten.map(&:downcase).uniq

    user_ids = User.unscoped.where(auth_source_id: id).fetch_ids_by_list(logins)
    current_user_ids = usergroup.users.where(auth_source_id: id).pluck(:id)

    usergroup.user_ids -= (current_user_ids - user_ids)
    usergroup.user_ids += (user_ids - current_user_ids)

    # audit changes - see #23377
    usergroup.save
  end

  def valid_user?(name)
    name.present? && ldap_con.valid_user?(name)
  end

  def valid_group?(name)
    return false unless name.present?
    ldap_con.valid_group?(name)
  end

  def users_in_group(name)
    ldap_con.user_list(name)
  end

  def test_connection
    result = {}
    begin
      Timeout.timeout(20) do
        ldap_con.test
      end
      result[:success] = true
      result[:message] = _("Test connection to LDAP server was successful.")
    rescue => exception
      raise ::Foreman::WrappedException.new exception, N_("Unable to connect to LDAP server")
    end
    result
  end

  private

  def strip_ldap_attributes
    [:attr_login, :attr_firstname, :attr_lastname, :attr_mail].each do |attr|
      self[attr] = self[attr].strip unless self[attr].nil?
    end
  end

  def sanitize_use_netgroups
    self.use_netgroups = false if server_type.to_s == 'active_directory'
    true
  end

  def set_defaults
    self.port ||= DEFAULT_PORTS[:ldap]
    self.attr_login ||= 'uid'
    self.attr_firstname ||= 'givenName'
    self.attr_lastname ||= 'sn'
    self.attr_mail ||= 'mail'
    self.attr_photo ||= 'jpegPhoto'
  end

  def required_ldap_attributes
    return {:dn => :dn} unless onthefly_register?
    { :firstname => attr_firstname,
      :lastname  => attr_lastname,
      :mail      => attr_mail,
      :login     => attr_login,
      :dn        => :dn,
    }
  end

  def optional_ldap_attributes
    { :avatar => attr_photo }
  end

  def attributes_values(entry)
    Hash[required_ldap_attributes.merge(optional_ldap_attributes).map do |name, value|
      next if value.blank? || (entry[value].blank? && optional_ldap_attributes.key?(name))
      if name.eql? :avatar
        [:avatar_hash, store_avatar(entry[value].first)]
      else
        value = entry[value].is_a?(Array) ? entry[value].first : entry[value]
        [name, value.to_s]
      end
    end.compact]
  end

  def avatar_path
    "#{Rails.public_path}/images/avatars"
  end

  def store_avatar(avatar)
    unless avatar.instance_of?(Net::BER::BerIdentifiedString)
      avatar = avatar.to_utf8
    end
    avatar_hash = Digest::SHA1.hexdigest(avatar)
    avatar_file = "#{avatar_path}/#{avatar_hash}.jpg"
    unless FileTest.exist? avatar_file
      FileUtils.mkdir_p(avatar_path)
      # net/ldap converts base64 data automatically to binary, in such case
      # we do not need to decode Base64 and we can just save the binary avatar.
      File.open(avatar_file, 'wb') do |f|
        if avatar.instance_of?(Net::BER::BerIdentifiedString)
          f.write(avatar)
        else
          f.write(Base64.decode64(avatar))
        end
      end
    end
    avatar_hash
  end

  def validate_ldap_filter
    Net::LDAP::Filter.construct(ldap_filter)
  rescue Net::LDAP::Error => e
    message = _("invalid LDAP filter syntax")
    Foreman::Logging.exception(message, e)
    errors.add(:ldap_filter, message)
  end

  def use_user_login_for_service?
    # returns true if account is defined and includes "$login"
    (account.present? && account.include?("$login"))
  end

  def service_user(login)
    use_user_login_for_service? ? account.sub("$login", login) : account
  end
end