diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:46:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:46:43 +0000 |
commit | 3e02d5aff85babc3ffbfcf52313f2108e313aa23 (patch) | |
tree | b01f3923360c20a6a504aff42d45670c58af3ec5 /library/Icinga/Protocol/Ldap/LdapConnection.php | |
parent | Initial commit. (diff) | |
download | icingaweb2-upstream.tar.xz icingaweb2-upstream.zip |
Adding upstream version 2.12.1.upstream/2.12.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'library/Icinga/Protocol/Ldap/LdapConnection.php')
-rw-r--r-- | library/Icinga/Protocol/Ldap/LdapConnection.php | 1584 |
1 files changed, 1584 insertions, 0 deletions
diff --git a/library/Icinga/Protocol/Ldap/LdapConnection.php b/library/Icinga/Protocol/Ldap/LdapConnection.php new file mode 100644 index 0000000..a620e6d --- /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); + + // 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; + } +} |