CartoDB/cartodb20

View on GitHub
app/models/carto/ldap/configuration.rb

Summary

Maintainability
A
35 mins
Test Coverage
# See http://www.rubydoc.info/gems/net-ldap/0.11
require 'net/ldap'

class Carto::Ldap::Configuration < ActiveRecord::Base

  # Not encrypted
  ENCRYPTION_NONE = nil
  # Encrypted from start
  ENCRYPTION_SIMPLE_TLS = 'simple_tls'
  # Upgrade to encrypted once connected
  ENCRYPTION_START_TLS = 'start_tls'

  ENCRYPTION_SSL_VERSION_DEFAULT = nil
  ENCRYPTION_SSL_VERSION_TLSV1_1 = 'TLSv1_1'

  DOMAIN_BASES_SEPARATOR = '||'

  self.table_name = 'ldap_configurations'

  belongs_to :organization, class_name: Carto::Organization

  # @param Uuid id  (Self-generated)
  # @param Uuid organization_id
  # @param String host LDAP host or ip address
  # @param Int port LDAP port e.g. 389, 636 (LDAPS)
  # @param String encryption (Optional) Encryption type to use. Empty means standard/simple Auth
  # @param String ca_file UNUSED FOR NOW -  (Optional) Certificate file path for start_tls encryption.
  #                       Example: "/etc/cafile.pem"
  # @param String ssl_version For start_tls_encryption. Example: "TLSv1_1"
  # @param String connection_user Full CN for "search connections" to LDAP: `CN=admin, DC=cartodb, DC=COM`
  # @param String connection_password Password for "search connections" to LDAP
  # @param String user_id_field Which LDAP entry field represents the user id. e.g. `sAMAccountName`, `uid`
  # @param String username_field Which LDAP entry field represents the username that will be mapped to cartodb.
  #                              For now, same as user_id_field
  # @param String email_field Which LDAP entry field represents the email
  # @param String domain_bases List of DCs conforming the path.
  #                            Serialized, e.g. "['a','b']", due to Rails 3 or PG gem issue handling `PG text[]` fields
  # @param String additional_search_filter Additional filter to add (with &) to the search query if present
  # @param String user_object_class Name of the attribute where the sers are maped in LDAP
  # @param String group_object_class Name of the attribute where the groups are maped in LDAP
  # @param DateTime created_at (Self-generated)
  # @param DateTime updated_at (Self-generated)

  attr_readonly :user_id_field

  validates :organization, :host, :port, :connection_user, :connection_password, :user_id_field, :username_field,
            :email_field, :user_object_class, :group_object_class, presence: true

  validates :ca_file, length: { minimum: 0, allow_nil: true }

  validates :encryption,  inclusion: { in: [ENCRYPTION_SIMPLE_TLS, ENCRYPTION_START_TLS], allow_nil: true }
  validates :ssl_version, inclusion: { in: [ENCRYPTION_SSL_VERSION_TLSV1_1], allow_nil: true }
  validate :domain_bases_not_empty

  def domain_bases_list
    domain_bases.split(DOMAIN_BASES_SEPARATOR) if domain_bases
  end

  def domain_bases_list=(list)
    self.domain_bases = list.join(DOMAIN_BASES_SEPARATOR)
  end

  # Returns matching Carto::Ldap::Entry or false if credentials are wrong
  # @param String username. No full CN, just the username, e.g. 'administrator1'
  # @param String password
  def authenticate(username, password)
    return false if username.blank? || password.blank?
    ldap_connection = Net::LDAP.new(connect_timeout: CONNECTION_TIMEOUT)
    ldap_connection.host = self.host
    ldap_connection.port = self.port
    configure_encryption(ldap_connection)

    ldap_connection.auth self.connection_user, self.connection_password

    valid_ldap_entry = nil
    domain_bases_list.find do |domain|
      valid_ldap_entry = ldap_connection.bind_as(
        base: domain,
        filter: search_filter(username),
        password: password
      )
    end
    @last_authentication_result = ldap_connection.get_operation_result
    return false unless valid_ldap_entry

    Carto::Ldap::Entry.new(valid_ldap_entry.first, self)
  rescue Net::LDAP::Error => e
    log_error(exception: e, message: 'Error authenticating against LDAP', current_user: username)
    nil
  end

  # INFO: Resets connection if already made
  def test_connection
    result = ldap_connection(true).bind
    if result
      { success: true, connection: result }
    else
      { success: false, error: last_operation_result.to_hash }
    end
  rescue StandardError => exception
    { success: false, error: { message: exception.message } }
  end

  def users(objectClass = self.user_object_class)
    search_in_domain_bases(Net::LDAP::Filter.eq('objectClass', objectClass))
  end

  def groups(objectClass = self.group_object_class)
    search_in_domain_bases(Net::LDAP::Filter.eq('objectClass', objectClass))
  end

  def last_authentication_result
    @last_authentication_result.nil? ? nil : Carto::Ldap::OperationResult.new(
      @last_authentication_result.code, @last_authentication_result.error_message,
      @last_authentication_result.matched_dn, @last_authentication_result.message)
  end

  def last_operation_result
    ldap_result = ldap_connection.get_operation_result
    Carto::Ldap::OperationResult.new(ldap_result.code, ldap_result.error_message, ldap_result.matched_dn,
      ldap_result.message)
  end

  private

  CONNECTION_TIMEOUT = 8

  def search_filter(username)
    user_id_filter = "(#{user_id_field}=#{username})"
    if additional_search_filter.present?
      "(&#{user_id_filter}#{additional_search_filter})"
    else
      user_id_filter
    end
  end

  def domain_bases_not_empty
    errors.add(:domain_bases, "No domain bases set") unless domain_bases.present?
    errors.add(:domain_bases_list, "Domain bases list empty") unless domain_bases_list.present?
  end

  def search_in_domain_bases(filter)
    domain_bases_list.map { |domain|
      search(domain, filter)
    }.flatten.compact
  end

  # @param String base DC to search at
  # @Param Net::LDAP::Filter filter (Optional)
  def search(base, filter = nil)
    if filter
      ldap_connection.search(base: base, filter: filter)
    else
      ldap_connection.search(base: base)
    end
  end

  # Performs connection always with the search connection user
  def ldap_connection(reset = false)
    @conn = nil if reset
    @conn ||= connect
  end

  # Connect, by default with the search connection user
  # @param String user full CN, like `CN=test_user, CN=developers, DC=cartodb, DC=COM`
  # @param String password Connection password
  # @throws InvalidConfigurationEncryptionError
  def connect(user = self.connection_user, password = self.connection_password)
    ldap = Net::LDAP.new(connect_timeout: CONNECTION_TIMEOUT)
    ldap.host = self.host
    ldap.port = self.port
    configure_encryption(ldap)
    # implicity this does basic/simple auth if no encryption added above
    ldap.auth(user, password)
    ldap
  end

  def configure_encryption(ldap)
    tls_options = OpenSSL::SSL::SSLContext::DEFAULT_PARAMS

    case self.encryption
    when ENCRYPTION_NONE
      return
    when ENCRYPTION_START_TLS
      tls_options.merge!(:ca_file => self.ca_file) if self.ca_file
    when ENCRYPTION_SIMPLE_TLS
      # No special value needed
    else
      raise InvalidConfigurationEncryptionError.new(self.encryption)
    end

    tls_options.merge!(:verify_mode => OpenSSL::SSL::VERIFY_NONE)

    # Default value is "SSLv23" at the gem
    tls_options.merge!(:ssl_version => self.ssl_version) if self.ssl_version

    ldap.encryption(method: self.encryption.to_sym, tls_options: tls_options)
  end

end

class InvalidConfigurationEncryptionError < StandardError
  def initialize(incorrect_encryption_value)
    super("Invalid encryption value supplied: #{incorrect_encryption_value}. Valid values: [nil, '', '']")
  end
end