summaryrefslogtreecommitdiffstats
path: root/library/Icinga/Protocol/Ldap
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--library/Icinga/Protocol/Ldap/Discovery.php143
-rw-r--r--library/Icinga/Protocol/Ldap/LdapCapabilities.php439
-rw-r--r--library/Icinga/Protocol/Ldap/LdapConnection.php1584
-rw-r--r--library/Icinga/Protocol/Ldap/LdapException.php14
-rw-r--r--library/Icinga/Protocol/Ldap/LdapQuery.php361
-rw-r--r--library/Icinga/Protocol/Ldap/LdapUtils.php148
-rw-r--r--library/Icinga/Protocol/Ldap/Node.php69
-rw-r--r--library/Icinga/Protocol/Ldap/Root.php241
8 files changed, 2999 insertions, 0 deletions
diff --git a/library/Icinga/Protocol/Ldap/Discovery.php b/library/Icinga/Protocol/Ldap/Discovery.php
new file mode 100644
index 0000000..d2080aa
--- /dev/null
+++ b/library/Icinga/Protocol/Ldap/Discovery.php
@@ -0,0 +1,143 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Ldap;
+
+use Icinga\Data\ConfigObject;
+use Icinga\Protocol\Dns;
+
+class Discovery
+{
+ /**
+ * @var LdapConnection
+ */
+ private $connection;
+
+ /**
+ * @param LdapConnection $conn The ldap connection to use for the discovery
+ */
+ public function __construct(LdapConnection $conn)
+ {
+ $this->connection = $conn;
+ }
+
+ /**
+ * Suggests a resource configuration of hostname, port and root_dn
+ * based on the discovery
+ *
+ * @return array The suggested configuration as an array
+ */
+ public function suggestResourceSettings()
+ {
+ return array(
+ 'hostname' => $this->connection->getHostname(),
+ 'port' => $this->connection->getPort(),
+ 'root_dn' => $this->connection->getCapabilities()->getDefaultNamingContext()
+ );
+ }
+
+ /**
+ * Suggests a backend configuration of base_dn, user_class and user_name_attribute
+ * based on the discovery
+ *
+ * @return array The suggested configuration as an array
+ */
+ public function suggestBackendSettings()
+ {
+ if ($this->isAd()) {
+ return array(
+ 'backend' => 'msldap',
+ 'base_dn' => $this->connection->getCapabilities()->getDefaultNamingContext(),
+ 'user_class' => 'user',
+ 'user_name_attribute' => 'sAMAccountName'
+ );
+ } else {
+ return array(
+ 'backend' => 'ldap',
+ 'base_dn' => $this->connection->getCapabilities()->getDefaultNamingContext(),
+ 'user_class' => 'inetOrgPerson',
+ 'user_name_attribute' => 'uid'
+ );
+ }
+ }
+
+ /**
+ * Whether the suggested ldap server is an ActiveDirectory
+ *
+ * @return boolean
+ */
+ public function isAd()
+ {
+ return $this->connection->getCapabilities()->isActiveDirectory();
+ }
+
+ /**
+ * Whether the discovery was successful
+ *
+ * @return bool False when the suggestions are guessed
+ */
+ public function isSuccess()
+ {
+ return $this->connection->discoverySuccessful();
+ }
+
+ /**
+ * Why the discovery failed
+ *
+ * @return \Exception|null
+ */
+ public function getError()
+ {
+ return $this->connection->getDiscoveryError();
+ }
+
+ /**
+ * Discover LDAP servers on the given domain
+ *
+ * @param string $domain The object containing the form elements
+ *
+ * @return Discovery True when the discovery was successful, false when the configuration was guessed
+ */
+ public static function discoverDomain($domain)
+ {
+ if (! isset($domain)) {
+ return false;
+ }
+
+ // Attempt 1: Connect to the domain directly
+ $disc = Discovery::discover($domain, 389);
+ if ($disc->isSuccess()) {
+ return $disc;
+ }
+
+ // Attempt 2: Discover all available ldap dns records and connect to the first one
+ $records = array_merge(Dns::getSrvRecords($domain, 'ldap'), Dns::getSrvRecords($domain, 'ldaps'));
+ if (isset($records[0])) {
+ $record = $records[0];
+ return Discovery::discover(
+ isset($record['target']) ? $record['target'] : $domain,
+ isset($record['port']) ? $record['port'] : $domain
+ );
+ }
+
+ // Return the first failed discovery, which will suggest properties based on guesses
+ return $disc;
+ }
+
+ /**
+ * Convenience method to instantiate a new Discovery
+ *
+ * @param $host The host on which to execute the discovery
+ * @param $port The port on which to execute the discovery
+ *
+ * @return Discover The resulting Discovery
+ */
+ public static function discover($host, $port)
+ {
+ $conn = new LdapConnection(new ConfigObject(array(
+ 'hostname' => $host,
+ 'port' => $port
+ )));
+ return new Discovery($conn);
+ }
+}
diff --git a/library/Icinga/Protocol/Ldap/LdapCapabilities.php b/library/Icinga/Protocol/Ldap/LdapCapabilities.php
new file mode 100644
index 0000000..0e562b1
--- /dev/null
+++ b/library/Icinga/Protocol/Ldap/LdapCapabilities.php
@@ -0,0 +1,439 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Ldap;
+
+use Icinga\Application\Logger;
+
+/**
+ * The properties and capabilities of an LDAP server
+ *
+ * Provides information about the available encryption mechanisms (StartTLS), the supported
+ * LDAP protocol (v2/v3), vendor-specific extensions or protocols controls and extensions.
+ */
+class LdapCapabilities
+{
+ const LDAP_SERVER_START_TLS_OID = '1.3.6.1.4.1.1466.20037';
+
+ const LDAP_PAGED_RESULT_OID_STRING = '1.2.840.113556.1.4.319';
+
+ const LDAP_SERVER_SHOW_DELETED_OID = '1.2.840.113556.1.4.417';
+
+ const LDAP_SERVER_SORT_OID = '1.2.840.113556.1.4.473';
+
+ const LDAP_SERVER_CROSSDOM_MOVE_TARGET_OID = '1.2.840.113556.1.4.521';
+
+ const LDAP_SERVER_NOTIFICATION_OID = '1.2.840.113556.1.4.528';
+
+ const LDAP_SERVER_EXTENDED_DN_OID = '1.2.840.113556.1.4.529';
+
+ const LDAP_SERVER_LAZY_COMMIT_OID = '1.2.840.113556.1.4.619';
+
+ const LDAP_SERVER_SD_FLAGS_OID = '1.2.840.113556.1.4.801';
+
+ const LDAP_SERVER_TREE_DELETE_OID = '1.2.840.113556.1.4.805';
+
+ const LDAP_SERVER_DIRSYNC_OID = '1.2.840.113556.1.4.841';
+
+ const LDAP_SERVER_VERIFY_NAME_OID = '1.2.840.113556.1.4.1338';
+
+ const LDAP_SERVER_DOMAIN_SCOPE_OID = '1.2.840.113556.1.4.1339';
+
+ const LDAP_SERVER_SEARCH_OPTIONS_OID = '1.2.840.113556.1.4.1340';
+
+ const LDAP_SERVER_PERMISSIVE_MODIFY_OID = '1.2.840.113556.1.4.1413';
+
+ const LDAP_SERVER_ASQ_OID = '1.2.840.113556.1.4.1504';
+
+ const LDAP_SERVER_FAST_BIND_OID = '1.2.840.113556.1.4.1781';
+
+ const LDAP_CONTROL_VLVREQUEST = '2.16.840.1.113730.3.4.9';
+
+
+ // MS Capabilities, Source: http://msdn.microsoft.com/en-us/library/cc223359.aspx
+
+ // Running Active Directory as AD DS
+ const LDAP_CAP_ACTIVE_DIRECTORY_OID = '1.2.840.113556.1.4.800';
+
+ // Capable of signing and sealing on an NTLM authenticated connection
+ // and of performing subsequent binds on a signed or sealed connection
+ const LDAP_CAP_ACTIVE_DIRECTORY_LDAP_INTEG_OID = '1.2.840.113556.1.4.1791';
+
+ // If AD DS: running at least W2K3, if AD LDS running at least W2K8
+ const LDAP_CAP_ACTIVE_DIRECTORY_V51_OID = '1.2.840.113556.1.4.1670';
+
+ // If AD LDS: accepts DIGEST-MD5 binds for AD LDSsecurity principals
+ const LDAP_CAP_ACTIVE_DIRECTORY_ADAM_DIGEST = '1.2.840.113556.1.4.1880';
+
+ // Running Active Directory as AD LDS
+ const LDAP_CAP_ACTIVE_DIRECTORY_ADAM_OID = '1.2.840.113556.1.4.1851';
+
+ // If AD DS: it's a Read Only DC (RODC)
+ const LDAP_CAP_ACTIVE_DIRECTORY_PARTIAL_SECRETS_OID = '1.2.840.113556.1.4.1920';
+
+ // Running at least W2K8
+ const LDAP_CAP_ACTIVE_DIRECTORY_V60_OID = '1.2.840.113556.1.4.1935';
+
+ // Running at least W2K8r2
+ const LDAP_CAP_ACTIVE_DIRECTORY_V61_R2_OID = '1.2.840.113556.1.4.2080';
+
+ // Running at least W2K12
+ const LDAP_CAP_ACTIVE_DIRECTORY_W8_OID = '1.2.840.113556.1.4.2237';
+
+ /**
+ * Attributes of the LDAP Server returned by the discovery query
+ *
+ * @var StdClass
+ */
+ private $attributes;
+
+ /**
+ * Map of supported available OIDS
+ *
+ * @var array
+ */
+ private $oids;
+
+ /**
+ * Construct a new capability
+ *
+ * @param $attributes StdClass The attributes returned, may be null for guessing default capabilities
+ */
+ public function __construct($attributes = null)
+ {
+ $this->setAttributes($attributes);
+ }
+
+ /**
+ * Set the attributes and (re)build the OIDs
+ *
+ * @param $attributes StdClass The attributes returned, may be null for guessing default capabilities
+ */
+ protected function setAttributes($attributes)
+ {
+ $this->attributes = $attributes;
+ $this->oids = array();
+
+ $keys = array('supportedControl', 'supportedExtension', 'supportedFeatures', 'supportedCapabilities');
+ foreach ($keys as $key) {
+ if (isset($attributes->$key)) {
+ if (is_array($attributes->$key)) {
+ foreach ($attributes->$key as $oid) {
+ $this->oids[$oid] = true;
+ }
+ } else {
+ $this->oids[$attributes->$key] = true;
+ }
+ }
+ }
+ }
+
+ /**
+ * Return if the capability object contains support for StartTLS
+ *
+ * @return bool Whether StartTLS is supported
+ */
+ public function hasStartTls()
+ {
+ return isset($this->oids[self::LDAP_SERVER_START_TLS_OID]);
+ }
+
+ /**
+ * Return if the capability object contains support for paged results
+ *
+ * @return bool Whether StartTLS is supported
+ */
+ public function hasPagedResult()
+ {
+ return isset($this->oids[self::LDAP_PAGED_RESULT_OID_STRING]);
+ }
+
+ /**
+ * Whether the ldap server is an ActiveDirectory server
+ *
+ * @return boolean
+ */
+ public function isActiveDirectory()
+ {
+ return isset($this->oids[self::LDAP_CAP_ACTIVE_DIRECTORY_OID]);
+ }
+
+ /**
+ * Whether the ldap server is an OpenLDAP server
+ *
+ * @return bool
+ */
+ public function isOpenLdap()
+ {
+ return isset($this->attributes->structuralObjectClass) &&
+ $this->attributes->structuralObjectClass === 'OpenLDAProotDSE';
+ }
+
+ /**
+ * Return if the capability objects contains support for LdapV3, defaults to true if discovery failed
+ *
+ * @return bool
+ */
+ public function hasLdapV3()
+ {
+ if (! isset($this->attributes) || ! isset($this->attributes->supportedLDAPVersion)) {
+ // Default to true, if unknown
+ return true;
+ }
+
+ return (is_string($this->attributes->supportedLDAPVersion)
+ && (int) $this->attributes->supportedLDAPVersion === 3)
+ || (is_array($this->attributes->supportedLDAPVersion)
+ && in_array(3, $this->attributes->supportedLDAPVersion));
+ }
+
+ /**
+ * Whether the capability with the given OID is supported
+ *
+ * @param $oid string The OID of the capability
+ *
+ * @return bool
+ */
+ public function hasOid($oid)
+ {
+ return isset($this->oids[$oid]);
+ }
+
+ /**
+ * Get the default naming context
+ *
+ * @return string|null the default naming context, or null when no contexts are available
+ */
+ public function getDefaultNamingContext()
+ {
+ // defaultNamingContext entry has higher priority
+ if (isset($this->attributes->defaultNamingContext)) {
+ return $this->attributes->defaultNamingContext;
+ }
+
+ // if its missing use namingContext
+ $namingContexts = $this->namingContexts();
+ return empty($namingContexts) ? null : $namingContexts[0];
+ }
+
+ /**
+ * Get the configuration naming context
+ *
+ * @return string|null
+ */
+ public function getConfigurationNamingContext()
+ {
+ if (isset($this->attributes->configurationNamingContext)) {
+ return $this->attributes->configurationNamingContext;
+ }
+ }
+
+ /**
+ * Get the NetBIOS name
+ *
+ * @return string|null
+ */
+ public function getNetBiosName()
+ {
+ if (isset($this->attributes->nETBIOSName)) {
+ return $this->attributes->nETBIOSName;
+ }
+ }
+
+ /**
+ * Fetch the namingContexts
+ *
+ * @return array the available naming contexts
+ */
+ public function namingContexts()
+ {
+ if (!isset($this->attributes->namingContexts)) {
+ return array();
+ }
+ if (!is_array($this->attributes->namingContexts)) {
+ return array($this->attributes->namingContexts);
+ }
+ return$this->attributes->namingContexts;
+ }
+
+ public function getVendor()
+ {
+ /*
+ rfc #3045 specifies that the name of the server MAY be included in the attribute 'verndorName',
+ AD and OpenLDAP don't do this, but for all all other vendors we follow the standard and
+ just hope for the best.
+ */
+
+ if ($this->isActiveDirectory()) {
+ return 'Microsoft Active Directory';
+ }
+
+ if ($this->isOpenLdap()) {
+ return 'OpenLDAP';
+ }
+
+ if (! isset($this->attributes->vendorName)) {
+ return null;
+ }
+ return $this->attributes->vendorName;
+ }
+
+ public function getVersion()
+ {
+ /*
+ rfc #3045 specifies that the version of the server MAY be included in the attribute 'vendorVersion',
+ but AD and OpenLDAP don't do this. For OpenLDAP there is no way to query the server versions, but for all
+ all other vendors we follow the standard and just hope for the best.
+ */
+
+ if ($this->isActiveDirectory()) {
+ return $this->getAdObjectVersionName();
+ }
+
+ if (! isset($this->attributes->vendorVersion)) {
+ return null;
+ }
+ return $this->attributes->vendorVersion;
+ }
+
+ /**
+ * Discover the capabilities of the given LDAP server
+ *
+ * @param LdapConnection $connection The ldap connection to use
+ *
+ * @return LdapCapabilities
+ *
+ * @throws LdapException In case the capability query has failed
+ */
+ public static function discoverCapabilities(LdapConnection $connection)
+ {
+ $ds = $connection->getConnection();
+
+ $fields = array(
+ 'configurationNamingContext',
+ 'defaultNamingContext',
+ 'namingContexts',
+ 'vendorName',
+ 'vendorVersion',
+ 'supportedSaslMechanisms',
+ 'dnsHostName',
+ 'schemaNamingContext',
+ 'supportedLDAPVersion', // => array(3, 2)
+ 'supportedCapabilities',
+ 'supportedControl',
+ 'supportedExtension',
+ 'objectVersion',
+ '+'
+ );
+
+ $result = @ldap_read($ds, '', (string) $connection->select()->from('*', $fields), $fields);
+ if (! $result) {
+ throw new LdapException(
+ 'Capability query failed (%s; Default port: %d): %s. Check if hostname and port'
+ . ' of the ldap resource are correct and if anonymous access is permitted.',
+ $connection->getHostname(),
+ $connection->getPort(),
+ ldap_error($ds)
+ );
+ }
+
+ $entry = ldap_first_entry($ds, $result);
+ if ($entry === false) {
+ throw new LdapException(
+ 'Capabilities not available (%s; Default port: %d): %s. Discovery of root DSE probably not permitted.',
+ $connection->getHostname(),
+ $connection->getPort(),
+ ldap_error($ds)
+ );
+ }
+
+ $cap = new LdapCapabilities($connection->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields));
+ $cap->discoverAdConfigOptions($connection);
+
+ if (isset($cap->attributes) && Logger::getInstance()->getLevel() === Logger::DEBUG) {
+ Logger::debug('Capability query discovered the following attributes:');
+ foreach ($cap->attributes as $name => $value) {
+ if ($value !== null) {
+ Logger::debug(' %s = %s', $name, $value);
+ }
+ }
+ Logger::debug('Capability query attribute listing ended.');
+ }
+
+ return $cap;
+ }
+
+ /**
+ * Discover the AD-specific configuration options of the given LDAP server
+ *
+ * @param LdapConnection $connection The ldap connection to use
+ *
+ * @throws LdapException In case the configuration options query has failed
+ */
+ protected function discoverAdConfigOptions(LdapConnection $connection)
+ {
+ if ($this->isActiveDirectory()) {
+ $configurationNamingContext = $this->getConfigurationNamingContext();
+ $defaultNamingContext = $this->getDefaultNamingContext();
+ if (!($configurationNamingContext === null || $defaultNamingContext === null)) {
+ $ds = $connection->bind()->getConnection();
+ $adFields = array('nETBIOSName');
+ $partitions = 'CN=Partitions,' . $configurationNamingContext;
+
+ $result = @ldap_list(
+ $ds,
+ $partitions,
+ (string) $connection->select()->from('*', $adFields)->where('nCName', $defaultNamingContext),
+ $adFields
+ );
+ if ($result) {
+ $entry = ldap_first_entry($ds, $result);
+ if ($entry === false) {
+ throw new LdapException(
+ 'Configuration options not available (%s:%d). Discovery of "%s" probably not permitted.',
+ $connection->getHostname(),
+ $connection->getPort(),
+ $partitions
+ );
+ }
+
+ $this->setAttributes((object) array_merge(
+ (array) $this->attributes,
+ (array) $connection->cleanupAttributes(ldap_get_attributes($ds, $entry), $adFields)
+ ));
+ } else {
+ if (ldap_errno($ds) !== 1) {
+ // One stands for "operations error" which occurs if not bound non-anonymously.
+
+ throw new LdapException(
+ 'Configuration options query failed (%s:%d): %s. Check if hostname and port of the'
+ . ' ldap resource are correct and if anonymous access is permitted.',
+ $connection->getHostname(),
+ $connection->getPort(),
+ ldap_error($ds)
+ );
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Determine the active directory version using the available capabillities
+ *
+ * @return null|string The server version description or null when unknown
+ */
+ protected function getAdObjectVersionName()
+ {
+ if (isset($this->oids[self::LDAP_CAP_ACTIVE_DIRECTORY_W8_OID])) {
+ return 'Windows Server 2012 (or newer)';
+ }
+ if (isset($this->oids[self::LDAP_CAP_ACTIVE_DIRECTORY_V61_R2_OID])) {
+ return 'Windows Server 2008 R2 (or newer)';
+ }
+ if (isset($this->oids[self::LDAP_CAP_ACTIVE_DIRECTORY_V60_OID])) {
+ return 'Windows Server 2008 (or newer)';
+ }
+ return null;
+ }
+}
diff --git a/library/Icinga/Protocol/Ldap/LdapConnection.php b/library/Icinga/Protocol/Ldap/LdapConnection.php
new file mode 100644
index 0000000..982db42
--- /dev/null
+++ b/library/Icinga/Protocol/Ldap/LdapConnection.php
@@ -0,0 +1,1584 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Ldap;
+
+use ArrayIterator;
+use Exception;
+use Icinga\Data\Filter\FilterNot;
+use LogicException;
+use stdClass;
+use Icinga\Application\Config;
+use Icinga\Application\Logger;
+use Icinga\Data\ConfigObject;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filter\FilterChain;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Data\Inspectable;
+use Icinga\Data\Inspection;
+use Icinga\Data\Selectable;
+use Icinga\Data\Sortable;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Web\Url;
+
+/**
+ * Encapsulate LDAP connections and query creation
+ */
+class LdapConnection implements Selectable, Inspectable
+{
+ /**
+ * Indicates that the target object cannot be found
+ *
+ * @var int
+ */
+ const LDAP_NO_SUCH_OBJECT = 32;
+
+ /**
+ * Indicates that in a search operation, the size limit specified by the client or the server has been exceeded
+ *
+ * @var int
+ */
+ const LDAP_SIZELIMIT_EXCEEDED = 4;
+
+ /**
+ * Indicates that an LDAP server limit set by an administrative authority has been exceeded
+ *
+ * @var int
+ */
+ const LDAP_ADMINLIMIT_EXCEEDED = 11;
+
+ /**
+ * Indicates that during a bind operation one of the following occurred: The client passed either an incorrect DN
+ * or password, or the password is incorrect because it has expired, intruder detection has locked the account, or
+ * another similar reason.
+ *
+ * @var int
+ */
+ const LDAP_INVALID_CREDENTIALS = 49;
+
+ /**
+ * The default page size to use for paged queries
+ *
+ * @var int
+ */
+ const PAGE_SIZE = 1000;
+
+ /**
+ * Encrypt connection using STARTTLS (upgrading a plain text connection)
+ *
+ * @var string
+ */
+ const STARTTLS = 'starttls';
+
+ /**
+ * Encrypt connection using LDAP over SSL (using a separate port)
+ *
+ * @var string
+ */
+ const LDAPS = 'ldaps';
+
+ /** @var ConfigObject Connection configuration */
+ protected $config;
+
+ /**
+ * Encryption for the connection if any
+ *
+ * @var string
+ */
+ protected $encryption;
+
+ /**
+ * The LDAP link identifier being used
+ *
+ * @var resource
+ */
+ protected $ds;
+
+ /**
+ * The ip address, hostname or ldap URI being used to connect with the LDAP server
+ *
+ * @var string
+ */
+ protected $hostname;
+
+ /**
+ * The port being used to connect with the LDAP server
+ *
+ * @var int
+ */
+ protected $port;
+
+ /**
+ * The distinguished name being used to bind to the LDAP server
+ *
+ * @var string
+ */
+ protected $bindDn;
+
+ /**
+ * The password being used to bind to the LDAP server
+ *
+ * @var string
+ */
+ protected $bindPw;
+
+ /**
+ * The distinguished name being used as the base path for queries which do not provide one theirselves
+ *
+ * @var string
+ */
+ protected $rootDn;
+
+ /**
+ * Whether the bind on this connection has already been performed
+ *
+ * @var bool
+ */
+ protected $bound;
+
+ /**
+ * The current connection's root node
+ *
+ * @var Root
+ */
+ protected $root;
+
+ /**
+ * LDAP_OPT_NETWORK_TIMEOUT for the LDAP connection
+ *
+ * @var int
+ */
+ protected $timeout;
+
+ /**
+ * The properties and capabilities of the LDAP server
+ *
+ * @var LdapCapabilities
+ */
+ protected $capabilities;
+
+ /**
+ * Whether discovery was successful
+ *
+ * @var bool
+ */
+ protected $discoverySuccess;
+
+ /**
+ * The cause of the discovery's failure
+ *
+ * @var Exception|null
+ */
+ private $discoveryError;
+
+ /**
+ * Whether the current connection is encrypted
+ *
+ * @var bool
+ */
+ protected $encrypted = null;
+
+ /**
+ * Create a new connection object
+ *
+ * @param ConfigObject $config
+ */
+ public function __construct(ConfigObject $config)
+ {
+ $this->config = $config;
+ $this->hostname = $config->hostname;
+ $this->bindDn = $config->bind_dn;
+ $this->bindPw = $config->bind_pw;
+ $this->rootDn = $config->root_dn;
+ $this->port = (int) $config->get('port', 389);
+ $this->timeout = (int) $config->get('timeout', 5);
+
+ $this->encryption = $config->encryption;
+ if ($this->encryption !== null) {
+ $this->encryption = strtolower($this->encryption);
+ }
+ }
+
+ /**
+ * Return the ip address, hostname or ldap URI being used to connect with the LDAP server
+ *
+ * @return string
+ */
+ public function getHostname()
+ {
+ return $this->hostname;
+ }
+
+ /**
+ * Return the port being used to connect with the LDAP server
+ *
+ * @return int
+ */
+ public function getPort()
+ {
+ return $this->port;
+ }
+
+ /**
+ * Return the distinguished name being used as the base path for queries which do not provide one theirselves
+ *
+ * @return string
+ */
+ public function getDn()
+ {
+ return $this->rootDn;
+ }
+
+ /**
+ * Return the root node for this connection
+ *
+ * @return Root
+ */
+ public function root()
+ {
+ if ($this->root === null) {
+ $this->root = Root::forConnection($this);
+ }
+
+ return $this->root;
+ }
+
+ /**
+ * Return the LDAP link identifier being used
+ *
+ * Establishes a connection if necessary.
+ *
+ * @return resource
+ */
+ public function getConnection()
+ {
+ if ($this->ds === null) {
+ $this->ds = $this->prepareNewConnection();
+ }
+
+ return $this->ds;
+ }
+
+ /**
+ * Return the capabilities of the current connection
+ *
+ * @return LdapCapabilities
+ */
+ public function getCapabilities()
+ {
+ if ($this->capabilities === null) {
+ try {
+ $this->capabilities = LdapCapabilities::discoverCapabilities($this);
+ $this->discoverySuccess = true;
+ $this->discoveryError = null;
+ } catch (LdapException $e) {
+ Logger::debug($e);
+ Logger::warning('LADP discovery failed, assuming default LDAP capabilities.');
+ $this->capabilities = new LdapCapabilities(); // create empty default capabilities
+ $this->discoverySuccess = false;
+ $this->discoveryError = $e;
+ }
+ }
+
+ return $this->capabilities;
+ }
+
+ /**
+ * Return whether discovery was successful
+ *
+ * @return bool true if the capabilities were successfully determined, false if the capabilities were guessed
+ */
+ public function discoverySuccessful()
+ {
+ if ($this->discoverySuccess === null) {
+ $this->getCapabilities(); // Initializes self::$discoverySuccess
+ }
+
+ return $this->discoverySuccess;
+ }
+
+ /**
+ * Get discovery error if any
+ *
+ * @return Exception|null
+ */
+ public function getDiscoveryError()
+ {
+ return $this->discoveryError;
+ }
+
+ /**
+ * Return whether the current connection is encrypted
+ *
+ * @return bool
+ */
+ public function isEncrypted()
+ {
+ if ($this->encrypted === null) {
+ return false;
+ }
+
+ return $this->encrypted;
+ }
+
+ /**
+ * Perform a LDAP bind on the current connection
+ *
+ * @throws LdapException In case the LDAP bind was unsuccessful or insecure
+ */
+ public function bind()
+ {
+ if ($this->bound) {
+ return $this;
+ }
+
+ $ds = $this->getConnection();
+
+ $success = @ldap_bind($ds, $this->bindDn, $this->bindPw);
+ if (! $success) {
+ throw new LdapException(
+ 'LDAP bind (%s / %s) to %s failed: %s',
+ $this->bindDn,
+ '***' /* $this->bindPw */,
+ $this->normalizeHostname($this->hostname),
+ ldap_error($ds)
+ );
+ }
+
+ $this->bound = true;
+ return $this;
+ }
+
+ /**
+ * Provide a query on this connection
+ *
+ * @return LdapQuery
+ */
+ public function select()
+ {
+ return new LdapQuery($this);
+ }
+
+ /**
+ * Fetch and return all rows of the given query's result set using an iterator
+ *
+ * @param LdapQuery $query The query returning the result set
+ *
+ * @return ArrayIterator
+ */
+ public function query(LdapQuery $query)
+ {
+ return new ArrayIterator($this->fetchAll($query));
+ }
+
+ /**
+ * Count all rows of the given query's result set
+ *
+ * @param LdapQuery $query The query returning the result set
+ *
+ * @return int
+ */
+ public function count(LdapQuery $query)
+ {
+ $this->bind();
+
+ if (($unfoldAttribute = $query->getUnfoldAttribute()) !== null) {
+ $desiredColumns = $query->getColumns();
+ if (isset($desiredColumns[$unfoldAttribute])) {
+ $fields = array($unfoldAttribute => $desiredColumns[$unfoldAttribute]);
+ } elseif (in_array($unfoldAttribute, $desiredColumns, true)) {
+ $fields = array($unfoldAttribute);
+ } else {
+ throw new ProgrammingError(
+ 'The attribute used to unfold a query\'s result must be selected'
+ );
+ }
+
+ $res = $this->runQuery($query, $fields);
+ return count($res);
+ }
+
+ $ds = $this->getConnection();
+ $results = $this->ldapSearch($query, array('dn'));
+
+ if ($results === false) {
+ if (ldap_errno($ds) !== self::LDAP_NO_SUCH_OBJECT) {
+ throw new LdapException(
+ 'LDAP count query "%s" (base %s) failed: %s',
+ (string) $query,
+ $query->getBase() ?: $this->getDn(),
+ ldap_error($ds)
+ );
+ }
+ }
+
+ return ldap_count_entries($ds, $results);
+ }
+
+ /**
+ * Retrieve an array containing all rows of the result set
+ *
+ * @param LdapQuery $query The query returning the result set
+ * @param array $fields Request these attributes instead of the ones registered in the given query
+ *
+ * @return array
+ */
+ public function fetchAll(LdapQuery $query, array $fields = null)
+ {
+ $this->bind();
+
+ if ($query->getUsePagedResults() && $this->getCapabilities()->hasPagedResult()) {
+ return $this->runPagedQuery($query, $fields);
+ } else {
+ return $this->runQuery($query, $fields);
+ }
+ }
+
+ /**
+ * Fetch the first row of the result set
+ *
+ * @param LdapQuery $query The query returning the result set
+ * @param array $fields Request these attributes instead of the ones registered in the given query
+ *
+ * @return mixed
+ */
+ public function fetchRow(LdapQuery $query, array $fields = null)
+ {
+ $clonedQuery = clone $query;
+ $clonedQuery->limit(1);
+ $clonedQuery->setUsePagedResults(false);
+ $results = $this->fetchAll($clonedQuery, $fields);
+ return array_shift($results) ?: false;
+ }
+
+ /**
+ * Fetch the first column of all rows of the result set as an array
+ *
+ * @param LdapQuery $query The query returning the result set
+ * @param array $fields Request these attributes instead of the ones registered in the given query
+ *
+ * @return array
+ *
+ * @throws ProgrammingError In case no attribute is being requested
+ */
+ public function fetchColumn(LdapQuery $query, array $fields = null)
+ {
+ if ($fields === null) {
+ $fields = $query->getColumns();
+ }
+
+ if (empty($fields)) {
+ throw new ProgrammingError('You must request at least one attribute when fetching a single column');
+ }
+
+ $alias = key($fields);
+ $results = $this->fetchAll($query, array($alias => current($fields)));
+ $column = is_int($alias) ? current($fields) : $alias;
+ $values = array();
+ foreach ($results as $row) {
+ if (isset($row->$column)) {
+ $values[] = $row->$column;
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * Fetch the first column of the first row of the result set
+ *
+ * @param LdapQuery $query The query returning the result set
+ * @param array $fields Request these attributes instead of the ones registered in the given query
+ *
+ * @return string
+ */
+ public function fetchOne(LdapQuery $query, array $fields = null)
+ {
+ $row = $this->fetchRow($query, $fields);
+ if ($row === false) {
+ return false;
+ }
+
+ $values = get_object_vars($row);
+ if (empty($values)) {
+ return false;
+ }
+
+ if ($fields === null) {
+ // Fetch the desired columns from the query if not explicitly overriden in the method's parameter
+ $fields = $query->getColumns();
+ }
+
+ if (empty($fields)) {
+ // The desired columns may be empty independently whether provided by the query or the method's parameter
+ return array_shift($values);
+ }
+
+ $alias = key($fields);
+ return $values[is_string($alias) ? $alias : $fields[$alias]];
+ }
+
+ /**
+ * Fetch all rows of the result set as an array of key-value pairs
+ *
+ * The first column is the key, the second column is the value.
+ *
+ * @param LdapQuery $query The query returning the result set
+ * @param array $fields Request these attributes instead of the ones registered in the given query
+ *
+ * @return array
+ *
+ * @throws ProgrammingError In case there are less than two attributes being requested
+ */
+ public function fetchPairs(LdapQuery $query, array $fields = null)
+ {
+ if ($fields === null) {
+ $fields = $query->getColumns();
+ }
+
+ if (count($fields) < 2) {
+ throw new ProgrammingError('You are required to request at least two attributes');
+ }
+
+ $columns = $desiredColumnNames = array();
+ foreach ($fields as $alias => $column) {
+ if (is_int($alias)) {
+ $columns[] = $column;
+ $desiredColumnNames[] = $column;
+ } else {
+ $columns[$alias] = $column;
+ $desiredColumnNames[] = $alias;
+ }
+
+ if (count($desiredColumnNames) === 2) {
+ break;
+ }
+ }
+
+ $results = $this->fetchAll($query, $columns);
+ $pairs = array();
+ foreach ($results as $row) {
+ $colOne = $desiredColumnNames[0];
+ $colTwo = $desiredColumnNames[1];
+ $pairs[$row->$colOne] = $row->$colTwo;
+ }
+
+ return $pairs;
+ }
+
+ /**
+ * Fetch an LDAP entry by its DN
+ *
+ * @param string $dn
+ * @param array|null $fields
+ *
+ * @return StdClass|bool
+ */
+ public function fetchByDn($dn, array $fields = null)
+ {
+ return $this->select()
+ ->from('*', $fields)
+ ->setBase($dn)
+ ->setScope('base')
+ ->fetchRow();
+ }
+
+ /**
+ * Test the given LDAP credentials by establishing a connection and attempting a LDAP bind
+ *
+ * @param string $bindDn
+ * @param string $bindPw
+ *
+ * @return bool Whether the given credentials are valid
+ *
+ * @throws LdapException In case an error occured while establishing the connection or attempting the bind
+ */
+ public function testCredentials($bindDn, $bindPw)
+ {
+ $ds = $this->getConnection();
+ $success = @ldap_bind($ds, $bindDn, $bindPw);
+ if (! $success) {
+ if (ldap_errno($ds) === self::LDAP_INVALID_CREDENTIALS) {
+ Logger::debug(
+ 'Testing LDAP credentials (%s / %s) failed: %s',
+ $bindDn,
+ '***',
+ ldap_error($ds)
+ );
+ return false;
+ }
+
+ throw new LdapException(ldap_error($ds));
+ }
+
+ return true;
+ }
+
+ /**
+ * Return whether an entry identified by the given distinguished name exists
+ *
+ * @param string $dn
+ *
+ * @return bool
+ */
+ public function hasDn($dn)
+ {
+ $ds = $this->getConnection();
+ $this->bind();
+
+ $result = ldap_read($ds, $dn, '(objectClass=*)', array('objectClass'));
+ return ldap_count_entries($ds, $result) > 0;
+ }
+
+ /**
+ * Delete a root entry and all of its children identified by the given distinguished name
+ *
+ * @param string $dn
+ *
+ * @return bool
+ *
+ * @throws LdapException In case an error occured while deleting an entry
+ */
+ public function deleteRecursively($dn)
+ {
+ $ds = $this->getConnection();
+ $this->bind();
+
+ $result = @ldap_list($ds, $dn, '(objectClass=*)', array('objectClass'));
+ if ($result === false) {
+ if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) {
+ return false;
+ }
+
+ throw new LdapException('LDAP list for "%s" failed: %s', $dn, ldap_error($ds));
+ }
+
+ $children = ldap_get_entries($ds, $result);
+ for ($i = 0; $i < $children['count']; $i++) {
+ $result = $this->deleteRecursively($children[$i]['dn']);
+ if (! $result) {
+ // TODO: return result code, if delete fails
+ throw new LdapException('Recursively deleting "%s" failed', $dn);
+ }
+ }
+
+ return $this->deleteDn($dn);
+ }
+
+ /**
+ * Delete a single entry identified by the given distinguished name
+ *
+ * @param string $dn
+ *
+ * @return bool
+ *
+ * @throws LdapException In case an error occured while deleting the entry
+ */
+ public function deleteDn($dn)
+ {
+ $ds = $this->getConnection();
+ $this->bind();
+
+ $result = @ldap_delete($ds, $dn);
+ if ($result === false) {
+ if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) {
+ return false; // TODO: Isn't it a success if something i'd like to remove is not existing at all???
+ }
+
+ throw new LdapException('LDAP delete for "%s" failed: %s', $dn, ldap_error($ds));
+ }
+
+ return true;
+ }
+
+ /**
+ * Fetch the distinguished name of the result of the given query
+ *
+ * @param LdapQuery $query The query returning the result set
+ *
+ * @return string The distinguished name, or false when the given query yields no results
+ *
+ * @throws LdapException In case the query yields multiple results
+ */
+ public function fetchDn(LdapQuery $query)
+ {
+ $rows = $this->fetchAll($query, array());
+ if (count($rows) > 1) {
+ throw new LdapException('Cannot fetch single DN for %s', $query);
+ }
+
+ return key($rows);
+ }
+
+ /**
+ * Run the given LDAP query and return the resulting entries
+ *
+ * @param LdapQuery $query The query to fetch results with
+ * @param array $fields Request these attributes instead of the ones registered in the given query
+ *
+ * @return array
+ *
+ * @throws LdapException In case an error occured while fetching the results
+ */
+ protected function runQuery(LdapQuery $query, array $fields = null)
+ {
+ $limit = $query->getLimit();
+ $offset = $query->hasOffset() ? $query->getOffset() : 0;
+
+ if ($fields === null) {
+ $fields = $query->getColumns();
+ }
+
+ $ds = $this->getConnection();
+
+ $serverSorting = ! $this->config->disable_server_side_sort
+ && $this->getCapabilities()->hasOid(LdapCapabilities::LDAP_SERVER_SORT_OID);
+
+ if ($query->hasOrder()) {
+ if ($serverSorting) {
+ ldap_set_option($ds, LDAP_OPT_SERVER_CONTROLS, array(
+ array(
+ 'oid' => LdapCapabilities::LDAP_SERVER_SORT_OID,
+ 'value' => $this->encodeSortRules($query->getOrder())
+ )
+ ));
+ } elseif (! empty($fields)) {
+ foreach ($query->getOrder() as $rule) {
+ if (! in_array($rule[0], $fields, true)) {
+ $fields[] = $rule[0];
+ }
+ }
+ }
+ }
+
+ $unfoldAttribute = $query->getUnfoldAttribute();
+ if ($unfoldAttribute) {
+ foreach ($query->getFilter()->listFilteredColumns() as $filterColumn) {
+ $fieldKey = array_search($filterColumn, $fields, true);
+ if ($fieldKey === false || is_string($fieldKey)) {
+ $fields[] = $filterColumn;
+ }
+ }
+ }
+
+ $results = $this->ldapSearch(
+ $query,
+ array_values($fields),
+ 0,
+ ($serverSorting || ! $query->hasOrder()) && $limit ? $offset + $limit : 0
+ );
+ if ($results === false) {
+ if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) {
+ return array();
+ }
+
+ throw new LdapException(
+ 'LDAP query "%s" (base %s) failed. Error: %s',
+ $query,
+ $query->getBase() ?: $this->rootDn,
+ ldap_error($ds)
+ );
+ } elseif (ldap_count_entries($ds, $results) === 0) {
+ return array();
+ }
+
+ $count = 0;
+ $entries = array();
+ $entry = ldap_first_entry($ds, $results);
+ do {
+ if ($unfoldAttribute) {
+ $rows = $this->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields, $unfoldAttribute);
+ if (is_array($rows)) {
+ // TODO: Register the DN the same way as a section name in the ArrayDatasource!
+ foreach ($rows as $row) {
+ if ($query->getFilter()->matches($row)) {
+ $count += 1;
+ if (! $serverSorting || $offset === 0 || $offset < $count) {
+ $entries[] = $row;
+ }
+
+ if ($serverSorting && $limit > 0 && $limit === count($entries)) {
+ break;
+ }
+ }
+ }
+ } else {
+ $count += 1;
+ if (! $serverSorting || $offset === 0 || $offset < $count) {
+ $entries[ldap_get_dn($ds, $entry)] = $rows;
+ }
+ }
+ } else {
+ $count += 1;
+ if (! $serverSorting || $offset === 0 || $offset < $count) {
+ $entries[ldap_get_dn($ds, $entry)] = $this->cleanupAttributes(
+ ldap_get_attributes($ds, $entry),
+ $fields
+ );
+ }
+ }
+ } while ((! $serverSorting || $limit === 0 || $limit !== count($entries))
+ && ($entry = ldap_next_entry($ds, $entry))
+ );
+
+ if (! $serverSorting) {
+ if ($query->hasOrder()) {
+ uasort($entries, array($query, 'compare'));
+ }
+
+ if ($limit && $count > $limit) {
+ $entries = array_splice($entries, $query->hasOffset() ? $query->getOffset() : 0, $limit);
+ }
+ }
+
+ ldap_free_result($results);
+ return $entries;
+ }
+
+ /**
+ * Run the given LDAP query and return the resulting entries
+ *
+ * This utilizes paged search requests as defined in RFC 2696.
+ *
+ * @param LdapQuery $query The query to fetch results with
+ * @param array $fields Request these attributes instead of the ones registered in the given query
+ * @param int $pageSize The maximum page size, defaults to self::PAGE_SIZE
+ *
+ * @return array
+ *
+ * @throws LdapException In case an error occured while fetching the results
+ */
+ protected function runPagedQuery(LdapQuery $query, array $fields = null, $pageSize = null)
+ {
+ if ($pageSize === null) {
+ $pageSize = static::PAGE_SIZE;
+ }
+
+ $limit = $query->getLimit();
+ $offset = $query->hasOffset() ? $query->getOffset() : 0;
+
+ if ($fields === null) {
+ $fields = $query->getColumns();
+ }
+
+ $ds = $this->getConnection();
+
+ $serverSorting = false;//$this->getCapabilities()->hasOid(LdapCapabilities::LDAP_SERVER_SORT_OID);
+ if (! $serverSorting && $query->hasOrder() && ! empty($fields)) {
+ foreach ($query->getOrder() as $rule) {
+ if (! in_array($rule[0], $fields, true)) {
+ $fields[] = $rule[0];
+ }
+ }
+ }
+
+ $unfoldAttribute = $query->getUnfoldAttribute();
+ if ($unfoldAttribute) {
+ foreach ($query->getFilter()->listFilteredColumns() as $filterColumn) {
+ $fieldKey = array_search($filterColumn, $fields, true);
+ if ($fieldKey === false || is_string($fieldKey)) {
+ $fields[] = $filterColumn;
+ }
+ }
+ }
+
+ $controls = [];
+ $legacyControlHandling = version_compare(PHP_VERSION, '7.3.0') < 0;
+ if ($serverSorting && $query->hasOrder()) {
+ $control = [
+ 'oid' => LDAP_CONTROL_SORTREQUEST,
+ 'value' => $this->encodeSortRules($query->getOrder())
+ ];
+ if ($legacyControlHandling) {
+ ldap_set_option($ds, LDAP_OPT_SERVER_CONTROLS, [$control]);
+ } else {
+ $controls[LDAP_CONTROL_SORTREQUEST] = $control;
+ }
+ }
+
+ $count = 0;
+ $cookie = '';
+ $entries = array();
+ do {
+ if ($legacyControlHandling) {
+ // Do not request the pagination control as a critical extension, as we want the
+ // server to return results even if the paged search request cannot be satisfied
+ ldap_control_paged_result($ds, $pageSize, false, $cookie);
+ } else {
+ $controls[LDAP_CONTROL_PAGEDRESULTS] = [
+ 'oid' => LDAP_CONTROL_PAGEDRESULTS,
+ 'iscritical' => false, // See above
+ 'value' => [
+ 'size' => $pageSize,
+ 'cookie' => $cookie
+ ]
+ ];
+ }
+
+ $results = $this->ldapSearch(
+ $query,
+ array_values($fields),
+ 0,
+ ($serverSorting || ! $query->hasOrder()) && $limit ? $offset + $limit : 0,
+ 0,
+ LDAP_DEREF_NEVER,
+ empty($controls) ? null : $controls
+ );
+ if ($results === false) {
+ if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) {
+ break;
+ }
+
+ throw new LdapException(
+ 'LDAP query "%s" (base %s) failed. Error: %s',
+ (string) $query,
+ $query->getBase() ?: $this->getDn(),
+ ldap_error($ds)
+ );
+ } elseif (ldap_count_entries($ds, $results) === 0) {
+ if (in_array(
+ ldap_errno($ds),
+ array(static::LDAP_SIZELIMIT_EXCEEDED, static::LDAP_ADMINLIMIT_EXCEEDED),
+ true
+ )) {
+ Logger::warning(
+ 'Unable to request more than %u results. Does the server allow paged search requests? (%s)',
+ $count,
+ ldap_error($ds)
+ );
+ }
+
+ break;
+ }
+
+ $entry = ldap_first_entry($ds, $results);
+ do {
+ if ($unfoldAttribute) {
+ $rows = $this->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields, $unfoldAttribute);
+ if (is_array($rows)) {
+ // TODO: Register the DN the same way as a section name in the ArrayDatasource!
+ foreach ($rows as $row) {
+ if ($query->getFilter()->matches($row)) {
+ $count += 1;
+ if (! $serverSorting || $offset === 0 || $offset < $count) {
+ $entries[] = $row;
+ }
+
+ if ($serverSorting && $limit > 0 && $limit === count($entries)) {
+ break;
+ }
+ }
+ }
+ } else {
+ $count += 1;
+ if (! $serverSorting || $offset === 0 || $offset < $count) {
+ $entries[ldap_get_dn($ds, $entry)] = $rows;
+ }
+ }
+ } else {
+ $count += 1;
+ if (! $serverSorting || $offset === 0 || $offset < $count) {
+ $entries[ldap_get_dn($ds, $entry)] = $this->cleanupAttributes(
+ ldap_get_attributes($ds, $entry),
+ $fields
+ );
+ }
+ }
+ } while ((! $serverSorting || $limit === 0 || $limit !== count($entries))
+ && ($entry = ldap_next_entry($ds, $entry))
+ );
+
+ if ($legacyControlHandling) {
+ if (false === @ldap_control_paged_result_response($ds, $results, $cookie)) {
+ // If the page size is greater than or equal to the sizeLimit value, the server should ignore the
+ // control as the request can be satisfied in a single page: https://www.ietf.org/rfc/rfc2696.txt
+ // This applies no matter whether paged search requests are permitted or not. You're done once you
+ // got everything you were out for.
+ if ($serverSorting && count($entries) !== $limit) {
+ // The server does not support pagination, but still returned a response by ignoring the
+ // pagedResultsControl. We output a warning to indicate that the pagination control was ignored.
+ Logger::warning(
+ 'Unable to request paged LDAP results. Does the server allow paged search requests?'
+ );
+ }
+ }
+ } else {
+ ldap_parse_result($ds, $results, $errno, $dn, $errmsg, $refs, $controlsReturned);
+ $cookie = $controlsReturned[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie'];
+ }
+
+ ldap_free_result($results);
+ } while ($cookie && (! $serverSorting || $limit === 0 || count($entries) < $limit));
+
+ if ($legacyControlHandling && $cookie) {
+ // A sequence of paged search requests is abandoned by the client sending a search request containing a
+ // pagedResultsControl with the size set to zero (0) and the cookie set to the last cookie returned by
+ // the server: https://www.ietf.org/rfc/rfc2696.txt
+ ldap_control_paged_result($ds, 0, false, $cookie);
+ // Returns no entries, due to the page size
+ ldap_search($ds, $query->getBase() ?: $this->getDn(), (string) $query);
+ }
+
+ if (! $serverSorting) {
+ if ($query->hasOrder()) {
+ uasort($entries, array($query, 'compare'));
+ }
+
+ if ($limit && $count > $limit) {
+ $entries = array_splice($entries, $query->hasOffset() ? $query->getOffset() : 0, $limit);
+ }
+ }
+
+ return $entries;
+ }
+
+ /**
+ * Clean up the given attributes and return them as simple object
+ *
+ * Applies column aliases, aggregates/unfolds multi-value attributes
+ * as array and sets null for each missing attribute.
+ *
+ * @param array $attributes
+ * @param array $requestedFields
+ * @param string $unfoldAttribute
+ *
+ * @return object|array An array in case the object has been unfolded
+ */
+ public function cleanupAttributes($attributes, array $requestedFields, $unfoldAttribute = null)
+ {
+ // In case the result contains attributes with a differing case than the requested fields, it is
+ // necessary to create another array to map attributes case insensitively to their requested counterparts.
+ // This does also apply the virtual alias handling. (Since an LDAP server does not handle such)
+ $loweredFieldMap = array();
+ foreach ($requestedFields as $alias => $name) {
+ $loweredName = strtolower($name);
+ if (isset($loweredFieldMap[$loweredName])) {
+ if (! is_array($loweredFieldMap[$loweredName])) {
+ $loweredFieldMap[$loweredName] = array($loweredFieldMap[$loweredName]);
+ }
+
+ $loweredFieldMap[$loweredName][] = is_string($alias) ? $alias : $name;
+ } else {
+ $loweredFieldMap[$loweredName] = is_string($alias) ? $alias : $name;
+ }
+ }
+
+ $cleanedAttributes = array();
+ for ($i = 0; $i < $attributes['count']; $i++) {
+ $attribute_name = $attributes[$i];
+ if ($attributes[$attribute_name]['count'] === 1) {
+ $attribute_value = $attributes[$attribute_name][0];
+ } else {
+ $attribute_value = array();
+ for ($j = 0; $j < $attributes[$attribute_name]['count']; $j++) {
+ $attribute_value[] = $attributes[$attribute_name][$j];
+ }
+ }
+
+ $requestedAttributeName = isset($loweredFieldMap[strtolower($attribute_name)])
+ ? $loweredFieldMap[strtolower($attribute_name)]
+ : $attribute_name;
+ if (is_array($requestedAttributeName)) {
+ foreach ($requestedAttributeName as $requestedName) {
+ $cleanedAttributes[$requestedName] = $attribute_value;
+ }
+ } else {
+ $cleanedAttributes[$requestedAttributeName] = $attribute_value;
+ }
+ }
+
+ // The result may not contain all requested fields, so populate the cleaned
+ // result with the missing fields and their value being set to null
+ foreach ($requestedFields as $alias => $name) {
+ if (! is_string($alias)) {
+ $alias = $name;
+ }
+
+ if (! array_key_exists($alias, $cleanedAttributes)) {
+ $cleanedAttributes[$alias] = null;
+ Logger::debug('LDAP query result does not provide the requested field "%s"', $name);
+ }
+ }
+
+ if ($unfoldAttribute !== null
+ && isset($cleanedAttributes[$unfoldAttribute])
+ && is_array($cleanedAttributes[$unfoldAttribute])
+ ) {
+ $siblings = array();
+ foreach ($loweredFieldMap as $loweredName => $requestedNames) {
+ if (is_array($requestedNames) && in_array($unfoldAttribute, $requestedNames, true)) {
+ $siblings = array_diff($requestedNames, array($unfoldAttribute));
+ break;
+ }
+ }
+
+ $values = $cleanedAttributes[$unfoldAttribute];
+ unset($cleanedAttributes[$unfoldAttribute]);
+ $baseRow = (object) $cleanedAttributes;
+ $rows = array();
+ foreach ($values as $value) {
+ $row = clone $baseRow;
+ $row->{$unfoldAttribute} = $value;
+ foreach ($siblings as $sibling) {
+ $row->{$sibling} = $value;
+ }
+
+ $rows[] = $row;
+ }
+
+ return $rows;
+ }
+
+ return (object) $cleanedAttributes;
+ }
+
+ /**
+ * Encode the given array of sort rules as ASN.1 octet stream according to RFC 2891
+ *
+ * @param array $sortRules
+ *
+ * @return string Binary representation of the octet stream
+ */
+ protected function encodeSortRules(array $sortRules)
+ {
+ $sequenceOf = '';
+
+ foreach ($sortRules as $rule) {
+ if ($rule[1] === Sortable::SORT_DESC) {
+ $reversed = '8101ff';
+ } else {
+ $reversed = '';
+ }
+
+ $attributeType = unpack('H*', $rule[0]);
+ $attributeType = $attributeType[1];
+ $attributeOctets = strlen($attributeType) / 2;
+ if ($attributeOctets >= 127) {
+ // Use the indefinite form of the length octets (the long form would be another option)
+ $attributeType = '0440' . $attributeType . '0000';
+ } else {
+ $attributeType = '04' . str_pad(dechex($attributeOctets), 2, '0', STR_PAD_LEFT) . $attributeType;
+ }
+
+ $sequence = $attributeType . $reversed;
+ $sequenceOctects = strlen($sequence) / 2;
+ if ($sequenceOctects >= 127) {
+ $sequence = '3040' . $sequence . '0000';
+ } else {
+ $sequence = '30' . str_pad(dechex($sequenceOctects), 2, '0', STR_PAD_LEFT) . $sequence;
+ }
+
+ $sequenceOf .= $sequence;
+ }
+
+ $sequenceOfOctets = strlen($sequenceOf) / 2;
+ if ($sequenceOfOctets >= 127) {
+ $sequenceOf = '3040' . $sequenceOf . '0000';
+ } else {
+ $sequenceOf = '30' . str_pad(dechex($sequenceOfOctets), 2, '0', STR_PAD_LEFT) . $sequenceOf;
+ }
+
+ return hex2bin($sequenceOf);
+ }
+
+ /**
+ * Prepare and establish a connection with the LDAP server
+ *
+ * @param Inspection $info Optional inspection to fill with diagnostic info
+ *
+ * @return resource A LDAP link identifier
+ *
+ * @throws LdapException In case the connection is not possible
+ */
+ protected function prepareNewConnection(Inspection $info = null)
+ {
+ if (! isset($info)) {
+ $info = new Inspection('');
+ }
+
+ $hostname = $this->normalizeHostname($this->hostname);
+
+ $ds = ldap_connect($hostname, $this->port);
+
+ // Set a proper timeout for each connection
+ ldap_set_option($ds, LDAP_OPT_NETWORK_TIMEOUT, $this->timeout);
+
+ // Usage of ldap_rename, setting LDAP_OPT_REFERRALS to 0 or using STARTTLS requires LDAPv3.
+ // If this does not work we're probably not in a PHP 5.3+ environment as it is VERY
+ // unlikely that the server complains about it by itself prior to a bind request
+ ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3);
+
+ // Not setting this results in "Operations error" on AD when using the whole domain as search base
+ ldap_set_option($ds, LDAP_OPT_REFERRALS, 0);
+
+ if ($this->encryption === static::LDAPS) {
+ $info->write('Connect using LDAPS');
+ } elseif ($this->encryption === static::STARTTLS) {
+ $this->encrypted = true;
+ $info->write('Connect using STARTTLS');
+ if (! ldap_start_tls($ds)) {
+ throw new LdapException('LDAP STARTTLS failed: %s', ldap_error($ds));
+ }
+ } elseif ($this->encryption !== static::LDAPS) {
+ $this->encrypted = false;
+ $info->write('Connect without encryption');
+ }
+
+ return $ds;
+ }
+
+ /**
+ * Perform a LDAP search and return the result
+ *
+ * @param LdapQuery $query
+ * @param array $attributes An array of the required attributes
+ * @param int $attrsonly Should be set to 1 if only attribute types are wanted
+ * @param int $sizelimit Enables you to limit the count of entries fetched
+ * @param int $timelimit Sets the number of seconds how long is spend on the search
+ * @param int $deref
+ * @param array $controls LDAP Controls to send with the request (Only supported with PHP v7.3+)
+ *
+ * @return resource|bool A search result identifier or false on error
+ *
+ * @throws LogicException If the LDAP query search scope is unsupported
+ */
+ public function ldapSearch(
+ LdapQuery $query,
+ array $attributes = null,
+ $attrsonly = 0,
+ $sizelimit = 0,
+ $timelimit = 0,
+ $deref = LDAP_DEREF_NEVER,
+ $controls = null
+ ) {
+ $queryString = (string) $query;
+ $baseDn = $query->getBase() ?: $this->getDn();
+ $scope = $query->getScope();
+
+ if (Logger::getInstance()->getLevel() === Logger::DEBUG) {
+ // We're checking the level by ourselves to avoid rendering the ldapsearch commandline for nothing
+ $starttlsParam = $this->encryption === static::STARTTLS ? ' -ZZ' : '';
+
+ $bindParams = '';
+ if ($this->bound) {
+ $bindParams = ' -D "' . $this->bindDn . '"' . ($this->bindPw ? ' -W' : '');
+ }
+
+ if ($deref === LDAP_DEREF_NEVER) {
+ $derefName = 'never';
+ } elseif ($deref === LDAP_DEREF_ALWAYS) {
+ $derefName = 'always';
+ } elseif ($deref === LDAP_DEREF_SEARCHING) {
+ $derefName = 'search';
+ } else { // $deref === LDAP_DEREF_FINDING
+ $derefName = 'find';
+ }
+
+ Logger::debug("Issuing LDAP search. Use '%s' to reproduce.", sprintf(
+ 'ldapsearch -P 3%s -H "%s"%s -b "%s" -s "%s" -z %u -l %u -a "%s"%s%s%s',
+ $starttlsParam,
+ $this->normalizeHostname($this->hostname),
+ $bindParams,
+ $baseDn,
+ $scope,
+ $sizelimit,
+ $timelimit,
+ $derefName,
+ $attrsonly ? ' -A' : '',
+ $queryString ? ' "' . $queryString . '"' : '',
+ $attributes ? ' "' . join('" "', $attributes) . '"' : ''
+ ));
+ }
+
+ switch ($scope) {
+ case LdapQuery::SCOPE_SUB:
+ $function = 'ldap_search';
+ break;
+ case LdapQuery::SCOPE_ONE:
+ $function = 'ldap_list';
+ break;
+ case LdapQuery::SCOPE_BASE:
+ $function = 'ldap_read';
+ break;
+ default:
+ throw new LogicException('LDAP scope %s not supported by ldapSearch', $scope);
+ }
+
+ // Explicit calls with and without controls,
+ // because the parameter is only supported since PHP 7.3.
+ // Since it is a public method,
+ // providing controls will naturally fail if the parameter is not supported by PHP.
+ if ($controls !== null) {
+ return @$function(
+ $this->getConnection(),
+ $baseDn,
+ $queryString,
+ $attributes,
+ $attrsonly,
+ $sizelimit,
+ $timelimit,
+ $deref,
+ $controls
+ );
+ } else {
+ return @$function(
+ $this->getConnection(),
+ $baseDn,
+ $queryString,
+ $attributes,
+ $attrsonly,
+ $sizelimit,
+ $timelimit,
+ $deref
+ );
+ }
+ }
+
+ /**
+ * Create an LDAP entry
+ *
+ * @param string $dn The distinguished name to use
+ * @param array $attributes The entry's attributes
+ *
+ * @return bool Whether the operation was successful
+ */
+ public function addEntry($dn, array $attributes)
+ {
+ return ldap_add($this->getConnection(), $dn, $attributes);
+ }
+
+ /**
+ * Modify an LDAP entry
+ *
+ * @param string $dn The distinguished name to use
+ * @param array $attributes The attributes to update the entry with
+ *
+ * @return bool Whether the operation was successful
+ */
+ public function modifyEntry($dn, array $attributes)
+ {
+ return ldap_modify($this->getConnection(), $dn, $attributes);
+ }
+
+ /**
+ * Change the distinguished name of an LDAP entry
+ *
+ * @param string $dn The entry's current distinguished name
+ * @param string $newRdn The new relative distinguished name
+ * @param string $newParentDn The new parent or superior entry's distinguished name
+ *
+ * @return resource The resulting search result identifier
+ *
+ * @throws LdapException In case an error occured
+ */
+ public function moveEntry($dn, $newRdn, $newParentDn)
+ {
+ $ds = $this->getConnection();
+ $result = ldap_rename($ds, $dn, $newRdn, $newParentDn, false);
+ if ($result === false) {
+ throw new LdapException('Could not move entry "%s" to "%s": %s', $dn, $newRdn, ldap_error($ds));
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return the LDAP specific configuration directory with the given relative path being appended
+ *
+ * @param string $sub
+ *
+ * @return string
+ */
+ protected function getConfigDir($sub = null)
+ {
+ $dir = Config::$configDir . '/ldap';
+ if ($sub !== null) {
+ $dir .= '/' . $sub;
+ }
+
+ return $dir;
+ }
+
+ /**
+ * Render and return a valid LDAP filter representation of the given filter
+ *
+ * @param Filter $filter
+ * @param int $level
+ *
+ * @return string
+ */
+ public function renderFilter(Filter $filter, $level = 0)
+ {
+ if ($filter->isExpression()) {
+ /** @var $filter FilterExpression */
+ return $this->renderFilterExpression($filter);
+ }
+
+ /** @var $filter FilterChain */
+ $parts = array();
+ foreach ($filter->filters() as $filterPart) {
+ $part = $this->renderFilter($filterPart, $level + 1);
+ if ($part) {
+ $parts[] = $part;
+ }
+ }
+
+ if (empty($parts)) {
+ return '';
+ }
+
+ $format = '%1$s(%2$s)';
+ if (count($parts) === 1 && ! $filter instanceof FilterNot) {
+ $format = '%2$s';
+ }
+ if ($level === 0) {
+ $format = '(' . $format . ')';
+ }
+
+ return sprintf($format, $filter->getOperatorSymbol(), implode(')(', $parts));
+ }
+
+ /**
+ * Render and return a valid LDAP filter expression of the given filter
+ *
+ * @param FilterExpression $filter
+ *
+ * @return string
+ */
+ protected function renderFilterExpression(FilterExpression $filter)
+ {
+ $column = $filter->getColumn();
+ $sign = $filter->getSign();
+ $expression = $filter->getExpression();
+ $format = '%1$s%2$s%3$s';
+
+ if ($expression === null || $expression === true) {
+ $expression = '*';
+ } elseif (is_array($expression)) {
+ $seqFormat = '|(%s)';
+ if ($sign === '!=') {
+ $seqFormat = '!(' . $seqFormat . ')';
+ $sign = '=';
+ }
+
+ $seqParts = array();
+ foreach ($expression as $expressionValue) {
+ $seqParts[] = sprintf(
+ $format,
+ LdapUtils::quoteForSearch($column),
+ $sign,
+ LdapUtils::quoteForSearch($expressionValue, true)
+ );
+ }
+
+ return sprintf($seqFormat, implode(')(', $seqParts));
+ }
+
+ if ($sign === '!=') {
+ $format = '!(%1$s=%3$s)';
+ }
+
+ return sprintf(
+ $format,
+ LdapUtils::quoteForSearch($column),
+ $sign,
+ LdapUtils::quoteForSearch($expression, true)
+ );
+ }
+
+ /**
+ * Inspect if this LDAP Connection is working as expected
+ *
+ * Check if connection, bind and encryption is working as expected and get additional
+ * information about the used
+ *
+ * @return Inspection Inspection result
+ */
+ public function inspect()
+ {
+ $insp = new Inspection('Ldap Connection');
+
+ // Try to connect to the server with the given connection parameters
+ try {
+ $ds = $this->prepareNewConnection($insp);
+ } catch (Exception $e) {
+ if ($this->encryption === 'starttls') {
+ // The Exception does not return any proper error messages in case of certificate errors. Connecting
+ // by STARTTLS will usually fail at this point when the certificate is unknown,
+ // so at least try to give some hints.
+ $insp->write('NOTE: There might be an issue with the chosen encryption. Ensure that the LDAP-Server ' .
+ 'supports STARTTLS and that the LDAP-Client is configured to accept its certificate.');
+ }
+ return $insp->error($e->getMessage());
+ }
+
+ // Try a bind-command with the given user credentials, this must not fail
+ $success = @ldap_bind($ds, $this->bindDn, $this->bindPw);
+ $msg = sprintf(
+ 'LDAP bind (%s / %s) to %s',
+ $this->bindDn,
+ '***' /* $this->bindPw */,
+ $this->normalizeHostname($this->hostname)
+ );
+ if (! $success) {
+ // ldap_error does not return any proper error messages in case of certificate errors. Connecting
+ // by LDAPS will usually fail at this point when the certificate is unknown, so at least try to give
+ // some hints.
+ if ($this->encryption === 'ldaps') {
+ $insp->write('NOTE: There might be an issue with the chosen encryption. Ensure that the LDAP-Server ' .
+ ' supports LDAPS and that the LDAP-Client is configured to accept its certificate.');
+ }
+ return $insp->error(sprintf('%s failed: %s', $msg, ldap_error($ds)));
+ }
+ $insp->write(sprintf($msg . ' successful'));
+
+ // Try to execute a schema discovery this may fail if schema discovery is not supported
+ try {
+ $cap = LdapCapabilities::discoverCapabilities($this);
+ $discovery = new Inspection('Discovery Results');
+ $vendor = $cap->getVendor();
+ if (isset($vendor)) {
+ $discovery->write($vendor);
+ }
+ $version = $cap->getVersion();
+ if (isset($version)) {
+ $discovery->write($version);
+ }
+ $discovery->write('Supports STARTTLS: ' . ($cap->hasStartTls() ? 'True' : 'False'));
+ $discovery->write('Default naming context: ' . $cap->getDefaultNamingContext());
+ $insp->write($discovery);
+ } catch (Exception $e) {
+ $insp->write('Schema discovery not possible: ' . $e->getMessage());
+ }
+ return $insp;
+ }
+
+ protected function normalizeHostname($hostname)
+ {
+ $scheme = $this->encryption === static::LDAPS ? 'ldaps://' : 'ldap://';
+ $normalizeHostname = function ($hostname) use ($scheme) {
+ if (strpos($hostname, $scheme) === false) {
+ $hostname = $scheme . $hostname;
+ }
+
+ if (! preg_match('/:\d+$/', $hostname)) {
+ $hostname .= ':' . $this->port;
+ }
+
+ return $hostname;
+ };
+
+ $ldapUrls = explode(' ', $hostname);
+ if (count($ldapUrls) > 1) {
+ foreach ($ldapUrls as & $uri) {
+ $uri = $normalizeHostname($uri);
+ }
+
+ $hostname = implode(' ', $ldapUrls);
+ } else {
+ $hostname = $normalizeHostname($hostname);
+ }
+
+ return $hostname;
+ }
+}
diff --git a/library/Icinga/Protocol/Ldap/LdapException.php b/library/Icinga/Protocol/Ldap/LdapException.php
new file mode 100644
index 0000000..740ee29
--- /dev/null
+++ b/library/Icinga/Protocol/Ldap/LdapException.php
@@ -0,0 +1,14 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Ldap;
+
+use Icinga\Exception\IcingaException;
+
+/**
+ * Class LdapException
+ * @package Icinga\Protocol\Ldap
+ */
+class LdapException extends IcingaException
+{
+}
diff --git a/library/Icinga/Protocol/Ldap/LdapQuery.php b/library/Icinga/Protocol/Ldap/LdapQuery.php
new file mode 100644
index 0000000..42a236f
--- /dev/null
+++ b/library/Icinga/Protocol/Ldap/LdapQuery.php
@@ -0,0 +1,361 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Ldap;
+
+use Icinga\Data\Filter\Filter;
+use LogicException;
+use Icinga\Data\SimpleQuery;
+
+/**
+ * LDAP query class
+ */
+class LdapQuery extends SimpleQuery
+{
+ /**
+ * The base dn being used for this query
+ *
+ * @var string
+ */
+ protected $base;
+
+ /**
+ * Whether this query is permitted to utilize paged results
+ *
+ * @var bool
+ */
+ protected $usePagedResults;
+
+ /**
+ * The name of the attribute used to unfold the result
+ *
+ * @var string
+ */
+ protected $unfoldAttribute;
+
+ /**
+ * This query's native LDAP filter
+ *
+ * @var string
+ */
+ protected $nativeFilter;
+
+ /**
+ * Only fetch the entry at the base of the search
+ */
+ const SCOPE_BASE = 'base';
+
+ /**
+ * Fetch entries one below the base DN
+ */
+ const SCOPE_ONE = 'one';
+
+ /**
+ * Fetch all entries below the base DN
+ */
+ const SCOPE_SUB = 'sub';
+
+ /**
+ * All available scopes
+ *
+ * @var array
+ */
+ public static $scopes = array(
+ LdapQuery::SCOPE_BASE,
+ LdapQuery::SCOPE_ONE,
+ LdapQuery::SCOPE_SUB
+ );
+
+ /**
+ * LDAP search scope (default: SCOPE_SUB)
+ *
+ * @var string
+ */
+ protected $scope = LdapQuery::SCOPE_SUB;
+
+ /**
+ * Initialize this query
+ */
+ protected function init()
+ {
+ $this->usePagedResults = false;
+ }
+
+ /**
+ * Set the base dn to be used for this query
+ *
+ * @param string $base
+ *
+ * @return $this
+ */
+ public function setBase($base)
+ {
+ $this->base = $base;
+ return $this;
+ }
+
+ /**
+ * Return the base dn being used for this query
+ *
+ * @return string
+ */
+ public function getBase()
+ {
+ return $this->base;
+ }
+
+ /**
+ * Set whether this query is permitted to utilize paged results
+ *
+ * @param bool $state
+ *
+ * @return $this
+ */
+ public function setUsePagedResults($state = true)
+ {
+ $this->usePagedResults = (bool) $state;
+ return $this;
+ }
+
+ /**
+ * Return whether this query is permitted to utilize paged results
+ *
+ * @return bool
+ */
+ public function getUsePagedResults()
+ {
+ return $this->usePagedResults;
+ }
+
+ /**
+ * Set the attribute to be used to unfold the result
+ *
+ * @param string $attributeName
+ *
+ * @return $this
+ */
+ public function setUnfoldAttribute($attributeName)
+ {
+ $this->unfoldAttribute = $attributeName;
+ return $this;
+ }
+
+ /**
+ * Return the attribute to use to unfold the result
+ *
+ * @return string
+ */
+ public function getUnfoldAttribute()
+ {
+ return $this->unfoldAttribute;
+ }
+
+ /**
+ * Set this query's native LDAP filter
+ *
+ * @param string $filter
+ *
+ * @return $this
+ */
+ public function setNativeFilter($filter)
+ {
+ $this->nativeFilter = $filter;
+ return $this;
+ }
+
+ /**
+ * Return this query's native LDAP filter
+ *
+ * @return string
+ */
+ public function getNativeFilter()
+ {
+ return $this->nativeFilter;
+ }
+
+ /**
+ * Choose an objectClass and the columns you are interested in
+ *
+ * {@inheritdoc} This creates an objectClass filter.
+ */
+ public function from($target, array $fields = null)
+ {
+ $this->where('objectClass', $target);
+ return parent::from($target, $fields);
+ }
+
+ public function where($condition, $value = null)
+ {
+ $this->addFilter(Filter::expression($condition, '=', $value));
+ return $this;
+ }
+
+ public function addFilter(Filter $filter)
+ {
+ $this->makeCaseInsensitive($filter);
+ return parent::addFilter($filter);
+ }
+
+ public function setFilter(Filter $filter)
+ {
+ $this->makeCaseInsensitive($filter);
+ return parent::setFilter($filter);
+ }
+
+ protected function makeCaseInsensitive(Filter $filter)
+ {
+ if ($filter->isExpression()) {
+ /** @var \Icinga\Data\Filter\FilterExpression $filter */
+ $filter->setCaseSensitive(false);
+ } else {
+ /** @var \Icinga\Data\Filter\FilterChain $filter */
+ foreach ($filter->filters() as $subFilter) {
+ $this->makeCaseInsensitive($subFilter);
+ }
+ }
+ }
+
+ public function compare($a, $b, $orderIndex = 0)
+ {
+ if (array_key_exists($orderIndex, $this->order)) {
+ $column = $this->order[$orderIndex][0];
+ $direction = $this->order[$orderIndex][1];
+
+ $flippedColumns = $this->flippedColumns ?: array_flip($this->columns);
+ if (array_key_exists($column, $flippedColumns) && is_string($flippedColumns[$column])) {
+ $column = $flippedColumns[$column];
+ }
+
+ if (is_array($a->$column)) {
+ // rfc2891 states: If a sort key is a multi-valued attribute, and an entry happens to
+ // have multiple values for that attribute and no other controls are
+ // present that affect the sorting order, then the server SHOULD use the
+ // least value (according to the ORDERING rule for that attribute).
+ $a = clone $a;
+ $a->$column = array_reduce($a->$column, function ($carry, $item) use ($direction) {
+ $result = $carry === null ? 0 : strcmp($item, $carry);
+ return ($direction === self::SORT_ASC ? $result : $result * -1) < 1 ? $item : $carry;
+ });
+ }
+
+ if (is_array($b->$column)) {
+ $b = clone $b;
+ $b->$column = array_reduce($b->$column, function ($carry, $item) use ($direction) {
+ $result = $carry === null ? 0 : strcmp($item, $carry);
+ return ($direction === self::SORT_ASC ? $result : $result * -1) < 1 ? $item : $carry;
+ });
+ }
+ }
+
+ return parent::compare($a, $b, $orderIndex);
+ }
+
+ /**
+ * Fetch result as tree
+ *
+ * @return Root
+ *
+ * @todo This is untested waste, not being used anywhere and ignores the query's order and base dn.
+ * Evaluate whether it's reasonable to properly implement and test it.
+ */
+ public function fetchTree()
+ {
+ $result = $this->fetchAll();
+ $sorted = array();
+ $quotedDn = preg_quote($this->ds->getDn(), '/');
+ foreach ($result as $key => & $item) {
+ $new_key = LdapUtils::implodeDN(
+ array_reverse(
+ LdapUtils::explodeDN(
+ preg_replace('/,' . $quotedDn . '$/', '', $key)
+ )
+ )
+ );
+ $sorted[$new_key] = $key;
+ }
+ unset($groups);
+ ksort($sorted);
+
+ $tree = Root::forConnection($this->ds);
+ $root_dn = $tree->getDN();
+ foreach ($sorted as $sort_key => & $key) {
+ if ($key === $root_dn) {
+ continue;
+ }
+ $tree->createChildByDN($key, $result[$key]);
+ }
+ return $tree;
+ }
+
+ /**
+ * Fetch the distinguished name of the first result
+ *
+ * @return string|false The distinguished name or false in case it's not possible to fetch a result
+ *
+ * @throws LdapException In case the query returns multiple results
+ * (i.e. it's not possible to fetch a unique DN)
+ */
+ public function fetchDn()
+ {
+ return $this->ds->fetchDn($this);
+ }
+
+ /**
+ * Render and return this query's filter
+ *
+ * @return string
+ */
+ public function renderFilter()
+ {
+ $filter = $this->ds->renderFilter($this->filter);
+ if ($this->nativeFilter) {
+ $filter = '(&(' . $this->nativeFilter . ')' . $filter . ')';
+ }
+
+ return $filter;
+ }
+
+ /**
+ * Return the LDAP filter to be applied on this query
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ return $this->renderFilter();
+ }
+
+ /**
+ * Get LDAP search scope
+ *
+ * @return string
+ */
+ public function getScope()
+ {
+ return $this->scope;
+ }
+
+ /**
+ * Set LDAP search scope
+ *
+ * Valid: sub one base (Default: sub)
+ *
+ * @param string $scope
+ *
+ * @return LdapQuery
+ *
+ * @throws LogicException If scope value is invalid
+ */
+ public function setScope($scope)
+ {
+ if (! in_array($scope, static::$scopes)) {
+ throw new LogicException(
+ 'Can\'t set scope %d, it is is invalid. Use one of %s or LdapQuery\'s constants.',
+ $scope,
+ implode(', ', static::$scopes)
+ );
+ }
+ $this->scope = $scope;
+ return $this;
+ }
+}
diff --git a/library/Icinga/Protocol/Ldap/LdapUtils.php b/library/Icinga/Protocol/Ldap/LdapUtils.php
new file mode 100644
index 0000000..9c9ae10
--- /dev/null
+++ b/library/Icinga/Protocol/Ldap/LdapUtils.php
@@ -0,0 +1,148 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Ldap;
+
+/**
+ * This class provides useful LDAP-related functions
+ *
+ * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com>
+ * @author Icinga-Web Team <info@icinga.com>
+ * @package Icinga\Protocol\Ldap
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License
+ */
+class LdapUtils
+{
+ /**
+ * Extends PHPs ldap_explode_dn() function
+ *
+ * UTF-8 chars like German umlauts would otherwise be escaped and shown
+ * as backslash-prefixed hexcode-sequenzes.
+ *
+ * @param string $dn DN
+ * @param boolean $with_type Returns 'type=value' when true and 'value' when false
+ *
+ * @return array
+ */
+ public static function explodeDN($dn, $with_type = true)
+ {
+ $res = ldap_explode_dn($dn, $with_type ? 0 : 1);
+
+ foreach ($res as $k => $v) {
+ $res[$k] = preg_replace_callback(
+ '/\\\([0-9a-f]{2})/i',
+ function ($m) {
+ return chr(hexdec($m[1]));
+ },
+ $v
+ );
+ }
+ unset($res['count']);
+ return $res;
+ }
+
+ /**
+ * Implode unquoted RDNs to a DN
+ *
+ * TODO: throw away, this is not how it shall be done
+ *
+ * @param array $parts DN-component
+ *
+ * @return string
+ */
+ public static function implodeDN($parts)
+ {
+ $str = '';
+ foreach ($parts as $part) {
+ if ($str !== '') {
+ $str .= ',';
+ }
+ list($key, $val) = preg_split('~=~', $part, 2);
+ $str .= $key . '=' . self::quoteForDN($val);
+ }
+ return $str;
+ }
+
+ /**
+ * Test if supplied value looks like a DN
+ *
+ * @param mixed $value
+ *
+ * @return bool
+ */
+ public static function isDn($value)
+ {
+ if (is_string($value)) {
+ return ldap_dn2ufn($value) !== false;
+ }
+ return false;
+ }
+
+ /**
+ * Quote a string that should be used in a DN
+ *
+ * Special characters will be escaped
+ *
+ * @param string $str DN-component
+ *
+ * @return string
+ */
+ public static function quoteForDN($str)
+ {
+ return self::quoteChars(
+ $str,
+ array(
+ ',',
+ '=',
+ '+',
+ '<',
+ '>',
+ ';',
+ '\\',
+ '"',
+ '#'
+ )
+ );
+ }
+
+ /**
+ * Quote a string that should be used in an LDAP search
+ *
+ * Special characters will be escaped
+ *
+ * @param string String to be escaped
+ * @param bool $allow_wildcard
+ * @return string
+ */
+ public static function quoteForSearch($str, $allow_wildcard = false)
+ {
+ if ($allow_wildcard) {
+ return self::quoteChars($str, array('(', ')', '\\', chr(0)));
+ }
+ return self::quoteChars($str, array('*', '(', ')', '\\', chr(0)));
+ }
+
+ /**
+ * Escape given characters in the given string
+ *
+ * Special characters will be escaped
+ *
+ * @param $str
+ * @param $chars
+ * @internal param String $string to be escaped
+ * @return string
+ */
+ protected static function quoteChars($str, $chars)
+ {
+ $quotedChars = array();
+ foreach ($chars as $k => $v) {
+ // Temporarily prefixing with illegal '('
+ $quotedChars[$k] = '(' . str_pad(dechex(ord($v)), 2, '0');
+ }
+ $str = str_replace($chars, $quotedChars, $str);
+ // Replacing temporary '(' with '\\'. This is a workaround, as
+ // str_replace behaves pretty strange with leading a backslash:
+ $str = preg_replace('~\(~', '\\', $str);
+ return $str;
+ }
+}
diff --git a/library/Icinga/Protocol/Ldap/Node.php b/library/Icinga/Protocol/Ldap/Node.php
new file mode 100644
index 0000000..176f962
--- /dev/null
+++ b/library/Icinga/Protocol/Ldap/Node.php
@@ -0,0 +1,69 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Ldap;
+
+/**
+ * This class represents an LDAP node object
+ *
+ * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com>
+ * @author Icinga-Web Team <info@icinga.com>
+ * @package Icinga\Protocol\Ldap
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License
+ */
+class Node extends Root
+{
+ /**
+ * @var LdapConnection
+ */
+ protected $connection;
+
+ /**
+ * @var
+ */
+ protected $rdn;
+
+ /**
+ * @var Root
+ */
+ protected $parent;
+
+ /**
+ * @param Root $parent
+ */
+ protected function __construct(Root $parent)
+ {
+ $this->connection = $parent->getConnection();
+ $this->parent = $parent;
+ }
+
+ /**
+ * @param $parent
+ * @param $rdn
+ * @param array $props
+ * @return Node
+ */
+ public static function createWithRDN($parent, $rdn, $props = array())
+ {
+ $node = new Node($parent);
+ $node->rdn = $rdn;
+ $node->props = $props;
+ return $node;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getRDN()
+ {
+ return $this->rdn;
+ }
+
+ /**
+ * @return mixed|string
+ */
+ public function getDN()
+ {
+ return $this->getRDN() . ',' . $this->parent->getDN();
+ }
+}
diff --git a/library/Icinga/Protocol/Ldap/Root.php b/library/Icinga/Protocol/Ldap/Root.php
new file mode 100644
index 0000000..5d7a63d
--- /dev/null
+++ b/library/Icinga/Protocol/Ldap/Root.php
@@ -0,0 +1,241 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Protocol\Ldap;
+
+use Icinga\Exception\IcingaException;
+
+/**
+ * This class is a special node object, representing your connections root node
+ *
+ * @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.com>
+ * @author Icinga-Web Team <info@icinga.com>
+ * @package Icinga\Protocol\Ldap
+ * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License
+ * @package Icinga\Protocol\Ldap
+ */
+class Root
+{
+ /**
+ * @var string
+ */
+ protected $rdn;
+
+ /**
+ * @var LdapConnection
+ */
+ protected $connection;
+
+ /**
+ * @var array
+ */
+ protected $children = array();
+
+ /**
+ * @var array
+ */
+ protected $props = array();
+
+ /**
+ * @param LdapConnection $connection
+ */
+ protected function __construct(LdapConnection $connection)
+ {
+ $this->connection = $connection;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasParent()
+ {
+ return false;
+ }
+
+ /**
+ * @param LdapConnection $connection
+ * @return Root
+ */
+ public static function forConnection(LdapConnection $connection)
+ {
+ $root = new Root($connection);
+ return $root;
+ }
+
+ /**
+ * @param $dn
+ * @param array $props
+ * @return Node
+ */
+ public function createChildByDN($dn, $props = array())
+ {
+ $dn = $this->stripMyDN($dn);
+ $parts = array_reverse(LdapUtils::explodeDN($dn));
+ $parent = $this;
+ while ($rdn = array_shift($parts)) {
+ if ($parent->hasChildRDN($rdn)) {
+ $child = $parent->getChildByRDN($rdn);
+ } else {
+ $child = Node::createWithRDN($parent, $rdn, (array)$props);
+ $parent->addChild($child);
+ }
+ $parent = $child;
+ }
+ return $child;
+ }
+
+ /**
+ * @param $rdn
+ * @return bool
+ */
+ public function hasChildRDN($rdn)
+ {
+ return array_key_exists(strtolower($rdn), $this->children);
+ }
+
+ /**
+ * @param $rdn
+ * @return mixed
+ * @throws IcingaException
+ */
+ public function getChildByRDN($rdn)
+ {
+ if (!$this->hasChildRDN($rdn)) {
+ throw new IcingaException(
+ 'The child RDN "%s" is not available',
+ $rdn
+ );
+ }
+ return $this->children[strtolower($rdn)];
+ }
+
+ /**
+ * @return array
+ */
+ public function children()
+ {
+ return $this->children;
+ }
+
+ public function countChildren()
+ {
+ return count($this->children);
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasChildren()
+ {
+ return !empty($this->children);
+ }
+
+ /**
+ * @param Node $child
+ * @return $this
+ */
+ public function addChild(Node $child)
+ {
+ $this->children[strtolower($child->getRDN())] = $child;
+ return $this;
+ }
+
+ /**
+ * @param $dn
+ * @return string
+ */
+ protected function stripMyDN($dn)
+ {
+ $this->assertSubDN($dn);
+ return substr($dn, 0, strlen($dn) - strlen($this->getDN()) - 1);
+ }
+
+ /**
+ * @param $dn
+ * @return $this
+ * @throws IcingaException
+ */
+ protected function assertSubDN($dn)
+ {
+ $mydn = $this->getDN();
+ $end = substr($dn, -1 * strlen($mydn));
+ if (strtolower($end) !== strtolower($mydn)) {
+ throw new IcingaException(
+ '"%s" is not a child of "%s"',
+ $dn,
+ $mydn
+ );
+ }
+ if (strlen($dn) === strlen($mydn)) {
+ throw new IcingaException(
+ '"%s" is not a child of "%s", they are equal',
+ $dn,
+ $mydn
+ );
+ }
+ return $this;
+ }
+
+ /**
+ * @param LdapConnection $connection
+ * @return $this
+ */
+ public function setConnection(LdapConnection $connection)
+ {
+ $this->connection = $connection;
+ return $this;
+ }
+
+ /**
+ * @return LdapConnection
+ */
+ public function getConnection()
+ {
+ return $this->connection;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasBeenChanged()
+ {
+ return false;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getRDN()
+ {
+ return $this->getDN();
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getDN()
+ {
+ return $this->connection->getDn();
+ }
+
+ /**
+ * @param $key
+ * @return null
+ */
+ public function __get($key)
+ {
+ if (!array_key_exists($key, $this->props)) {
+ return null;
+ }
+ return $this->props[$key];
+ }
+
+ /**
+ * @param $key
+ * @return bool
+ */
+ public function __isset($key)
+ {
+ return array_key_exists($key, $this->props);
+ }
+}