cartodb/app/models/carto/ldap/configuration.rb
2020-06-15 10:58:47 +08:00

206 lines
7.4 KiB
Ruby

# 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
CartoDB::Logger.error(exception: e, message: 'Error authenticating against LDAP', username: username)
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 => 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