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; } }