1<?php
2/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
3
4namespace Icinga\Protocol\Ldap;
5
6use ArrayIterator;
7use Exception;
8use Icinga\Data\Filter\FilterNot;
9use LogicException;
10use stdClass;
11use Icinga\Application\Config;
12use Icinga\Application\Logger;
13use Icinga\Data\ConfigObject;
14use Icinga\Data\Filter\Filter;
15use Icinga\Data\Filter\FilterChain;
16use Icinga\Data\Filter\FilterExpression;
17use Icinga\Data\Inspectable;
18use Icinga\Data\Inspection;
19use Icinga\Data\Selectable;
20use Icinga\Data\Sortable;
21use Icinga\Exception\ProgrammingError;
22use Icinga\Web\Url;
23
24/**
25 * Encapsulate LDAP connections and query creation
26 */
27class LdapConnection implements Selectable, Inspectable
28{
29    /**
30     * Indicates that the target object cannot be found
31     *
32     * @var int
33     */
34    const LDAP_NO_SUCH_OBJECT = 32;
35
36    /**
37     * Indicates that in a search operation, the size limit specified by the client or the server has been exceeded
38     *
39     * @var int
40     */
41    const LDAP_SIZELIMIT_EXCEEDED = 4;
42
43    /**
44     * Indicates that an LDAP server limit set by an administrative authority has been exceeded
45     *
46     * @var int
47     */
48    const LDAP_ADMINLIMIT_EXCEEDED = 11;
49
50    /**
51     * Indicates that during a bind operation one of the following occurred: The client passed either an incorrect DN
52     * or password, or the password is incorrect because it has expired, intruder detection has locked the account, or
53     * another similar reason.
54     *
55     * @var int
56     */
57    const LDAP_INVALID_CREDENTIALS = 49;
58
59    /**
60     * The default page size to use for paged queries
61     *
62     * @var int
63     */
64    const PAGE_SIZE = 1000;
65
66    /**
67     * Encrypt connection using STARTTLS (upgrading a plain text connection)
68     *
69     * @var string
70     */
71    const STARTTLS = 'starttls';
72
73    /**
74     * Encrypt connection using LDAP over SSL (using a separate port)
75     *
76     * @var string
77     */
78    const LDAPS = 'ldaps';
79
80    /** @var ConfigObject Connection configuration */
81    protected $config;
82
83    /**
84     * Encryption for the connection if any
85     *
86     * @var string
87     */
88    protected $encryption;
89
90    /**
91     * The LDAP link identifier being used
92     *
93     * @var resource
94     */
95    protected $ds;
96
97    /**
98     * The ip address, hostname or ldap URI being used to connect with the LDAP server
99     *
100     * @var string
101     */
102    protected $hostname;
103
104    /**
105     * The port being used to connect with the LDAP server
106     *
107     * @var int
108     */
109    protected $port;
110
111    /**
112     * The distinguished name being used to bind to the LDAP server
113     *
114     * @var string
115     */
116    protected $bindDn;
117
118    /**
119     * The password being used to bind to the LDAP server
120     *
121     * @var string
122     */
123    protected $bindPw;
124
125    /**
126     * The distinguished name being used as the base path for queries which do not provide one theirselves
127     *
128     * @var string
129     */
130    protected $rootDn;
131
132    /**
133     * Whether the bind on this connection has already been performed
134     *
135     * @var bool
136     */
137    protected $bound;
138
139    /**
140     * The current connection's root node
141     *
142     * @var Root
143     */
144    protected $root;
145
146    /**
147     * LDAP_OPT_NETWORK_TIMEOUT for the LDAP connection
148     *
149     * @var int
150     */
151    protected $timeout;
152
153    /**
154     * The properties and capabilities of the LDAP server
155     *
156     * @var LdapCapabilities
157     */
158    protected $capabilities;
159
160    /**
161     * Whether discovery was successful
162     *
163     * @var bool
164     */
165    protected $discoverySuccess;
166
167    /**
168     * The cause of the discovery's failure
169     *
170     * @var Exception|null
171     */
172    private $discoveryError;
173
174    /**
175     * Whether the current connection is encrypted
176     *
177     * @var bool
178     */
179    protected $encrypted = null;
180
181    /**
182     * Create a new connection object
183     *
184     * @param   ConfigObject    $config
185     */
186    public function __construct(ConfigObject $config)
187    {
188        $this->config = $config;
189        $this->hostname = $config->hostname;
190        $this->bindDn = $config->bind_dn;
191        $this->bindPw = $config->bind_pw;
192        $this->rootDn = $config->root_dn;
193        $this->port = (int) $config->get('port', 389);
194        $this->timeout = (int) $config->get('timeout', 5);
195
196        $this->encryption = $config->encryption;
197        if ($this->encryption !== null) {
198            $this->encryption = strtolower($this->encryption);
199        }
200    }
201
202    /**
203     * Return the ip address, hostname or ldap URI being used to connect with the LDAP server
204     *
205     * @return  string
206     */
207    public function getHostname()
208    {
209        return $this->hostname;
210    }
211
212    /**
213     * Return the port being used to connect with the LDAP server
214     *
215     * @return  int
216     */
217    public function getPort()
218    {
219        return $this->port;
220    }
221
222    /**
223     * Return the distinguished name being used as the base path for queries which do not provide one theirselves
224     *
225     * @return  string
226     */
227    public function getDn()
228    {
229        return $this->rootDn;
230    }
231
232    /**
233     * Return the root node for this connection
234     *
235     * @return  Root
236     */
237    public function root()
238    {
239        if ($this->root === null) {
240            $this->root = Root::forConnection($this);
241        }
242
243        return $this->root;
244    }
245
246    /**
247     * Return the LDAP link identifier being used
248     *
249     * Establishes a connection if necessary.
250     *
251     * @return  resource
252     */
253    public function getConnection()
254    {
255        if ($this->ds === null) {
256            $this->ds = $this->prepareNewConnection();
257        }
258
259        return $this->ds;
260    }
261
262    /**
263     * Return the capabilities of the current connection
264     *
265     * @return  LdapCapabilities
266     */
267    public function getCapabilities()
268    {
269        if ($this->capabilities === null) {
270            try {
271                $this->capabilities = LdapCapabilities::discoverCapabilities($this);
272                $this->discoverySuccess = true;
273                $this->discoveryError = null;
274            } catch (LdapException $e) {
275                Logger::debug($e);
276                Logger::warning('LADP discovery failed, assuming default LDAP capabilities.');
277                $this->capabilities = new LdapCapabilities(); // create empty default capabilities
278                $this->discoverySuccess = false;
279                $this->discoveryError = $e;
280            }
281        }
282
283        return $this->capabilities;
284    }
285
286    /**
287     * Return whether discovery was successful
288     *
289     * @return  bool    true if the capabilities were successfully determined, false if the capabilities were guessed
290     */
291    public function discoverySuccessful()
292    {
293        if ($this->discoverySuccess === null) {
294            $this->getCapabilities(); // Initializes self::$discoverySuccess
295        }
296
297        return $this->discoverySuccess;
298    }
299
300    /**
301     * Get discovery error if any
302     *
303     * @return Exception|null
304     */
305    public function getDiscoveryError()
306    {
307        return $this->discoveryError;
308    }
309
310    /**
311     * Return whether the current connection is encrypted
312     *
313     * @return  bool
314     */
315    public function isEncrypted()
316    {
317        if ($this->encrypted === null) {
318            return false;
319        }
320
321        return $this->encrypted;
322    }
323
324    /**
325     * Establish a connection
326     *
327     * @throws  LdapException   In case the connection could not be established
328     *
329     * @deprecated              The connection is established lazily now
330     */
331    public function connect()
332    {
333        $this->getConnection();
334    }
335
336    /**
337     * Perform a LDAP bind on the current connection
338     *
339     * @throws  LdapException   In case the LDAP bind was unsuccessful or insecure
340     */
341    public function bind()
342    {
343        if ($this->bound) {
344            return $this;
345        }
346
347        $ds = $this->getConnection();
348
349        $success = @ldap_bind($ds, $this->bindDn, $this->bindPw);
350        if (! $success) {
351            throw new LdapException(
352                'LDAP bind (%s / %s) to %s failed: %s',
353                $this->bindDn,
354                '***' /* $this->bindPw */,
355                $this->normalizeHostname($this->hostname),
356                ldap_error($ds)
357            );
358        }
359
360        $this->bound = true;
361        return $this;
362    }
363
364    /**
365     * Provide a query on this connection
366     *
367     * @return  LdapQuery
368     */
369    public function select()
370    {
371        return new LdapQuery($this);
372    }
373
374    /**
375     * Fetch and return all rows of the given query's result set using an iterator
376     *
377     * @param   LdapQuery   $query  The query returning the result set
378     *
379     * @return  ArrayIterator
380     */
381    public function query(LdapQuery $query)
382    {
383        return new ArrayIterator($this->fetchAll($query));
384    }
385
386    /**
387     * Count all rows of the given query's result set
388     *
389     * @param   LdapQuery   $query  The query returning the result set
390     *
391     * @return  int
392     */
393    public function count(LdapQuery $query)
394    {
395        $this->bind();
396
397        if (($unfoldAttribute = $query->getUnfoldAttribute()) !== null) {
398            $desiredColumns = $query->getColumns();
399            if (isset($desiredColumns[$unfoldAttribute])) {
400                $fields = array($unfoldAttribute => $desiredColumns[$unfoldAttribute]);
401            } elseif (in_array($unfoldAttribute, $desiredColumns, true)) {
402                $fields = array($unfoldAttribute);
403            } else {
404                throw new ProgrammingError(
405                    'The attribute used to unfold a query\'s result must be selected'
406                );
407            }
408
409            $res = $this->runQuery($query, $fields);
410            return count($res);
411        }
412
413        $ds = $this->getConnection();
414        $results = $this->ldapSearch($query, array('dn'));
415
416        if ($results === false) {
417            if (ldap_errno($ds) !== self::LDAP_NO_SUCH_OBJECT) {
418                throw new LdapException(
419                    'LDAP count query "%s" (base %s) failed: %s',
420                    (string) $query,
421                    $query->getBase() ?: $this->getDn(),
422                    ldap_error($ds)
423                );
424            }
425        }
426
427        return ldap_count_entries($ds, $results);
428    }
429
430    /**
431     * Retrieve an array containing all rows of the result set
432     *
433     * @param   LdapQuery   $query      The query returning the result set
434     * @param   array       $fields     Request these attributes instead of the ones registered in the given query
435     *
436     * @return  array
437     */
438    public function fetchAll(LdapQuery $query, array $fields = null)
439    {
440        $this->bind();
441
442        if ($query->getUsePagedResults() && $this->getCapabilities()->hasPagedResult()) {
443            return $this->runPagedQuery($query, $fields);
444        } else {
445            return $this->runQuery($query, $fields);
446        }
447    }
448
449    /**
450     * Fetch the first row of the result set
451     *
452     * @param   LdapQuery   $query      The query returning the result set
453     * @param   array       $fields     Request these attributes instead of the ones registered in the given query
454     *
455     * @return  mixed
456     */
457    public function fetchRow(LdapQuery $query, array $fields = null)
458    {
459        $clonedQuery = clone $query;
460        $clonedQuery->limit(1);
461        $clonedQuery->setUsePagedResults(false);
462        $results = $this->fetchAll($clonedQuery, $fields);
463        return array_shift($results) ?: false;
464    }
465
466    /**
467     * Fetch the first column of all rows of the result set as an array
468     *
469     * @param   LdapQuery   $query      The query returning the result set
470     * @param   array       $fields     Request these attributes instead of the ones registered in the given query
471     *
472     * @return  array
473     *
474     * @throws  ProgrammingError        In case no attribute is being requested
475     */
476    public function fetchColumn(LdapQuery $query, array $fields = null)
477    {
478        if ($fields === null) {
479            $fields = $query->getColumns();
480        }
481
482        if (empty($fields)) {
483            throw new ProgrammingError('You must request at least one attribute when fetching a single column');
484        }
485
486        $alias = key($fields);
487        $results = $this->fetchAll($query, array($alias => current($fields)));
488        $column = is_int($alias) ? current($fields) : $alias;
489        $values = array();
490        foreach ($results as $row) {
491            if (isset($row->$column)) {
492                $values[] = $row->$column;
493            }
494        }
495
496        return $values;
497    }
498
499    /**
500     * Fetch the first column of the first row of the result set
501     *
502     * @param   LdapQuery   $query      The query returning the result set
503     * @param   array       $fields     Request these attributes instead of the ones registered in the given query
504     *
505     * @return  string
506     */
507    public function fetchOne(LdapQuery $query, array $fields = null)
508    {
509        $row = $this->fetchRow($query, $fields);
510        if ($row === false) {
511            return false;
512        }
513
514        $values = get_object_vars($row);
515        if (empty($values)) {
516            return false;
517        }
518
519        if ($fields === null) {
520            // Fetch the desired columns from the query if not explicitly overriden in the method's parameter
521            $fields = $query->getColumns();
522        }
523
524        if (empty($fields)) {
525            // The desired columns may be empty independently whether provided by the query or the method's parameter
526            return array_shift($values);
527        }
528
529        $alias = key($fields);
530        return $values[is_string($alias) ? $alias : $fields[$alias]];
531    }
532
533    /**
534     * Fetch all rows of the result set as an array of key-value pairs
535     *
536     * The first column is the key, the second column is the value.
537     *
538     * @param   LdapQuery   $query      The query returning the result set
539     * @param   array       $fields     Request these attributes instead of the ones registered in the given query
540     *
541     * @return  array
542     *
543     * @throws  ProgrammingError        In case there are less than two attributes being requested
544     */
545    public function fetchPairs(LdapQuery $query, array $fields = null)
546    {
547        if ($fields === null) {
548            $fields = $query->getColumns();
549        }
550
551        if (count($fields) < 2) {
552            throw new ProgrammingError('You are required to request at least two attributes');
553        }
554
555        $columns = $desiredColumnNames = array();
556        foreach ($fields as $alias => $column) {
557            if (is_int($alias)) {
558                $columns[] = $column;
559                $desiredColumnNames[] = $column;
560            } else {
561                $columns[$alias] = $column;
562                $desiredColumnNames[] = $alias;
563            }
564
565            if (count($desiredColumnNames) === 2) {
566                break;
567            }
568        }
569
570        $results = $this->fetchAll($query, $columns);
571        $pairs = array();
572        foreach ($results as $row) {
573            $colOne = $desiredColumnNames[0];
574            $colTwo = $desiredColumnNames[1];
575            $pairs[$row->$colOne] = $row->$colTwo;
576        }
577
578        return $pairs;
579    }
580
581    /**
582     * Fetch an LDAP entry by its DN
583     *
584     * @param  string        $dn
585     * @param  array|null    $fields
586     *
587     * @return StdClass|bool
588     */
589    public function fetchByDn($dn, array $fields = null)
590    {
591        return $this->select()
592            ->from('*', $fields)
593            ->setBase($dn)
594            ->setScope('base')
595            ->fetchRow();
596    }
597
598    /**
599     * Test the given LDAP credentials by establishing a connection and attempting a LDAP bind
600     *
601     * @param   string  $bindDn
602     * @param   string  $bindPw
603     *
604     * @return  bool                Whether the given credentials are valid
605     *
606     * @throws  LdapException       In case an error occured while establishing the connection or attempting the bind
607     */
608    public function testCredentials($bindDn, $bindPw)
609    {
610        $ds = $this->getConnection();
611        $success = @ldap_bind($ds, $bindDn, $bindPw);
612        if (! $success) {
613            if (ldap_errno($ds) === self::LDAP_INVALID_CREDENTIALS) {
614                Logger::debug(
615                    'Testing LDAP credentials (%s / %s) failed: %s',
616                    $bindDn,
617                    '***',
618                    ldap_error($ds)
619                );
620                return false;
621            }
622
623            throw new LdapException(ldap_error($ds));
624        }
625
626        return true;
627    }
628
629    /**
630     * Return whether an entry identified by the given distinguished name exists
631     *
632     * @param   string  $dn
633     *
634     * @return  bool
635     */
636    public function hasDn($dn)
637    {
638        $ds = $this->getConnection();
639        $this->bind();
640
641        $result = ldap_read($ds, $dn, '(objectClass=*)', array('objectClass'));
642        return ldap_count_entries($ds, $result) > 0;
643    }
644
645    /**
646     * Delete a root entry and all of its children identified by the given distinguished name
647     *
648     * @param   string  $dn
649     *
650     * @return  bool
651     *
652     * @throws  LdapException   In case an error occured while deleting an entry
653     */
654    public function deleteRecursively($dn)
655    {
656        $ds = $this->getConnection();
657        $this->bind();
658
659        $result = @ldap_list($ds, $dn, '(objectClass=*)', array('objectClass'));
660        if ($result === false) {
661            if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) {
662                return false;
663            }
664
665            throw new LdapException('LDAP list for "%s" failed: %s', $dn, ldap_error($ds));
666        }
667
668        $children = ldap_get_entries($ds, $result);
669        for ($i = 0; $i < $children['count']; $i++) {
670            $result = $this->deleteRecursively($children[$i]['dn']);
671            if (! $result) {
672                // TODO: return result code, if delete fails
673                throw new LdapException('Recursively deleting "%s" failed', $dn);
674            }
675        }
676
677        return $this->deleteDn($dn);
678    }
679
680    /**
681     * Delete a single entry identified by the given distinguished name
682     *
683     * @param   string  $dn
684     *
685     * @return  bool
686     *
687     * @throws  LdapException   In case an error occured while deleting the entry
688     */
689    public function deleteDn($dn)
690    {
691        $ds = $this->getConnection();
692        $this->bind();
693
694        $result = @ldap_delete($ds, $dn);
695        if ($result === false) {
696            if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) {
697                return false; // TODO: Isn't it a success if something i'd like to remove is not existing at all???
698            }
699
700            throw new LdapException('LDAP delete for "%s" failed: %s', $dn, ldap_error($ds));
701        }
702
703        return true;
704    }
705
706    /**
707     * Fetch the distinguished name of the result of the given query
708     *
709     * @param   LdapQuery   $query  The query returning the result set
710     *
711     * @return  string              The distinguished name, or false when the given query yields no results
712     *
713     * @throws  LdapException       In case the query yields multiple results
714     */
715    public function fetchDn(LdapQuery $query)
716    {
717        $rows = $this->fetchAll($query, array());
718        if (count($rows) > 1) {
719            throw new LdapException('Cannot fetch single DN for %s', $query);
720        }
721
722        return key($rows);
723    }
724
725    /**
726     * Run the given LDAP query and return the resulting entries
727     *
728     * @param   LdapQuery   $query      The query to fetch results with
729     * @param   array       $fields     Request these attributes instead of the ones registered in the given query
730     *
731     * @return  array
732     *
733     * @throws  LdapException           In case an error occured while fetching the results
734     */
735    protected function runQuery(LdapQuery $query, array $fields = null)
736    {
737        $limit = $query->getLimit();
738        $offset = $query->hasOffset() ? $query->getOffset() : 0;
739
740        if ($fields === null) {
741            $fields = $query->getColumns();
742        }
743
744        $ds = $this->getConnection();
745
746        $serverSorting = ! $this->config->disable_server_side_sort
747            && $this->getCapabilities()->hasOid(LdapCapabilities::LDAP_SERVER_SORT_OID);
748
749        if ($query->hasOrder()) {
750            if ($serverSorting) {
751                ldap_set_option($ds, LDAP_OPT_SERVER_CONTROLS, array(
752                    array(
753                        'oid'   => LdapCapabilities::LDAP_SERVER_SORT_OID,
754                        'value' => $this->encodeSortRules($query->getOrder())
755                    )
756                ));
757            } elseif (! empty($fields)) {
758                foreach ($query->getOrder() as $rule) {
759                    if (! in_array($rule[0], $fields, true)) {
760                        $fields[] = $rule[0];
761                    }
762                }
763            }
764        }
765
766        $unfoldAttribute = $query->getUnfoldAttribute();
767        if ($unfoldAttribute) {
768            foreach ($query->getFilter()->listFilteredColumns() as $filterColumn) {
769                $fieldKey = array_search($filterColumn, $fields, true);
770                if ($fieldKey === false || is_string($fieldKey)) {
771                    $fields[] = $filterColumn;
772                }
773            }
774        }
775
776        $results = $this->ldapSearch(
777            $query,
778            array_values($fields),
779            0,
780            ($serverSorting || ! $query->hasOrder()) && $limit ? $offset + $limit : 0
781        );
782        if ($results === false) {
783            if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) {
784                return array();
785            }
786
787            throw new LdapException(
788                'LDAP query "%s" (base %s) failed. Error: %s',
789                $query,
790                $query->getBase() ?: $this->rootDn,
791                ldap_error($ds)
792            );
793        } elseif (ldap_count_entries($ds, $results) === 0) {
794            return array();
795        }
796
797        $count = 0;
798        $entries = array();
799        $entry = ldap_first_entry($ds, $results);
800        do {
801            if ($unfoldAttribute) {
802                $rows = $this->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields, $unfoldAttribute);
803                if (is_array($rows)) {
804                    // TODO: Register the DN the same way as a section name in the ArrayDatasource!
805                    foreach ($rows as $row) {
806                        if ($query->getFilter()->matches($row)) {
807                            $count += 1;
808                            if (! $serverSorting || $offset === 0 || $offset < $count) {
809                                $entries[] = $row;
810                            }
811
812                            if ($serverSorting && $limit > 0 && $limit === count($entries)) {
813                                break;
814                            }
815                        }
816                    }
817                } else {
818                    $count += 1;
819                    if (! $serverSorting || $offset === 0 || $offset < $count) {
820                        $entries[ldap_get_dn($ds, $entry)] = $rows;
821                    }
822                }
823            } else {
824                $count += 1;
825                if (! $serverSorting || $offset === 0 || $offset < $count) {
826                    $entries[ldap_get_dn($ds, $entry)] = $this->cleanupAttributes(
827                        ldap_get_attributes($ds, $entry),
828                        $fields
829                    );
830                }
831            }
832        } while ((! $serverSorting || $limit === 0 || $limit !== count($entries))
833            && ($entry = ldap_next_entry($ds, $entry))
834        );
835
836        if (! $serverSorting) {
837            if ($query->hasOrder()) {
838                uasort($entries, array($query, 'compare'));
839            }
840
841            if ($limit && $count > $limit) {
842                $entries = array_splice($entries, $query->hasOffset() ? $query->getOffset() : 0, $limit);
843            }
844        }
845
846        ldap_free_result($results);
847        return $entries;
848    }
849
850    /**
851     * Run the given LDAP query and return the resulting entries
852     *
853     * This utilizes paged search requests as defined in RFC 2696.
854     *
855     * @param   LdapQuery   $query      The query to fetch results with
856     * @param   array       $fields     Request these attributes instead of the ones registered in the given query
857     * @param   int         $pageSize   The maximum page size, defaults to self::PAGE_SIZE
858     *
859     * @return  array
860     *
861     * @throws  LdapException           In case an error occured while fetching the results
862     */
863    protected function runPagedQuery(LdapQuery $query, array $fields = null, $pageSize = null)
864    {
865        if ($pageSize === null) {
866            $pageSize = static::PAGE_SIZE;
867        }
868
869        $limit = $query->getLimit();
870        $offset = $query->hasOffset() ? $query->getOffset() : 0;
871
872        if ($fields === null) {
873            $fields = $query->getColumns();
874        }
875
876        $ds = $this->getConnection();
877
878        $serverSorting = false;//$this->getCapabilities()->hasOid(LdapCapabilities::LDAP_SERVER_SORT_OID);
879        if (! $serverSorting && $query->hasOrder() && ! empty($fields)) {
880            foreach ($query->getOrder() as $rule) {
881                if (! in_array($rule[0], $fields, true)) {
882                    $fields[] = $rule[0];
883                }
884            }
885        }
886
887        $unfoldAttribute = $query->getUnfoldAttribute();
888        if ($unfoldAttribute) {
889            foreach ($query->getFilter()->listFilteredColumns() as $filterColumn) {
890                $fieldKey = array_search($filterColumn, $fields, true);
891                if ($fieldKey === false || is_string($fieldKey)) {
892                    $fields[] = $filterColumn;
893                }
894            }
895        }
896
897        $legacyControlHandling = version_compare(PHP_VERSION, '7.3.0') < 0;
898
899        $count = 0;
900        $cookie = '';
901        $entries = array();
902        do {
903            if ($legacyControlHandling) {
904                // Do not request the pagination control as a critical extension, as we want the
905                // server to return results even if the paged search request cannot be satisfied
906                ldap_control_paged_result($ds, $pageSize, false, $cookie);
907            }
908
909            if ($serverSorting && $query->hasOrder()) {
910                ldap_set_option($ds, LDAP_OPT_SERVER_CONTROLS, array(
911                    array(
912                        'oid'   => LdapCapabilities::LDAP_SERVER_SORT_OID,
913                        'value' => $this->encodeSortRules($query->getOrder())
914                    )
915                ));
916            }
917
918            $results = $this->ldapSearch(
919                $query,
920                array_values($fields),
921                0,
922                ($serverSorting || ! $query->hasOrder()) && $limit ? $offset + $limit : 0,
923                0,
924                LDAP_DEREF_NEVER,
925                $legacyControlHandling ? null : $pageSize
926            );
927            if ($results === false) {
928                if (ldap_errno($ds) === self::LDAP_NO_SUCH_OBJECT) {
929                    break;
930                }
931
932                throw new LdapException(
933                    'LDAP query "%s" (base %s) failed. Error: %s',
934                    (string) $query,
935                    $query->getBase() ?: $this->getDn(),
936                    ldap_error($ds)
937                );
938            } elseif (ldap_count_entries($ds, $results) === 0) {
939                if (in_array(
940                    ldap_errno($ds),
941                    array(static::LDAP_SIZELIMIT_EXCEEDED, static::LDAP_ADMINLIMIT_EXCEEDED),
942                    true
943                )) {
944                    Logger::warning(
945                        'Unable to request more than %u results. Does the server allow paged search requests? (%s)',
946                        $count,
947                        ldap_error($ds)
948                    );
949                }
950
951                break;
952            }
953
954            $entry = ldap_first_entry($ds, $results);
955            do {
956                if ($unfoldAttribute) {
957                    $rows = $this->cleanupAttributes(ldap_get_attributes($ds, $entry), $fields, $unfoldAttribute);
958                    if (is_array($rows)) {
959                        // TODO: Register the DN the same way as a section name in the ArrayDatasource!
960                        foreach ($rows as $row) {
961                            if ($query->getFilter()->matches($row)) {
962                                $count += 1;
963                                if (! $serverSorting || $offset === 0 || $offset < $count) {
964                                    $entries[] = $row;
965                                }
966
967                                if ($serverSorting && $limit > 0 && $limit === count($entries)) {
968                                    break;
969                                }
970                            }
971                        }
972                    } else {
973                        $count += 1;
974                        if (! $serverSorting || $offset === 0 || $offset < $count) {
975                            $entries[ldap_get_dn($ds, $entry)] = $rows;
976                        }
977                    }
978                } else {
979                    $count += 1;
980                    if (! $serverSorting || $offset === 0 || $offset < $count) {
981                        $entries[ldap_get_dn($ds, $entry)] = $this->cleanupAttributes(
982                            ldap_get_attributes($ds, $entry),
983                            $fields
984                        );
985                    }
986                }
987            } while ((! $serverSorting || $limit === 0 || $limit !== count($entries))
988                && ($entry = ldap_next_entry($ds, $entry))
989            );
990
991            if ($legacyControlHandling && false === @ldap_control_paged_result_response($ds, $results, $cookie)) {
992                // If the page size is greater than or equal to the sizeLimit value, the server should ignore the
993                // control as the request can be satisfied in a single page: https://www.ietf.org/rfc/rfc2696.txt
994                // This applies no matter whether paged search requests are permitted or not. You're done once you
995                // got everything you were out for.
996                if ($serverSorting && count($entries) !== $limit) {
997                    // The server does not support pagination, but still returned a response by ignoring the
998                    // pagedResultsControl. We output a warning to indicate that the pagination control was ignored.
999                    Logger::warning(
1000                        'Unable to request paged LDAP results. Does the server allow paged search requests?'
1001                    );
1002                }
1003            }
1004
1005            ldap_free_result($results);
1006        } while ($cookie && (! $serverSorting || $limit === 0 || count($entries) < $limit));
1007
1008        if ($legacyControlHandling && $cookie) {
1009            // A sequence of paged search requests is abandoned by the client sending a search request containing a
1010            // pagedResultsControl with the size set to zero (0) and the cookie set to the last cookie returned by
1011            // the server: https://www.ietf.org/rfc/rfc2696.txt
1012            ldap_control_paged_result($ds, 0, false, $cookie);
1013            // Returns no entries, due to the page size
1014            ldap_search($ds, $query->getBase() ?: $this->getDn(), (string) $query);
1015        }
1016
1017        if (! $serverSorting) {
1018            if ($query->hasOrder()) {
1019                uasort($entries, array($query, 'compare'));
1020            }
1021
1022            if ($limit && $count > $limit) {
1023                $entries = array_splice($entries, $query->hasOffset() ? $query->getOffset() : 0, $limit);
1024            }
1025        }
1026
1027        return $entries;
1028    }
1029
1030    /**
1031     * Clean up the given attributes and return them as simple object
1032     *
1033     * Applies column aliases, aggregates/unfolds multi-value attributes
1034     * as array and sets null for each missing attribute.
1035     *
1036     * @param   array   $attributes
1037     * @param   array   $requestedFields
1038     * @param   string  $unfoldAttribute
1039     *
1040     * @return  object|array    An array in case the object has been unfolded
1041     */
1042    public function cleanupAttributes($attributes, array $requestedFields, $unfoldAttribute = null)
1043    {
1044        // In case the result contains attributes with a differing case than the requested fields, it is
1045        // necessary to create another array to map attributes case insensitively to their requested counterparts.
1046        // This does also apply the virtual alias handling. (Since an LDAP server does not handle such)
1047        $loweredFieldMap = array();
1048        foreach ($requestedFields as $alias => $name) {
1049            $loweredName = strtolower($name);
1050            if (isset($loweredFieldMap[$loweredName])) {
1051                if (! is_array($loweredFieldMap[$loweredName])) {
1052                    $loweredFieldMap[$loweredName] = array($loweredFieldMap[$loweredName]);
1053                }
1054
1055                $loweredFieldMap[$loweredName][] = is_string($alias) ? $alias : $name;
1056            } else {
1057                $loweredFieldMap[$loweredName] = is_string($alias) ? $alias : $name;
1058            }
1059        }
1060
1061        $cleanedAttributes = array();
1062        for ($i = 0; $i < $attributes['count']; $i++) {
1063            $attribute_name = $attributes[$i];
1064            if ($attributes[$attribute_name]['count'] === 1) {
1065                $attribute_value = $attributes[$attribute_name][0];
1066            } else {
1067                $attribute_value = array();
1068                for ($j = 0; $j < $attributes[$attribute_name]['count']; $j++) {
1069                    $attribute_value[] = $attributes[$attribute_name][$j];
1070                }
1071            }
1072
1073            $requestedAttributeName = isset($loweredFieldMap[strtolower($attribute_name)])
1074                ? $loweredFieldMap[strtolower($attribute_name)]
1075                : $attribute_name;
1076            if (is_array($requestedAttributeName)) {
1077                foreach ($requestedAttributeName as $requestedName) {
1078                    $cleanedAttributes[$requestedName] = $attribute_value;
1079                }
1080            } else {
1081                $cleanedAttributes[$requestedAttributeName] = $attribute_value;
1082            }
1083        }
1084
1085        // The result may not contain all requested fields, so populate the cleaned
1086        // result with the missing fields and their value being set to null
1087        foreach ($requestedFields as $alias => $name) {
1088            if (! is_string($alias)) {
1089                $alias = $name;
1090            }
1091
1092            if (! array_key_exists($alias, $cleanedAttributes)) {
1093                $cleanedAttributes[$alias] = null;
1094                Logger::debug('LDAP query result does not provide the requested field "%s"', $name);
1095            }
1096        }
1097
1098        if ($unfoldAttribute !== null
1099            && isset($cleanedAttributes[$unfoldAttribute])
1100            && is_array($cleanedAttributes[$unfoldAttribute])
1101        ) {
1102            $siblings = array();
1103            foreach ($loweredFieldMap as $loweredName => $requestedNames) {
1104                if (is_array($requestedNames) && in_array($unfoldAttribute, $requestedNames, true)) {
1105                    $siblings = array_diff($requestedNames, array($unfoldAttribute));
1106                    break;
1107                }
1108            }
1109
1110            $values = $cleanedAttributes[$unfoldAttribute];
1111            unset($cleanedAttributes[$unfoldAttribute]);
1112            $baseRow = (object) $cleanedAttributes;
1113            $rows = array();
1114            foreach ($values as $value) {
1115                $row = clone $baseRow;
1116                $row->{$unfoldAttribute} = $value;
1117                foreach ($siblings as $sibling) {
1118                    $row->{$sibling} = $value;
1119                }
1120
1121                $rows[] = $row;
1122            }
1123
1124            return $rows;
1125        }
1126
1127        return (object) $cleanedAttributes;
1128    }
1129
1130    /**
1131     * Encode the given array of sort rules as ASN.1 octet stream according to RFC 2891
1132     *
1133     * @param   array   $sortRules
1134     *
1135     * @return  string  Binary representation of the octet stream
1136     */
1137    protected function encodeSortRules(array $sortRules)
1138    {
1139        $sequenceOf = '';
1140
1141        foreach ($sortRules as $rule) {
1142            if ($rule[1] === Sortable::SORT_DESC) {
1143                $reversed = '8101ff';
1144            } else {
1145                $reversed = '';
1146            }
1147
1148            $attributeType = unpack('H*', $rule[0]);
1149            $attributeType = $attributeType[1];
1150            $attributeOctets = strlen($attributeType) / 2;
1151            if ($attributeOctets >= 127) {
1152                // Use the indefinite form of the length octets (the long form would be another option)
1153                $attributeType = '0440' . $attributeType . '0000';
1154            } else {
1155                $attributeType = '04' . str_pad(dechex($attributeOctets), 2, '0', STR_PAD_LEFT) . $attributeType;
1156            }
1157
1158            $sequence = $attributeType . $reversed;
1159            $sequenceOctects = strlen($sequence) / 2;
1160            if ($sequenceOctects >= 127) {
1161                $sequence = '3040' . $sequence . '0000';
1162            } else {
1163                $sequence = '30' . str_pad(dechex($sequenceOctects), 2, '0', STR_PAD_LEFT) . $sequence;
1164            }
1165
1166            $sequenceOf .= $sequence;
1167        }
1168
1169        $sequenceOfOctets = strlen($sequenceOf) / 2;
1170        if ($sequenceOfOctets >= 127) {
1171            $sequenceOf = '3040' . $sequenceOf . '0000';
1172        } else {
1173            $sequenceOf = '30' . str_pad(dechex($sequenceOfOctets), 2, '0', STR_PAD_LEFT) . $sequenceOf;
1174        }
1175
1176        return hex2bin($sequenceOf);
1177    }
1178
1179    /**
1180     * Prepare and establish a connection with the LDAP server
1181     *
1182     * @param   Inspection  $info   Optional inspection to fill with diagnostic info
1183     *
1184     * @return  resource            A LDAP link identifier
1185     *
1186     * @throws  LdapException       In case the connection is not possible
1187     */
1188    protected function prepareNewConnection(Inspection $info = null)
1189    {
1190        if (! isset($info)) {
1191            $info = new Inspection('');
1192        }
1193
1194        $hostname = $this->normalizeHostname($this->hostname);
1195
1196        $ds = ldap_connect($hostname, $this->port);
1197
1198        // Set a proper timeout for each connection
1199        ldap_set_option($ds, LDAP_OPT_NETWORK_TIMEOUT, $this->timeout);
1200
1201        // Usage of ldap_rename, setting LDAP_OPT_REFERRALS to 0 or using STARTTLS requires LDAPv3.
1202        // If this does not work we're probably not in a PHP 5.3+ environment as it is VERY
1203        // unlikely that the server complains about it by itself prior to a bind request
1204        ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3);
1205
1206        // Not setting this results in "Operations error" on AD when using the whole domain as search base
1207        ldap_set_option($ds, LDAP_OPT_REFERRALS, 0);
1208
1209        if ($this->encryption === static::LDAPS) {
1210            $info->write('Connect using LDAPS');
1211        } elseif ($this->encryption === static::STARTTLS) {
1212            $this->encrypted = true;
1213            $info->write('Connect using STARTTLS');
1214            if (! ldap_start_tls($ds)) {
1215                throw new LdapException('LDAP STARTTLS failed: %s', ldap_error($ds));
1216            }
1217        } elseif ($this->encryption !== static::LDAPS) {
1218            $this->encrypted = false;
1219            $info->write('Connect without encryption');
1220        }
1221
1222        return $ds;
1223    }
1224
1225    /**
1226     * Perform a LDAP search and return the result
1227     *
1228     * @param   LdapQuery   $query
1229     * @param   array       $attributes     An array of the required attributes
1230     * @param   int         $attrsonly      Should be set to 1 if only attribute types are wanted
1231     * @param   int         $sizelimit      Enables you to limit the count of entries fetched
1232     * @param   int         $timelimit      Sets the number of seconds how long is spend on the search
1233     * @param   int         $deref
1234     * @param   int         $pageSize       The page size to request (Only supported with PHP v7.3+)
1235     *
1236     * @return  resource|bool               A search result identifier or false on error
1237     *
1238     * @throws  LogicException              If the LDAP query search scope is unsupported
1239     */
1240    public function ldapSearch(
1241        LdapQuery $query,
1242        array $attributes = null,
1243        $attrsonly = 0,
1244        $sizelimit = 0,
1245        $timelimit = 0,
1246        $deref = LDAP_DEREF_NEVER,
1247        $pageSize = null
1248    ) {
1249        $queryString = (string) $query;
1250        $baseDn = $query->getBase() ?: $this->getDn();
1251        $scope = $query->getScope();
1252
1253        if (Logger::getInstance()->getLevel() === Logger::DEBUG) {
1254            // We're checking the level by ourselves to avoid rendering the ldapsearch commandline for nothing
1255            $starttlsParam = $this->encryption === static::STARTTLS ? ' -ZZ' : '';
1256
1257            $bindParams = '';
1258            if ($this->bound) {
1259                $bindParams = ' -D "' . $this->bindDn . '"' . ($this->bindPw ? ' -W' : '');
1260            }
1261
1262            if ($deref === LDAP_DEREF_NEVER) {
1263                $derefName = 'never';
1264            } elseif ($deref === LDAP_DEREF_ALWAYS) {
1265                $derefName = 'always';
1266            } elseif ($deref === LDAP_DEREF_SEARCHING) {
1267                $derefName = 'search';
1268            } else { // $deref === LDAP_DEREF_FINDING
1269                $derefName = 'find';
1270            }
1271
1272            Logger::debug("Issuing LDAP search. Use '%s' to reproduce.", sprintf(
1273                'ldapsearch -P 3%s -H "%s"%s -b "%s" -s "%s" -z %u -l %u -a "%s"%s%s%s',
1274                $starttlsParam,
1275                $this->normalizeHostname($this->hostname),
1276                $bindParams,
1277                $baseDn,
1278                $scope,
1279                $sizelimit,
1280                $timelimit,
1281                $derefName,
1282                $attrsonly ? ' -A' : '',
1283                $queryString ? ' "' . $queryString . '"' : '',
1284                $attributes ? ' "' . join('" "', $attributes) . '"' : ''
1285            ));
1286        }
1287
1288        switch ($scope) {
1289            case LdapQuery::SCOPE_SUB:
1290                $function = 'ldap_search';
1291                break;
1292            case LdapQuery::SCOPE_ONE:
1293                $function = 'ldap_list';
1294                break;
1295            case LdapQuery::SCOPE_BASE:
1296                $function = 'ldap_read';
1297                break;
1298            default:
1299                throw new LogicException('LDAP scope %s not supported by ldapSearch', $scope);
1300        }
1301
1302        if ($pageSize !== null) {
1303            $serverctrls[] = [
1304                'oid' => LDAP_CONTROL_PAGEDRESULTS,
1305                // Do not request the pagination control as a critical extension, as we want the
1306                // server to return results even if the paged search request cannot be satisfied
1307                'iscritical' => false,
1308                'value' => [
1309                    'size' => $pageSize,
1310                    'cookie' => ''
1311                ]
1312            ];
1313
1314            return @$function(
1315                $this->getConnection(),
1316                $baseDn,
1317                $queryString,
1318                $attributes,
1319                $attrsonly,
1320                $sizelimit,
1321                $timelimit,
1322                $deref,
1323                $serverctrls
1324            );
1325        } else {
1326            return @$function(
1327                $this->getConnection(),
1328                $baseDn,
1329                $queryString,
1330                $attributes,
1331                $attrsonly,
1332                $sizelimit,
1333                $timelimit,
1334                $deref
1335            );
1336        }
1337    }
1338
1339    /**
1340     * Create an LDAP entry
1341     *
1342     * @param   string  $dn             The distinguished name to use
1343     * @param   array   $attributes     The entry's attributes
1344     *
1345     * @return  bool                    Whether the operation was successful
1346     */
1347    public function addEntry($dn, array $attributes)
1348    {
1349        return ldap_add($this->getConnection(), $dn, $attributes);
1350    }
1351
1352    /**
1353     * Modify an LDAP entry
1354     *
1355     * @param   string  $dn             The distinguished name to use
1356     * @param   array   $attributes     The attributes to update the entry with
1357     *
1358     * @return  bool                    Whether the operation was successful
1359     */
1360    public function modifyEntry($dn, array $attributes)
1361    {
1362        return ldap_modify($this->getConnection(), $dn, $attributes);
1363    }
1364
1365    /**
1366     * Change the distinguished name of an LDAP entry
1367     *
1368     * @param   string  $dn             The entry's current distinguished name
1369     * @param   string  $newRdn         The new relative distinguished name
1370     * @param   string  $newParentDn    The new parent or superior entry's distinguished name
1371     *
1372     * @return  resource                The resulting search result identifier
1373     *
1374     * @throws  LdapException           In case an error occured
1375     */
1376    public function moveEntry($dn, $newRdn, $newParentDn)
1377    {
1378        $ds = $this->getConnection();
1379        $result = ldap_rename($ds, $dn, $newRdn, $newParentDn, false);
1380        if ($result === false) {
1381            throw new LdapException('Could not move entry "%s" to "%s": %s', $dn, $newRdn, ldap_error($ds));
1382        }
1383
1384        return $result;
1385    }
1386
1387    /**
1388     * Return the LDAP specific configuration directory with the given relative path being appended
1389     *
1390     * @param   string  $sub
1391     *
1392     * @return  string
1393     */
1394    protected function getConfigDir($sub = null)
1395    {
1396        $dir = Config::$configDir . '/ldap';
1397        if ($sub !== null) {
1398            $dir .= '/' . $sub;
1399        }
1400
1401        return $dir;
1402    }
1403
1404    /**
1405     * Render and return a valid LDAP filter representation of the given filter
1406     *
1407     * @param   Filter  $filter
1408     * @param   int     $level
1409     *
1410     * @return  string
1411     */
1412    public function renderFilter(Filter $filter, $level = 0)
1413    {
1414        if ($filter->isExpression()) {
1415            /** @var $filter FilterExpression */
1416            return $this->renderFilterExpression($filter);
1417        }
1418
1419        /** @var $filter FilterChain */
1420        $parts = array();
1421        foreach ($filter->filters() as $filterPart) {
1422            $part = $this->renderFilter($filterPart, $level + 1);
1423            if ($part) {
1424                $parts[] = $part;
1425            }
1426        }
1427
1428        if (empty($parts)) {
1429            return '';
1430        }
1431
1432        $format = '%1$s(%2$s)';
1433        if (count($parts) === 1 && ! $filter instanceof FilterNot) {
1434            $format = '%2$s';
1435        }
1436        if ($level === 0) {
1437            $format = '(' . $format . ')';
1438        }
1439
1440        return sprintf($format, $filter->getOperatorSymbol(), implode(')(', $parts));
1441    }
1442
1443    /**
1444     * Render and return a valid LDAP filter expression of the given filter
1445     *
1446     * @param   FilterExpression    $filter
1447     *
1448     * @return  string
1449     */
1450    protected function renderFilterExpression(FilterExpression $filter)
1451    {
1452        $column = $filter->getColumn();
1453        $sign = $filter->getSign();
1454        $expression = $filter->getExpression();
1455        $format = '%1$s%2$s%3$s';
1456
1457        if ($expression === null || $expression === true) {
1458            $expression = '*';
1459        } elseif (is_array($expression)) {
1460            $seqFormat = '|(%s)';
1461            if ($sign === '!=') {
1462                $seqFormat = '!(' . $seqFormat . ')';
1463                $sign = '=';
1464            }
1465
1466            $seqParts = array();
1467            foreach ($expression as $expressionValue) {
1468                $seqParts[] = sprintf(
1469                    $format,
1470                    LdapUtils::quoteForSearch($column),
1471                    $sign,
1472                    LdapUtils::quoteForSearch($expressionValue, true)
1473                );
1474            }
1475
1476            return sprintf($seqFormat, implode(')(', $seqParts));
1477        }
1478
1479        if ($sign === '!=') {
1480            $format = '!(%1$s=%3$s)';
1481        }
1482
1483        return sprintf(
1484            $format,
1485            LdapUtils::quoteForSearch($column),
1486            $sign,
1487            LdapUtils::quoteForSearch($expression, true)
1488        );
1489    }
1490
1491    /**
1492     * Inspect if this LDAP Connection is working as expected
1493     *
1494     * Check if connection, bind and encryption is working as expected and get additional
1495     * information about the used
1496     *
1497     * @return  Inspection  Inspection result
1498     */
1499    public function inspect()
1500    {
1501        $insp = new Inspection('Ldap Connection');
1502
1503        // Try to connect to the server with the given connection parameters
1504        try {
1505            $ds = $this->prepareNewConnection($insp);
1506        } catch (Exception $e) {
1507            if ($this->encryption === 'starttls') {
1508                // The Exception does not return any proper error messages in case of certificate errors. Connecting
1509                // by STARTTLS will usually fail at this point when the certificate is unknown,
1510                // so at least try to give some hints.
1511                $insp->write('NOTE: There might be an issue with the chosen encryption. Ensure that the LDAP-Server ' .
1512                    'supports STARTTLS and that the LDAP-Client is configured to accept its certificate.');
1513            }
1514            return $insp->error($e->getMessage());
1515        }
1516
1517        // Try a bind-command with the given user credentials, this must not fail
1518        $success = @ldap_bind($ds, $this->bindDn, $this->bindPw);
1519        $msg = sprintf(
1520            'LDAP bind (%s / %s) to %s',
1521            $this->bindDn,
1522            '***' /* $this->bindPw */,
1523            $this->normalizeHostname($this->hostname)
1524        );
1525        if (! $success) {
1526            // ldap_error does not return any proper error messages in case of certificate errors. Connecting
1527            // by LDAPS will usually fail at this point when the certificate is unknown, so at least try to give
1528            // some hints.
1529            if ($this->encryption === 'ldaps') {
1530                $insp->write('NOTE: There might be an issue with the chosen encryption. Ensure that the LDAP-Server ' .
1531                    ' supports LDAPS and that the LDAP-Client is configured to accept its certificate.');
1532            }
1533            return $insp->error(sprintf('%s failed: %s', $msg, ldap_error($ds)));
1534        }
1535        $insp->write(sprintf($msg . ' successful'));
1536
1537        // Try to execute a schema discovery this may fail if schema discovery is not supported
1538        try {
1539            $cap = LdapCapabilities::discoverCapabilities($this);
1540            $discovery = new Inspection('Discovery Results');
1541            $vendor = $cap->getVendor();
1542            if (isset($vendor)) {
1543                $discovery->write($vendor);
1544            }
1545            $version = $cap->getVersion();
1546            if (isset($version)) {
1547                $discovery->write($version);
1548            }
1549            $discovery->write('Supports STARTTLS: ' . ($cap->hasStartTls() ? 'True' : 'False'));
1550            $discovery->write('Default naming context: ' . $cap->getDefaultNamingContext());
1551            $insp->write($discovery);
1552        } catch (Exception $e) {
1553            $insp->write('Schema discovery not possible: ' . $e->getMessage());
1554        }
1555        return $insp;
1556    }
1557
1558    protected function normalizeHostname($hostname)
1559    {
1560        $scheme = $this->encryption === static::LDAPS ? 'ldaps://' : 'ldap://';
1561        $normalizeHostname = function ($hostname) use ($scheme) {
1562            if (strpos($hostname, $scheme) === false) {
1563                $hostname = $scheme . $hostname;
1564            }
1565
1566            if (! preg_match('/:\d+$/', $hostname)) {
1567                $hostname .= ':' . $this->port;
1568            }
1569
1570            return $hostname;
1571        };
1572
1573        $ldapUrls = explode(' ', $hostname);
1574        if (count($ldapUrls) > 1) {
1575            foreach ($ldapUrls as & $uri) {
1576                $uri = $normalizeHostname($uri);
1577            }
1578
1579            $hostname = implode(' ', $ldapUrls);
1580        } else {
1581            $hostname = $normalizeHostname($hostname);
1582        }
1583
1584        return $hostname;
1585    }
1586}
1587