1<?php
2/**
3 * Zend Framework (http://framework.zend.com/)
4 *
5 * @link      http://github.com/zendframework/zf2 for the canonical source repository
6 * @copyright Copyright (c) 2005-2015 Zend Technologies USA Inc. (http://www.zend.com)
7 * @license   http://framework.zend.com/license/new-bsd New BSD License
8 */
9
10namespace Zend\Ldap;
11
12use Traversable;
13use Zend\Stdlib\ErrorHandler;
14
15class Ldap
16{
17    const SEARCH_SCOPE_SUB  = 1;
18    const SEARCH_SCOPE_ONE  = 2;
19    const SEARCH_SCOPE_BASE = 3;
20
21    const ACCTNAME_FORM_DN        = 1;
22    const ACCTNAME_FORM_USERNAME  = 2;
23    const ACCTNAME_FORM_BACKSLASH = 3;
24    const ACCTNAME_FORM_PRINCIPAL = 4;
25
26    /**
27     * String used with ldap_connect for error handling purposes.
28     *
29     * @var string
30     */
31    private $connectString;
32
33    /**
34     * The options used in connecting, binding, etc.
35     *
36     * @var array
37     */
38    protected $options = null;
39
40    /**
41     * The raw LDAP extension resource.
42     *
43     * @var resource
44     */
45    protected $resource = null;
46
47    /**
48     * FALSE if no user is bound to the LDAP resource
49     * NULL if there has been an anonymous bind
50     * username of the currently bound user
51     *
52     * @var bool|null|string
53     */
54    protected $boundUser = false;
55
56    /**
57     * Caches the RootDse
58     *
59     * @var Node\RootDse
60     */
61    protected $rootDse = null;
62
63    /**
64     * Caches the schema
65     *
66     * @var Node\Schema
67     */
68    protected $schema = null;
69
70    /**
71     * Constructor.
72     *
73     * @param  array|Traversable $options Options used in connecting, binding, etc.
74     * @throws Exception\LdapException
75     */
76    public function __construct($options = array())
77    {
78        if (!extension_loaded('ldap')) {
79            throw new Exception\LdapException(null, 'LDAP extension not loaded',
80                Exception\LdapException::LDAP_X_EXTENSION_NOT_LOADED);
81        }
82        $this->setOptions($options);
83    }
84
85    /**
86     * Destructor.
87     *
88     * @return void
89     */
90    public function __destruct()
91    {
92        $this->disconnect();
93    }
94
95    /**
96     * @return resource The raw LDAP extension resource.
97     */
98    public function getResource()
99    {
100        if (!is_resource($this->resource) || $this->boundUser === false) {
101            $this->bind();
102        }
103
104        return $this->resource;
105    }
106
107    /**
108     * Return the LDAP error number of the last LDAP command
109     *
110     * @return int
111     */
112    public function getLastErrorCode()
113    {
114        ErrorHandler::start(E_WARNING);
115        $ret = ldap_get_option($this->resource, LDAP_OPT_ERROR_NUMBER, $err);
116        ErrorHandler::stop();
117        if ($ret === true) {
118            if ($err <= -1 && $err >= -17) {
119                /* For some reason draft-ietf-ldapext-ldap-c-api-xx.txt error
120                 * codes in OpenLDAP are negative values from -1 to -17.
121                 */
122                $err = Exception\LdapException::LDAP_SERVER_DOWN + (-$err - 1);
123            }
124            return $err;
125        }
126
127        return 0;
128    }
129
130    /**
131     * Return the LDAP error message of the last LDAP command
132     *
133     * @param  int   $errorCode
134     * @param  array $errorMessages
135     * @return string
136     */
137    public function getLastError(&$errorCode = null, array &$errorMessages = null)
138    {
139        $errorCode     = $this->getLastErrorCode();
140        $errorMessages = array();
141
142        /* The various error retrieval functions can return
143         * different things so we just try to collect what we
144         * can and eliminate dupes.
145         */
146        ErrorHandler::start(E_WARNING);
147        $estr1 = ldap_error($this->resource);
148        ErrorHandler::stop();
149        if ($errorCode !== 0 && $estr1 === 'Success') {
150            ErrorHandler::start(E_WARNING);
151            $estr1 = ldap_err2str($errorCode);
152            ErrorHandler::stop();
153        }
154        if (!empty($estr1)) {
155            $errorMessages[] = $estr1;
156        }
157
158        ErrorHandler::start(E_WARNING);
159        ldap_get_option($this->resource, LDAP_OPT_ERROR_STRING, $estr2);
160        ErrorHandler::stop();
161        if (!empty($estr2) && !in_array($estr2, $errorMessages)) {
162            $errorMessages[] = $estr2;
163        }
164
165        $message = '';
166        if ($errorCode > 0) {
167            $message = '0x' . dechex($errorCode) . ' ';
168        }
169
170        if (count($errorMessages) > 0) {
171            $message .= '(' . implode('; ', $errorMessages) . ')';
172        } else {
173            $message .= '(no error message from LDAP)';
174        }
175
176        return $message;
177    }
178
179    /**
180     * Get the currently bound user
181     *
182     * FALSE if no user is bound to the LDAP resource
183     * NULL if there has been an anonymous bind
184     * username of the currently bound user
185     *
186     * @return bool|null|string
187     */
188    public function getBoundUser()
189    {
190        return $this->boundUser;
191    }
192
193    /**
194     * Sets the options used in connecting, binding, etc.
195     *
196     * Valid option keys:
197     *  host
198     *  port
199     *  useSsl
200     *  username
201     *  password
202     *  bindRequiresDn
203     *  baseDn
204     *  accountCanonicalForm
205     *  accountDomainName
206     *  accountDomainNameShort
207     *  accountFilterFormat
208     *  allowEmptyPassword
209     *  useStartTls
210     *  optReferrals
211     *  tryUsernameSplit
212     *  networkTimeout
213     *
214     * @param  array|Traversable $options Options used in connecting, binding, etc.
215     * @return Ldap Provides a fluent interface
216     * @throws Exception\LdapException
217     */
218    public function setOptions($options)
219    {
220        if ($options instanceof Traversable) {
221            $options = iterator_to_array($options);
222        }
223
224        $permittedOptions = array(
225            'host'                   => null,
226            'port'                   => 0,
227            'useSsl'                 => false,
228            'username'               => null,
229            'password'               => null,
230            'bindRequiresDn'         => false,
231            'baseDn'                 => null,
232            'accountCanonicalForm'   => null,
233            'accountDomainName'      => null,
234            'accountDomainNameShort' => null,
235            'accountFilterFormat'    => null,
236            'allowEmptyPassword'     => false,
237            'useStartTls'            => false,
238            'optReferrals'           => false,
239            'tryUsernameSplit'       => true,
240            'networkTimeout'         => null,
241        );
242
243        foreach ($permittedOptions as $key => $val) {
244            if (array_key_exists($key, $options)) {
245                $val = $options[$key];
246                unset($options[$key]);
247                /* Enforce typing. This eliminates issues like Zend\Config\Reader\Ini
248                 * returning '1' as a string (ZF-3163).
249                 */
250                switch ($key) {
251                    case 'port':
252                    case 'accountCanonicalForm':
253                    case 'networkTimeout':
254                        $permittedOptions[$key] = (int) $val;
255                        break;
256                    case 'useSsl':
257                    case 'bindRequiresDn':
258                    case 'allowEmptyPassword':
259                    case 'useStartTls':
260                    case 'optReferrals':
261                    case 'tryUsernameSplit':
262                        $permittedOptions[$key] = ($val === true
263                            || $val === '1'
264                            || strcasecmp($val, 'true') == 0);
265                        break;
266                    default:
267                        $permittedOptions[$key] = trim($val);
268                        break;
269                }
270            }
271        }
272        if (count($options) > 0) {
273            $key = key($options);
274            throw new Exception\LdapException(null, "Unknown Zend\\Ldap\\Ldap option: $key");
275        }
276        $this->options = $permittedOptions;
277
278        return $this;
279    }
280
281    /**
282     * @return array The current options.
283     */
284    public function getOptions()
285    {
286        return $this->options;
287    }
288
289    /**
290     * @return string The hostname of the LDAP server being used to
291     *  authenticate accounts
292     */
293    protected function getHost()
294    {
295        return $this->options['host'];
296    }
297
298    /**
299     * @return int The port of the LDAP server or 0 to indicate that no port
300     *  value is set
301     */
302    protected function getPort()
303    {
304        return $this->options['port'];
305    }
306
307    /**
308     * @return bool The default SSL / TLS encrypted transport control
309     */
310    protected function getUseSsl()
311    {
312        return $this->options['useSsl'];
313    }
314
315    /**
316     * @return string The default acctname for binding
317     */
318    protected function getUsername()
319    {
320        return $this->options['username'];
321    }
322
323    /**
324     * @return string The default password for binding
325     */
326    protected function getPassword()
327    {
328        return $this->options['password'];
329    }
330
331    /**
332     * @return bool Bind requires DN
333     */
334    protected function getBindRequiresDn()
335    {
336        return $this->options['bindRequiresDn'];
337    }
338
339    /**
340     * Gets the base DN under which objects of interest are located
341     *
342     * @return string
343     */
344    public function getBaseDn()
345    {
346        return $this->options['baseDn'];
347    }
348
349    /**
350     * @return int Either ACCTNAME_FORM_BACKSLASH, ACCTNAME_FORM_PRINCIPAL or
351     * ACCTNAME_FORM_USERNAME indicating the form usernames should be canonicalized to.
352     */
353    protected function getAccountCanonicalForm()
354    {
355        /* Account names should always be qualified with a domain. In some scenarios
356         * using non-qualified account names can lead to security vulnerabilities. If
357         * no account canonical form is specified, we guess based in what domain
358         * names have been supplied.
359         */
360        $accountCanonicalForm = $this->options['accountCanonicalForm'];
361        if (!$accountCanonicalForm) {
362            $accountDomainName      = $this->getAccountDomainName();
363            $accountDomainNameShort = $this->getAccountDomainNameShort();
364            if ($accountDomainNameShort) {
365                $accountCanonicalForm = self::ACCTNAME_FORM_BACKSLASH;
366            } else {
367                if ($accountDomainName) {
368                    $accountCanonicalForm = self::ACCTNAME_FORM_PRINCIPAL;
369                } else {
370                    $accountCanonicalForm = self::ACCTNAME_FORM_USERNAME;
371                }
372            }
373        }
374
375        return $accountCanonicalForm;
376    }
377
378    /**
379     * @return string The account domain name
380     */
381    protected function getAccountDomainName()
382    {
383        return $this->options['accountDomainName'];
384    }
385
386    /**
387     * @return string The short account domain name
388     */
389    protected function getAccountDomainNameShort()
390    {
391        return $this->options['accountDomainNameShort'];
392    }
393
394    /**
395     * @return string A format string for building an LDAP search filter to match
396     * an account
397     */
398    protected function getAccountFilterFormat()
399    {
400        return $this->options['accountFilterFormat'];
401    }
402
403    /**
404     * @return bool Allow empty passwords
405     */
406    protected function getAllowEmptyPassword()
407    {
408        return $this->options['allowEmptyPassword'];
409    }
410
411    /**
412     * @return bool The default SSL / TLS encrypted transport control
413     */
414    protected function getUseStartTls()
415    {
416        return $this->options['useStartTls'];
417    }
418
419    /**
420     * @return bool Opt. Referrals
421     */
422    protected function getOptReferrals()
423    {
424        return $this->options['optReferrals'];
425    }
426
427    /**
428     * @return bool Try splitting the username into username and domain
429     */
430    protected function getTryUsernameSplit()
431    {
432        return $this->options['tryUsernameSplit'];
433    }
434
435    /**
436     * @return int The value for network timeout when connect to the LDAP server.
437     */
438    protected function getNetworkTimeout()
439    {
440        return $this->options['networkTimeout'];
441    }
442
443    /**
444     * @param  string $acctname
445     * @return string The LDAP search filter for matching directory accounts
446     */
447    protected function getAccountFilter($acctname)
448    {
449        $dname = '';
450        $aname = '';
451        $this->splitName($acctname, $dname, $aname);
452        $accountFilterFormat = $this->getAccountFilterFormat();
453        $aname               = Filter\AbstractFilter::escapeValue($aname);
454        if ($accountFilterFormat) {
455            return sprintf($accountFilterFormat, $aname);
456        }
457        if (!$this->getBindRequiresDn()) {
458            // is there a better way to detect this?
459            return sprintf("(&(objectClass=user)(sAMAccountName=%s))", $aname);
460        }
461
462        return sprintf("(&(objectClass=posixAccount)(uid=%s))", $aname);
463    }
464
465    /**
466     * @param string $name  The name to split
467     * @param string $dname The resulting domain name (this is an out parameter)
468     * @param string $aname The resulting account name (this is an out parameter)
469     * @return void
470     */
471    protected function splitName($name, &$dname, &$aname)
472    {
473        $dname = null;
474        $aname = $name;
475
476        if (!$this->getTryUsernameSplit()) {
477            return;
478        }
479
480        $pos = strpos($name, '@');
481        if ($pos) {
482            $dname = substr($name, $pos + 1);
483            $aname = substr($name, 0, $pos);
484        } else {
485            $pos = strpos($name, '\\');
486            if ($pos) {
487                $dname = substr($name, 0, $pos);
488                $aname = substr($name, $pos + 1);
489            }
490        }
491    }
492
493    /**
494     * @param  string $acctname The name of the account
495     * @return string The DN of the specified account
496     * @throws Exception\LdapException
497     */
498    protected function getAccountDn($acctname)
499    {
500        if (Dn::checkDn($acctname)) {
501            return $acctname;
502        }
503        $acctname = $this->getCanonicalAccountName($acctname, self::ACCTNAME_FORM_USERNAME);
504        $acct     = $this->getAccount($acctname, array('dn'));
505
506        return $acct['dn'];
507    }
508
509    /**
510     * @param  string $dname The domain name to check
511     * @return bool
512     */
513    protected function isPossibleAuthority($dname)
514    {
515        if ($dname === null) {
516            return true;
517        }
518        $accountDomainName      = $this->getAccountDomainName();
519        $accountDomainNameShort = $this->getAccountDomainNameShort();
520        if ($accountDomainName === null && $accountDomainNameShort === null) {
521            return true;
522        }
523        if (strcasecmp($dname, $accountDomainName) == 0) {
524            return true;
525        }
526        if (strcasecmp($dname, $accountDomainNameShort) == 0) {
527            return true;
528        }
529
530        return false;
531    }
532
533    /**
534     * @param  string $acctname The name to canonicalize
535     * @param  int    $form     The desired form of canonicalization
536     * @return string The canonicalized name in the desired form
537     * @throws Exception\LdapException
538     */
539    public function getCanonicalAccountName($acctname, $form = 0)
540    {
541        $dname = '';
542        $uname = '';
543
544        $this->splitName($acctname, $dname, $uname);
545
546        if (!$this->isPossibleAuthority($dname)) {
547            throw new Exception\LdapException(null,
548                "Binding domain is not an authority for user: $acctname",
549                Exception\LdapException::LDAP_X_DOMAIN_MISMATCH);
550        }
551
552        if (!$uname) {
553            throw new Exception\LdapException(null, "Invalid account name syntax: $acctname");
554        }
555
556        if (function_exists('mb_strtolower')) {
557            $uname = mb_strtolower($uname, 'UTF-8');
558        } else {
559            $uname = strtolower($uname);
560        }
561
562        if ($form === 0) {
563            $form = $this->getAccountCanonicalForm();
564        }
565
566        switch ($form) {
567            case self::ACCTNAME_FORM_DN:
568                return $this->getAccountDn($acctname);
569            case self::ACCTNAME_FORM_USERNAME:
570                return $uname;
571            case self::ACCTNAME_FORM_BACKSLASH:
572                $accountDomainNameShort = $this->getAccountDomainNameShort();
573                if (!$accountDomainNameShort) {
574                    throw new Exception\LdapException(null, 'Option required: accountDomainNameShort');
575                }
576                return "$accountDomainNameShort\\$uname";
577            case self::ACCTNAME_FORM_PRINCIPAL:
578                $accountDomainName = $this->getAccountDomainName();
579                if (!$accountDomainName) {
580                    throw new Exception\LdapException(null, 'Option required: accountDomainName');
581                }
582                return "$uname@$accountDomainName";
583            default:
584                throw new Exception\LdapException(null, "Unknown canonical name form: $form");
585        }
586    }
587
588    /**
589     * @param  string $acctname
590     * @param  array  $attrs An array of names of desired attributes
591     * @return array  An array of the attributes representing the account
592     * @throws Exception\LdapException
593     */
594    protected function getAccount($acctname, array $attrs = null)
595    {
596        $baseDn = $this->getBaseDn();
597        if (!$baseDn) {
598            throw new Exception\LdapException(null, 'Base DN not set');
599        }
600
601        $accountFilter = $this->getAccountFilter($acctname);
602        if (!$accountFilter) {
603            throw new Exception\LdapException(null, 'Invalid account filter');
604        }
605
606        if (!is_resource($this->getResource())) {
607            $this->bind();
608        }
609
610        $accounts = $this->search($accountFilter, $baseDn, self::SEARCH_SCOPE_SUB, $attrs);
611        $count    = $accounts->count();
612        if ($count === 1) {
613            $acct = $accounts->getFirst();
614            $accounts->close();
615
616            return $acct;
617        } else {
618            if ($count === 0) {
619                $code = Exception\LdapException::LDAP_NO_SUCH_OBJECT;
620                $str  = "No object found for: $accountFilter";
621            } else {
622                $code = Exception\LdapException::LDAP_OPERATIONS_ERROR;
623                $str  = "Unexpected result count ($count) for: $accountFilter";
624            }
625        }
626        $accounts->close();
627
628        throw new Exception\LdapException($this, $str, $code);
629    }
630
631    /**
632     * @return Ldap Provides a fluent interface
633     */
634    public function disconnect()
635    {
636        if (is_resource($this->resource)) {
637            ErrorHandler::start(E_WARNING);
638            ldap_unbind($this->resource);
639            ErrorHandler::stop();
640        }
641        $this->resource  = null;
642        $this->boundUser = false;
643
644        return $this;
645    }
646
647    /**
648     * To connect using SSL it seems the client tries to verify the server
649     * certificate by default. One way to disable this behavior is to set
650     * 'TLS_REQCERT never' in OpenLDAP's ldap.conf and restarting Apache. Or,
651     * if you really care about the server's cert you can put a cert on the
652     * web server.
653     *
654     * @param  string  $host           The hostname of the LDAP server to connect to
655     * @param  int     $port           The port number of the LDAP server to connect to
656     * @param  bool $useSsl         Use SSL
657     * @param  bool $useStartTls    Use STARTTLS
658     * @param  int     $networkTimeout The value for network timeout when connect to the LDAP server.
659     * @return Ldap Provides a fluent interface
660     * @throws Exception\LdapException
661     */
662    public function connect($host = null, $port = null, $useSsl = null, $useStartTls = null, $networkTimeout = null)
663    {
664        if ($host === null) {
665            $host = $this->getHost();
666        }
667        if ($port === null) {
668            $port = $this->getPort();
669        } else {
670            $port = (int) $port;
671        }
672        if ($useSsl === null) {
673            $useSsl = $this->getUseSsl();
674        } else {
675            $useSsl = (bool) $useSsl;
676        }
677        if ($useStartTls === null) {
678            $useStartTls = $this->getUseStartTls();
679        } else {
680            $useStartTls = (bool) $useStartTls;
681        }
682        if ($networkTimeout === null) {
683            $networkTimeout = $this->getNetworkTimeout();
684        } else {
685            $networkTimeout = (int) $networkTimeout;
686        }
687
688        if (!$host) {
689            throw new Exception\LdapException(null, 'A host parameter is required');
690        }
691
692        $useUri = false;
693        /* Because ldap_connect doesn't really try to connect, any connect error
694         * will actually occur during the ldap_bind call. Therefore, we save the
695         * connect string here for reporting it in error handling in bind().
696         */
697        $hosts = array();
698        if (preg_match_all('~ldap(?:i|s)?://~', $host, $hosts, PREG_SET_ORDER) > 0) {
699            $this->connectString = $host;
700            $useUri              = true;
701            $useSsl              = false;
702        } else {
703            if ($useSsl) {
704                $this->connectString = 'ldaps://' . $host;
705                $useUri              = true;
706            } else {
707                $this->connectString = 'ldap://' . $host;
708            }
709            if ($port) {
710                $this->connectString .= ':' . $port;
711            }
712        }
713
714        $this->disconnect();
715
716
717        /* Only OpenLDAP 2.2 + supports URLs so if SSL is not requested, just
718         * use the old form.
719         */
720        ErrorHandler::start();
721        $resource = ($useUri) ? ldap_connect($this->connectString) : ldap_connect($host, $port);
722        ErrorHandler::stop();
723
724        if (is_resource($resource) === true) {
725            $this->resource  = $resource;
726            $this->boundUser = false;
727
728            $optReferrals = ($this->getOptReferrals()) ? 1 : 0;
729            ErrorHandler::start(E_WARNING);
730            if (ldap_set_option($resource, LDAP_OPT_PROTOCOL_VERSION, 3)
731                && ldap_set_option($resource, LDAP_OPT_REFERRALS, $optReferrals)
732            ) {
733                if ($networkTimeout) {
734                    ldap_set_option($resource, LDAP_OPT_NETWORK_TIMEOUT, $networkTimeout);
735                }
736                if ($useSsl || !$useStartTls || ldap_start_tls($resource)) {
737                    ErrorHandler::stop();
738                    return $this;
739                }
740            }
741            ErrorHandler::stop();
742
743            $zle = new Exception\LdapException($this, "$host:$port");
744            $this->disconnect();
745            throw $zle;
746        }
747
748        throw new Exception\LdapException(null, "Failed to connect to LDAP server: $host:$port");
749    }
750
751    /**
752     * @param  string $username The username for authenticating the bind
753     * @param  string $password The password for authenticating the bind
754     * @return Ldap Provides a fluent interface
755     * @throws Exception\LdapException
756     */
757    public function bind($username = null, $password = null)
758    {
759        $moreCreds = true;
760
761        // Security check: remove null bytes in password
762        // @see https://net.educause.edu/ir/library/pdf/csd4875.pdf
763        $password = str_replace("\0", '', $password);
764
765        if ($username === null) {
766            $username  = $this->getUsername();
767            $password  = $this->getPassword();
768            $moreCreds = false;
769        }
770
771        if (empty($username)) {
772            /* Perform anonymous bind
773             */
774            $username = null;
775            $password = null;
776        } else {
777            /* Check to make sure the username is in DN form.
778             */
779            if (!Dn::checkDn($username)) {
780                if ($this->getBindRequiresDn()) {
781                    /* moreCreds stops an infinite loop if getUsername does not
782                     * return a DN and the bind requires it
783                     */
784                    if ($moreCreds) {
785                        try {
786                            $username = $this->getAccountDn($username);
787                        } catch (Exception\LdapException $zle) {
788                            switch ($zle->getCode()) {
789                                case Exception\LdapException::LDAP_NO_SUCH_OBJECT:
790                                case Exception\LdapException::LDAP_X_DOMAIN_MISMATCH:
791                                case Exception\LdapException::LDAP_X_EXTENSION_NOT_LOADED:
792                                    throw $zle;
793                            }
794                            throw new Exception\LdapException(null,
795                                'Failed to retrieve DN for account: ' . $username .
796                                    ' [' . $zle->getMessage() . ']',
797                                Exception\LdapException::LDAP_OPERATIONS_ERROR);
798                        }
799                    } else {
800                        throw new Exception\LdapException(null, 'Binding requires username in DN form');
801                    }
802                } else {
803                    $username = $this->getCanonicalAccountName(
804                        $username,
805                        $this->getAccountCanonicalForm()
806                    );
807                }
808            }
809        }
810
811        if (!is_resource($this->resource)) {
812            $this->connect();
813        }
814
815        if ($username !== null && $password === '' && $this->getAllowEmptyPassword() !== true) {
816            $zle = new Exception\LdapException(null,
817                'Empty password not allowed - see allowEmptyPassword option.');
818        } else {
819            ErrorHandler::start(E_WARNING);
820            $bind = ldap_bind($this->resource, $username, $password);
821            ErrorHandler::stop();
822            if ($bind) {
823                $this->boundUser = $username;
824                return $this;
825            }
826
827            $message = ($username === null) ? $this->connectString : $username;
828            switch ($this->getLastErrorCode()) {
829                case Exception\LdapException::LDAP_SERVER_DOWN:
830                    /* If the error is related to establishing a connection rather than binding,
831                     * the connect string is more informative than the username.
832                     */
833                    $message = $this->connectString;
834            }
835
836            $zle = new Exception\LdapException($this, $message);
837        }
838        $this->disconnect();
839
840        throw $zle;
841    }
842
843    /**
844     * A global LDAP search routine for finding information.
845     *
846     * Options can be either passed as single parameters according to the
847     * method signature or as an array with one or more of the following keys
848     * - filter
849     * - baseDn
850     * - scope
851     * - attributes
852     * - sort
853     * - collectionClass
854     * - sizelimit
855     * - timelimit
856     *
857     * @param  string|Filter\AbstractFilter|array $filter
858     * @param  string|Dn|null                     $basedn
859     * @param  int                            $scope
860     * @param  array                              $attributes
861     * @param  string|null                        $sort
862     * @param  string|null                        $collectionClass
863     * @param  int                            $sizelimit
864     * @param  int                            $timelimit
865     * @return Collection
866     * @throws Exception\LdapException
867     */
868    public function search($filter, $basedn = null, $scope = self::SEARCH_SCOPE_SUB, array $attributes = array(),
869                           $sort = null, $collectionClass = null, $sizelimit = 0, $timelimit = 0
870    ) {
871        if (is_array($filter)) {
872            $options = array_change_key_case($filter, CASE_LOWER);
873            foreach ($options as $key => $value) {
874                switch ($key) {
875                    case 'filter':
876                    case 'basedn':
877                    case 'scope':
878                    case 'sort':
879                        $$key = $value;
880                        break;
881                    case 'attributes':
882                        if (is_array($value)) {
883                            $attributes = $value;
884                        }
885                        break;
886                    case 'collectionclass':
887                        $collectionClass = $value;
888                        break;
889                    case 'sizelimit':
890                    case 'timelimit':
891                        $$key = (int) $value;
892                        break;
893                }
894            }
895        }
896
897        if ($basedn === null) {
898            $basedn = $this->getBaseDn();
899        } elseif ($basedn instanceof Dn) {
900            $basedn = $basedn->toString();
901        }
902
903        if ($filter instanceof Filter\AbstractFilter) {
904            $filter = $filter->toString();
905        }
906
907        $resource = $this->getResource();
908        ErrorHandler::start(E_WARNING);
909        switch ($scope) {
910            case self::SEARCH_SCOPE_ONE:
911                $search = ldap_list($resource, $basedn, $filter, $attributes, 0, $sizelimit, $timelimit);
912                break;
913            case self::SEARCH_SCOPE_BASE:
914                $search = ldap_read($resource, $basedn, $filter, $attributes, 0, $sizelimit, $timelimit);
915                break;
916            case self::SEARCH_SCOPE_SUB:
917            default:
918                $search = ldap_search($resource, $basedn, $filter, $attributes, 0, $sizelimit, $timelimit);
919                break;
920        }
921        ErrorHandler::stop();
922
923        if ($search === false) {
924            throw new Exception\LdapException($this, 'searching: ' . $filter);
925        }
926        if ($sort !== null && is_string($sort)) {
927            ErrorHandler::start(E_WARNING);
928            $isSorted = ldap_sort($resource, $search, $sort);
929            ErrorHandler::stop();
930            if ($isSorted === false) {
931                throw new Exception\LdapException($this, 'sorting: ' . $sort);
932            }
933        }
934
935        $iterator = new Collection\DefaultIterator($this, $search);
936
937        return $this->createCollection($iterator, $collectionClass);
938    }
939
940    /**
941     * Extension point for collection creation
942     *
943     * @param  Collection\DefaultIterator $iterator
944     * @param  string|null                $collectionClass
945     * @return Collection
946     * @throws Exception\LdapException
947     */
948    protected function createCollection(Collection\DefaultIterator $iterator, $collectionClass)
949    {
950        if ($collectionClass === null) {
951            return new Collection($iterator);
952        } else {
953            $collectionClass = (string) $collectionClass;
954            if (!class_exists($collectionClass)) {
955                throw new Exception\LdapException(null,
956                    "Class '$collectionClass' can not be found");
957            }
958            if (!is_subclass_of($collectionClass, 'Zend\Ldap\Collection')) {
959                throw new Exception\LdapException(null,
960                    "Class '$collectionClass' must subclass 'Zend\\Ldap\\Collection'");
961            }
962
963            return new $collectionClass($iterator);
964        }
965    }
966
967    /**
968     * Count items found by given filter.
969     *
970     * @param  string|Filter\AbstractFilter $filter
971     * @param  string|Dn|null               $basedn
972     * @param  int                      $scope
973     * @return int
974     * @throws Exception\LdapException
975     */
976    public function count($filter, $basedn = null, $scope = self::SEARCH_SCOPE_SUB)
977    {
978        try {
979            $result = $this->search($filter, $basedn, $scope, array('dn'), null);
980        } catch (Exception\LdapException $e) {
981            if ($e->getCode() === Exception\LdapException::LDAP_NO_SUCH_OBJECT) {
982                return 0;
983            }
984            throw $e;
985        }
986
987        return $result->count();
988    }
989
990    /**
991     * Count children for a given DN.
992     *
993     * @param  string|Dn $dn
994     * @return int
995     * @throws Exception\LdapException
996     */
997    public function countChildren($dn)
998    {
999        return $this->count('(objectClass=*)', $dn, self::SEARCH_SCOPE_ONE);
1000    }
1001
1002    /**
1003     * Check if a given DN exists.
1004     *
1005     * @param  string|Dn $dn
1006     * @return bool
1007     * @throws Exception\LdapException
1008     */
1009    public function exists($dn)
1010    {
1011        return ($this->count('(objectClass=*)', $dn, self::SEARCH_SCOPE_BASE) == 1);
1012    }
1013
1014    /**
1015     * Search LDAP registry for entries matching filter and optional attributes
1016     *
1017     * Options can be either passed as single parameters according to the
1018     * method signature or as an array with one or more of the following keys
1019     * - filter
1020     * - baseDn
1021     * - scope
1022     * - attributes
1023     * - sort
1024     * - reverseSort
1025     * - sizelimit
1026     * - timelimit
1027     *
1028     * @param  string|Filter\AbstractFilter|array $filter
1029     * @param  string|Dn|null                     $basedn
1030     * @param  int                            $scope
1031     * @param  array                              $attributes
1032     * @param  string|null                        $sort
1033     * @param  bool                            $reverseSort
1034     * @param  int                            $sizelimit
1035     * @param  int                            $timelimit
1036     * @return array
1037     * @throws Exception\LdapException
1038     */
1039    public function searchEntries($filter, $basedn = null, $scope = self::SEARCH_SCOPE_SUB,
1040                                  array $attributes = array(), $sort = null, $reverseSort = false, $sizelimit = 0,
1041                                  $timelimit = 0)
1042    {
1043        if (is_array($filter)) {
1044            $filter = array_change_key_case($filter, CASE_LOWER);
1045            if (isset($filter['collectionclass'])) {
1046                unset($filter['collectionclass']);
1047            }
1048            if (isset($filter['reversesort'])) {
1049                $reverseSort = $filter['reversesort'];
1050                unset($filter['reversesort']);
1051            }
1052        }
1053        $result = $this->search($filter, $basedn, $scope, $attributes, $sort, null, $sizelimit, $timelimit);
1054        $items  = $result->toArray();
1055        if ((bool) $reverseSort === true) {
1056            $items = array_reverse($items, false);
1057        }
1058
1059        return $items;
1060    }
1061
1062    /**
1063     * Get LDAP entry by DN
1064     *
1065     * @param  string|Dn $dn
1066     * @param  array     $attributes
1067     * @param  bool   $throwOnNotFound
1068     * @return array
1069     * @throws null|Exception\LdapException
1070     */
1071    public function getEntry($dn, array $attributes = array(), $throwOnNotFound = false)
1072    {
1073        try {
1074            $result = $this->search(
1075                "(objectClass=*)", $dn, self::SEARCH_SCOPE_BASE,
1076                $attributes, null
1077            );
1078
1079            return $result->getFirst();
1080        } catch (Exception\LdapException $e) {
1081            if ($throwOnNotFound !== false) {
1082                throw $e;
1083            }
1084        }
1085
1086        return;
1087    }
1088
1089    /**
1090     * Prepares an ldap data entry array for insert/update operation
1091     *
1092     * @param  array $entry
1093     * @throws Exception\InvalidArgumentException
1094     * @return void
1095     */
1096    public static function prepareLdapEntryArray(array &$entry)
1097    {
1098        if (array_key_exists('dn', $entry)) {
1099            unset($entry['dn']);
1100        }
1101        foreach ($entry as $key => $value) {
1102            if (is_array($value)) {
1103                foreach ($value as $i => $v) {
1104                    if ($v === null) {
1105                        unset($value[$i]);
1106                    } elseif (!is_scalar($v)) {
1107                        throw new Exception\InvalidArgumentException('Only scalar values allowed in LDAP data');
1108                    } else {
1109                        $v = (string) $v;
1110                        if (strlen($v) == 0) {
1111                            unset($value[$i]);
1112                        } else {
1113                            $value[$i] = $v;
1114                        }
1115                    }
1116                }
1117                $entry[$key] = array_values($value);
1118            } else {
1119                if ($value === null) {
1120                    $entry[$key] = array();
1121                } elseif (!is_scalar($value)) {
1122                    throw new Exception\InvalidArgumentException('Only scalar values allowed in LDAP data');
1123                } else {
1124                    $value = (string) $value;
1125                    if (strlen($value) == 0) {
1126                        $entry[$key] = array();
1127                    } else {
1128                        $entry[$key] = array($value);
1129                    }
1130                }
1131            }
1132        }
1133        $entry = array_change_key_case($entry, CASE_LOWER);
1134    }
1135
1136    /**
1137     * Add new information to the LDAP repository
1138     *
1139     * @param  string|Dn $dn
1140     * @param  array     $entry
1141     * @return Ldap Provides a fluid interface
1142     * @throws Exception\LdapException
1143     */
1144    public function add($dn, array $entry)
1145    {
1146        if (!($dn instanceof Dn)) {
1147            $dn = Dn::factory($dn, null);
1148        }
1149        static::prepareLdapEntryArray($entry);
1150        foreach ($entry as $key => $value) {
1151            if (is_array($value) && count($value) === 0) {
1152                unset($entry[$key]);
1153            }
1154        }
1155
1156        $rdnParts = $dn->getRdn(Dn::ATTR_CASEFOLD_LOWER);
1157        foreach ($rdnParts as $key => $value) {
1158            $value = Dn::unescapeValue($value);
1159            if (!array_key_exists($key, $entry)) {
1160                $entry[$key] = array($value);
1161            } elseif (!in_array($value, $entry[$key])) {
1162                $entry[$key] = array_merge(array($value), $entry[$key]);
1163            }
1164        }
1165        $adAttributes = array('distinguishedname', 'instancetype', 'name', 'objectcategory',
1166                              'objectguid', 'usnchanged', 'usncreated', 'whenchanged', 'whencreated');
1167        foreach ($adAttributes as $attr) {
1168            if (array_key_exists($attr, $entry)) {
1169                unset($entry[$attr]);
1170            }
1171        }
1172
1173        $resource = $this->getResource();
1174        ErrorHandler::start(E_WARNING);
1175        $isAdded = ldap_add($resource, $dn->toString(), $entry);
1176        ErrorHandler::stop();
1177        if ($isAdded === false) {
1178            throw new Exception\LdapException($this, 'adding: ' . $dn->toString());
1179        }
1180
1181        return $this;
1182    }
1183
1184    /**
1185     * Update LDAP registry
1186     *
1187     * @param  string|Dn $dn
1188     * @param  array     $entry
1189     * @return Ldap Provides a fluid interface
1190     * @throws Exception\LdapException
1191     */
1192    public function update($dn, array $entry)
1193    {
1194        if (!($dn instanceof Dn)) {
1195            $dn = Dn::factory($dn, null);
1196        }
1197        static::prepareLdapEntryArray($entry);
1198
1199        $rdnParts = $dn->getRdn(Dn::ATTR_CASEFOLD_LOWER);
1200        foreach ($rdnParts as $key => $value) {
1201            $value = Dn::unescapeValue($value);
1202            if (array_key_exists($key, $entry) && !in_array($value, $entry[$key])) {
1203                $entry[$key] = array_merge(array($value), $entry[$key]);
1204            }
1205        }
1206        $adAttributes = array('distinguishedname', 'instancetype', 'name', 'objectcategory',
1207                              'objectguid', 'usnchanged', 'usncreated', 'whenchanged', 'whencreated');
1208        foreach ($adAttributes as $attr) {
1209            if (array_key_exists($attr, $entry)) {
1210                unset($entry[$attr]);
1211            }
1212        }
1213
1214        if (count($entry) > 0) {
1215            $resource = $this->getResource();
1216            ErrorHandler::start(E_WARNING);
1217            $isModified = ldap_modify($resource, $dn->toString(), $entry);
1218            ErrorHandler::stop();
1219            if ($isModified === false) {
1220                throw new Exception\LdapException($this, 'updating: ' . $dn->toString());
1221            }
1222        }
1223
1224        return $this;
1225    }
1226
1227    /**
1228     * Save entry to LDAP registry.
1229     *
1230     * Internally decides if entry will be updated to added by calling
1231     * {@link exists()}.
1232     *
1233     * @param  string|Dn $dn
1234     * @param  array     $entry
1235     * @return Ldap Provides a fluid interface
1236     * @throws Exception\LdapException
1237     */
1238    public function save($dn, array $entry)
1239    {
1240        if ($dn instanceof Dn) {
1241            $dn = $dn->toString();
1242        }
1243        if ($this->exists($dn)) {
1244            $this->update($dn, $entry);
1245        } else {
1246            $this->add($dn, $entry);
1247        }
1248
1249        return $this;
1250    }
1251
1252    /**
1253     * Delete an LDAP entry
1254     *
1255     * @param  string|Dn $dn
1256     * @param  bool   $recursively
1257     * @return Ldap Provides a fluid interface
1258     * @throws Exception\LdapException
1259     */
1260    public function delete($dn, $recursively = false)
1261    {
1262        if ($dn instanceof Dn) {
1263            $dn = $dn->toString();
1264        }
1265        if ($recursively === true) {
1266            if ($this->countChildren($dn) > 0) {
1267                $children = $this->getChildrenDns($dn);
1268                foreach ($children as $c) {
1269                    $this->delete($c, true);
1270                }
1271            }
1272        }
1273
1274        $resource = $this->getResource();
1275        ErrorHandler::start(E_WARNING);
1276        $isDeleted = ldap_delete($resource, $dn);
1277        ErrorHandler::stop();
1278        if ($isDeleted === false) {
1279            throw new Exception\LdapException($this, 'deleting: ' . $dn);
1280        }
1281
1282        return $this;
1283    }
1284
1285    /**
1286     * Retrieve the immediate children DNs of the given $parentDn
1287     *
1288     * This method is used in recursive methods like {@see delete()}
1289     * or {@see copy()}
1290     *
1291     * @param  string|Dn $parentDn
1292     * @throws Exception\LdapException
1293     * @return array of DNs
1294     */
1295    protected function getChildrenDns($parentDn)
1296    {
1297        if ($parentDn instanceof Dn) {
1298            $parentDn = $parentDn->toString();
1299        }
1300        $children = array();
1301
1302        $resource = $this->getResource();
1303        ErrorHandler::start(E_WARNING);
1304        $search = ldap_list($resource, $parentDn, '(objectClass=*)', array('dn'));
1305        for (
1306            $entry = ldap_first_entry($resource, $search);
1307            $entry !== false;
1308            $entry = ldap_next_entry($resource, $entry)
1309        ) {
1310            $childDn = ldap_get_dn($resource, $entry);
1311            if ($childDn === false) {
1312                ErrorHandler::stop();
1313                throw new Exception\LdapException($this, 'getting dn');
1314            }
1315            $children[] = $childDn;
1316        }
1317        ldap_free_result($search);
1318        ErrorHandler::stop();
1319
1320        return $children;
1321    }
1322
1323    /**
1324     * Moves a LDAP entry from one DN to another subtree.
1325     *
1326     * @param  string|Dn $from
1327     * @param  string|Dn $to
1328     * @param  bool   $recursively
1329     * @param  bool   $alwaysEmulate
1330     * @return Ldap Provides a fluid interface
1331     * @throws Exception\LdapException
1332     */
1333    public function moveToSubtree($from, $to, $recursively = false, $alwaysEmulate = false)
1334    {
1335        if ($from instanceof Dn) {
1336            $orgDnParts = $from->toArray();
1337        } else {
1338            $orgDnParts = Dn::explodeDn($from);
1339        }
1340
1341        if ($to instanceof Dn) {
1342            $newParentDnParts = $to->toArray();
1343        } else {
1344            $newParentDnParts = Dn::explodeDn($to);
1345        }
1346
1347        $newDnParts = array_merge(array(array_shift($orgDnParts)), $newParentDnParts);
1348        $newDn      = Dn::fromArray($newDnParts);
1349
1350        return $this->rename($from, $newDn, $recursively, $alwaysEmulate);
1351    }
1352
1353    /**
1354     * Moves a LDAP entry from one DN to another DN.
1355     *
1356     * This is an alias for {@link rename()}
1357     *
1358     * @param  string|Dn $from
1359     * @param  string|Dn $to
1360     * @param  bool   $recursively
1361     * @param  bool   $alwaysEmulate
1362     * @return Ldap Provides a fluid interface
1363     * @throws Exception\LdapException
1364     */
1365    public function move($from, $to, $recursively = false, $alwaysEmulate = false)
1366    {
1367        return $this->rename($from, $to, $recursively, $alwaysEmulate);
1368    }
1369
1370    /**
1371     * Renames a LDAP entry from one DN to another DN.
1372     *
1373     * This method implicitly moves the entry to another location within the tree.
1374     *
1375     * @param  string|Dn $from
1376     * @param  string|Dn $to
1377     * @param  bool   $recursively
1378     * @param  bool   $alwaysEmulate
1379     * @return Ldap Provides a fluid interface
1380     * @throws Exception\LdapException
1381     */
1382    public function rename($from, $to, $recursively = false, $alwaysEmulate = false)
1383    {
1384        $emulate = (bool) $alwaysEmulate;
1385        if (!function_exists('ldap_rename')) {
1386            $emulate = true;
1387        } elseif ($recursively) {
1388            $emulate = true;
1389        }
1390
1391        if ($emulate === false) {
1392            if ($from instanceof Dn) {
1393                $from = $from->toString();
1394            }
1395
1396            if ($to instanceof Dn) {
1397                $newDnParts = $to->toArray();
1398            } else {
1399                $newDnParts = Dn::explodeDn($to);
1400            }
1401
1402            $newRdn    = Dn::implodeRdn(array_shift($newDnParts));
1403            $newParent = Dn::implodeDn($newDnParts);
1404
1405            $resource = $this->getResource();
1406            ErrorHandler::start(E_WARNING);
1407            $isOK = ldap_rename($resource, $from, $newRdn, $newParent, true);
1408            ErrorHandler::stop();
1409            if ($isOK === false) {
1410                throw new Exception\LdapException($this, 'renaming ' . $from . ' to ' . $to);
1411            } elseif (!$this->exists($to)) {
1412                $emulate = true;
1413            }
1414        }
1415        if ($emulate) {
1416            $this->copy($from, $to, $recursively);
1417            $this->delete($from, $recursively);
1418        }
1419
1420        return $this;
1421    }
1422
1423    /**
1424     * Copies a LDAP entry from one DN to another subtree.
1425     *
1426     * @param  string|Dn $from
1427     * @param  string|Dn $to
1428     * @param  bool   $recursively
1429     * @return Ldap Provides a fluid interface
1430     * @throws Exception\LdapException
1431     */
1432    public function copyToSubtree($from, $to, $recursively = false)
1433    {
1434        if ($from instanceof Dn) {
1435            $orgDnParts = $from->toArray();
1436        } else {
1437            $orgDnParts = Dn::explodeDn($from);
1438        }
1439
1440        if ($to instanceof Dn) {
1441            $newParentDnParts = $to->toArray();
1442        } else {
1443            $newParentDnParts = Dn::explodeDn($to);
1444        }
1445
1446        $newDnParts = array_merge(array(array_shift($orgDnParts)), $newParentDnParts);
1447        $newDn      = Dn::fromArray($newDnParts);
1448
1449        return $this->copy($from, $newDn, $recursively);
1450    }
1451
1452    /**
1453     * Copies a LDAP entry from one DN to another DN.
1454     *
1455     * @param  string|Dn $from
1456     * @param  string|Dn $to
1457     * @param  bool   $recursively
1458     * @return Ldap Provides a fluid interface
1459     * @throws Exception\LdapException
1460     */
1461    public function copy($from, $to, $recursively = false)
1462    {
1463        $entry = $this->getEntry($from, array(), true);
1464
1465        if ($to instanceof Dn) {
1466            $toDnParts = $to->toArray();
1467        } else {
1468            $toDnParts = Dn::explodeDn($to);
1469        }
1470        $this->add($to, $entry);
1471
1472        if ($recursively === true && $this->countChildren($from) > 0) {
1473            $children = $this->getChildrenDns($from);
1474            foreach ($children as $c) {
1475                $cDnParts      = Dn::explodeDn($c);
1476                $newChildParts = array_merge(array(array_shift($cDnParts)), $toDnParts);
1477                $newChild      = Dn::implodeDn($newChildParts);
1478                $this->copy($c, $newChild, true);
1479            }
1480        }
1481
1482        return $this;
1483    }
1484
1485    /**
1486     * Returns the specified DN as a Zend\Ldap\Node
1487     *
1488     * @param  string|Dn $dn
1489     * @return Node|null
1490     * @throws Exception\LdapException
1491     */
1492    public function getNode($dn)
1493    {
1494        return Node::fromLdap($dn, $this);
1495    }
1496
1497    /**
1498     * Returns the base node as a Zend\Ldap\Node
1499     *
1500     * @return Node
1501     * @throws Exception\LdapException
1502     */
1503    public function getBaseNode()
1504    {
1505        return $this->getNode($this->getBaseDn(), $this);
1506    }
1507
1508    /**
1509     * Returns the RootDse
1510     *
1511     * @return Node\RootDse
1512     * @throws Exception\LdapException
1513     */
1514    public function getRootDse()
1515    {
1516        if ($this->rootDse === null) {
1517            $this->rootDse = Node\RootDse::create($this);
1518        }
1519
1520        return $this->rootDse;
1521    }
1522
1523    /**
1524     * Returns the schema
1525     *
1526     * @return Node\Schema
1527     * @throws Exception\LdapException
1528     */
1529    public function getSchema()
1530    {
1531        if ($this->schema === null) {
1532            $this->schema = Node\Schema::create($this);
1533        }
1534
1535        return $this->schema;
1536    }
1537}
1538