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\Authentication\Adapter;
11
12use stdClass;
13use Zend\Authentication\Result as AuthenticationResult;
14use Zend\Ldap as ZendLdap;
15use Zend\Ldap\Exception\LdapException;
16
17class Ldap extends AbstractAdapter
18{
19    /**
20     * The Zend\Ldap\Ldap context.
21     *
22     * @var ZendLdap\Ldap
23     */
24    protected $ldap = null;
25
26    /**
27     * The array of arrays of Zend\Ldap\Ldap options passed to the constructor.
28     *
29     * @var array
30     */
31    protected $options = null;
32
33    /**
34     * The DN of the authenticated account. Used to retrieve the account entry on request.
35     *
36     * @var string
37     */
38    protected $authenticatedDn = null;
39
40    /**
41     * Constructor
42     *
43     * @param  array  $options    An array of arrays of Zend\Ldap\Ldap options
44     * @param  string $identity   The username of the account being authenticated
45     * @param  string $credential The password of the account being authenticated
46     */
47    public function __construct(array $options = array(), $identity = null, $credential = null)
48    {
49        $this->setOptions($options);
50        if ($identity !== null) {
51            $this->setIdentity($identity);
52        }
53        if ($credential !== null) {
54            $this->setCredential($credential);
55        }
56    }
57
58    /**
59     * Returns the array of arrays of Zend\Ldap\Ldap options of this adapter.
60     *
61     * @return array|null
62     */
63    public function getOptions()
64    {
65        return $this->options;
66    }
67
68    /**
69     * Sets the array of arrays of Zend\Ldap\Ldap options to be used by
70     * this adapter.
71     *
72     * @param  array $options The array of arrays of Zend\Ldap\Ldap options
73     * @return Ldap Provides a fluent interface
74     */
75    public function setOptions($options)
76    {
77        $this->options = is_array($options) ? $options : array();
78        if (array_key_exists('identity', $this->options)) {
79            $this->options['username'] = $this->options['identity'];
80        }
81        if (array_key_exists('credential', $this->options)) {
82            $this->options['password'] = $this->options['credential'];
83        }
84        return $this;
85    }
86
87    /**
88     * Returns the username of the account being authenticated, or
89     * NULL if none is set.
90     *
91     * @return string|null
92     */
93    public function getUsername()
94    {
95        return $this->getIdentity();
96    }
97
98    /**
99     * Sets the username for binding
100     *
101     * @param  string $username The username for binding
102     * @return Ldap Provides a fluent interface
103     */
104    public function setUsername($username)
105    {
106        return $this->setIdentity($username);
107    }
108
109    /**
110     * Returns the password of the account being authenticated, or
111     * NULL if none is set.
112     *
113     * @return string|null
114     */
115    public function getPassword()
116    {
117        return $this->getCredential();
118    }
119
120    /**
121     * Sets the password for the account
122     *
123     * @param  string $password The password of the account being authenticated
124     * @return Ldap Provides a fluent interface
125     */
126    public function setPassword($password)
127    {
128        return $this->setCredential($password);
129    }
130
131    /**
132     * Returns the LDAP Object
133     *
134     * @return ZendLdap\Ldap The Zend\Ldap\Ldap object used to authenticate the credentials
135     */
136    public function getLdap()
137    {
138        if ($this->ldap === null) {
139            $this->ldap = new ZendLdap\Ldap();
140        }
141
142        return $this->ldap;
143    }
144
145    /**
146     * Set an Ldap connection
147     *
148     * @param  ZendLdap\Ldap $ldap An existing Ldap object
149     * @return Ldap Provides a fluent interface
150     */
151    public function setLdap(ZendLdap\Ldap $ldap)
152    {
153        $this->ldap = $ldap;
154
155        $this->setOptions(array($ldap->getOptions()));
156
157        return $this;
158    }
159
160    /**
161     * Returns a domain name for the current LDAP options. This is used
162     * for skipping redundant operations (e.g. authentications).
163     *
164     * @return string
165     */
166    protected function getAuthorityName()
167    {
168        $options = $this->getLdap()->getOptions();
169        $name = $options['accountDomainName'];
170        if (!$name) {
171            $name = $options['accountDomainNameShort'];
172        }
173
174        return $name ? $name : '';
175    }
176
177    /**
178     * Authenticate the user
179     *
180     * @return AuthenticationResult
181     * @throws Exception\ExceptionInterface
182     */
183    public function authenticate()
184    {
185        $messages = array();
186        $messages[0] = ''; // reserved
187        $messages[1] = ''; // reserved
188
189        $username = $this->identity;
190        $password = $this->credential;
191
192        if (!$username) {
193            $code = AuthenticationResult::FAILURE_IDENTITY_NOT_FOUND;
194            $messages[0] = 'A username is required';
195            return new AuthenticationResult($code, '', $messages);
196        }
197        if (!$password) {
198            /* A password is required because some servers will
199             * treat an empty password as an anonymous bind.
200             */
201            $code = AuthenticationResult::FAILURE_CREDENTIAL_INVALID;
202            $messages[0] = 'A password is required';
203            return new AuthenticationResult($code, '', $messages);
204        }
205
206        $ldap = $this->getLdap();
207
208        $code = AuthenticationResult::FAILURE;
209        $messages[0] = "Authority not found: $username";
210        $failedAuthorities = array();
211
212        /* Iterate through each server and try to authenticate the supplied
213         * credentials against it.
214         */
215        foreach ($this->options as $options) {
216            if (!is_array($options)) {
217                throw new Exception\InvalidArgumentException('Adapter options array not an array');
218            }
219            $adapterOptions = $this->prepareOptions($ldap, $options);
220            $dname = '';
221
222            try {
223                if ($messages[1]) {
224                    $messages[] = $messages[1];
225                }
226
227                $messages[1] = '';
228                $messages[] = $this->optionsToString($options);
229
230                $dname = $this->getAuthorityName();
231                if (isset($failedAuthorities[$dname])) {
232                    /* If multiple sets of server options for the same domain
233                     * are supplied, we want to skip redundant authentications
234                     * where the identity or credentials where found to be
235                     * invalid with another server for the same domain. The
236                     * $failedAuthorities array tracks this condition (and also
237                     * serves to supply the original error message).
238                     * This fixes issue ZF-4093.
239                     */
240                    $messages[1] = $failedAuthorities[$dname];
241                    $messages[] = "Skipping previously failed authority: $dname";
242                    continue;
243                }
244
245                $canonicalName = $ldap->getCanonicalAccountName($username);
246                $ldap->bind($canonicalName, $password);
247                /*
248                 * Fixes problem when authenticated user is not allowed to retrieve
249                 * group-membership information or own account.
250                 * This requires that the user specified with "username" and optionally
251                 * "password" in the Zend\Ldap\Ldap options is able to retrieve the required
252                 * information.
253                 */
254                $requireRebind = false;
255                if (isset($options['username'])) {
256                    $ldap->bind();
257                    $requireRebind = true;
258                }
259                $dn = $ldap->getCanonicalAccountName($canonicalName, ZendLdap\Ldap::ACCTNAME_FORM_DN);
260
261                $groupResult = $this->checkGroupMembership($ldap, $canonicalName, $dn, $adapterOptions);
262                if ($groupResult === true) {
263                    $this->authenticatedDn = $dn;
264                    $messages[0] = '';
265                    $messages[1] = '';
266                    $messages[] = "$canonicalName authentication successful";
267                    if ($requireRebind === true) {
268                        // rebinding with authenticated user
269                        $ldap->bind($dn, $password);
270                    }
271                    return new AuthenticationResult(AuthenticationResult::SUCCESS, $canonicalName, $messages);
272                } else {
273                    $messages[0] = 'Account is not a member of the specified group';
274                    $messages[1] = $groupResult;
275                    $failedAuthorities[$dname] = $groupResult;
276                }
277            } catch (LdapException $zle) {
278                /* LDAP based authentication is notoriously difficult to diagnose. Therefore
279                 * we bend over backwards to capture and record every possible bit of
280                 * information when something goes wrong.
281                 */
282
283                $err = $zle->getCode();
284
285                if ($err == LdapException::LDAP_X_DOMAIN_MISMATCH) {
286                    /* This error indicates that the domain supplied in the
287                     * username did not match the domains in the server options
288                     * and therefore we should just skip to the next set of
289                     * server options.
290                     */
291                    continue;
292                } elseif ($err == LdapException::LDAP_NO_SUCH_OBJECT) {
293                    $code = AuthenticationResult::FAILURE_IDENTITY_NOT_FOUND;
294                    $messages[0] = "Account not found: $username";
295                    $failedAuthorities[$dname] = $zle->getMessage();
296                } elseif ($err == LdapException::LDAP_INVALID_CREDENTIALS) {
297                    $code = AuthenticationResult::FAILURE_CREDENTIAL_INVALID;
298                    $messages[0] = 'Invalid credentials';
299                    $failedAuthorities[$dname] = $zle->getMessage();
300                } else {
301                    $line = $zle->getLine();
302                    $messages[] = $zle->getFile() . "($line): " . $zle->getMessage();
303                    $messages[] = preg_replace(
304                        '/\b'.preg_quote(substr($password, 0, 15), '/').'\b/',
305                        '*****',
306                        $zle->getTraceAsString()
307                    );
308                    $messages[0] = 'An unexpected failure occurred';
309                }
310                $messages[1] = $zle->getMessage();
311            }
312        }
313
314        $msg = isset($messages[1]) ? $messages[1] : $messages[0];
315        $messages[] = "$username authentication failed: $msg";
316
317        return new AuthenticationResult($code, $username, $messages);
318    }
319
320    /**
321     * Sets the LDAP specific options on the Zend\Ldap\Ldap instance
322     *
323     * @param  ZendLdap\Ldap $ldap
324     * @param  array         $options
325     * @return array of auth-adapter specific options
326     */
327    protected function prepareOptions(ZendLdap\Ldap $ldap, array $options)
328    {
329        $adapterOptions = array(
330            'group'       => null,
331            'groupDn'     => $ldap->getBaseDn(),
332            'groupScope'  => ZendLdap\Ldap::SEARCH_SCOPE_SUB,
333            'groupAttr'   => 'cn',
334            'groupFilter' => 'objectClass=groupOfUniqueNames',
335            'memberAttr'  => 'uniqueMember',
336            'memberIsDn'  => true
337        );
338        foreach ($adapterOptions as $key => $value) {
339            if (array_key_exists($key, $options)) {
340                $value = $options[$key];
341                unset($options[$key]);
342                switch ($key) {
343                    case 'groupScope':
344                        $value = (int) $value;
345                        if (in_array(
346                            $value,
347                            array(
348                                ZendLdap\Ldap::SEARCH_SCOPE_BASE,
349                                ZendLdap\Ldap::SEARCH_SCOPE_ONE,
350                                ZendLdap\Ldap::SEARCH_SCOPE_SUB,
351                            ),
352                            true
353                        )) {
354                            $adapterOptions[$key] = $value;
355                        }
356                        break;
357                    case 'memberIsDn':
358                        $adapterOptions[$key] = ($value === true ||
359                                $value === '1' || strcasecmp($value, 'true') == 0);
360                        break;
361                    default:
362                        $adapterOptions[$key] = trim($value);
363                        break;
364                }
365            }
366        }
367        $ldap->setOptions($options);
368        return $adapterOptions;
369    }
370
371    /**
372     * Checks the group membership of the bound user
373     *
374     * @param  ZendLdap\Ldap $ldap
375     * @param  string        $canonicalName
376     * @param  string        $dn
377     * @param  array         $adapterOptions
378     * @return string|true
379     */
380    protected function checkGroupMembership(ZendLdap\Ldap $ldap, $canonicalName, $dn, array $adapterOptions)
381    {
382        if ($adapterOptions['group'] === null) {
383            return true;
384        }
385
386        if ($adapterOptions['memberIsDn'] === false) {
387            $user = $canonicalName;
388        } else {
389            $user = $dn;
390        }
391
392        $groupName   = ZendLdap\Filter::equals($adapterOptions['groupAttr'], $adapterOptions['group']);
393        $membership  = ZendLdap\Filter::equals($adapterOptions['memberAttr'], $user);
394        $group       = ZendLdap\Filter::andFilter($groupName, $membership);
395        $groupFilter = $adapterOptions['groupFilter'];
396        if (!empty($groupFilter)) {
397            $group = $group->addAnd($groupFilter);
398        }
399
400        $result = $ldap->count($group, $adapterOptions['groupDn'], $adapterOptions['groupScope']);
401
402        if ($result === 1) {
403            return true;
404        }
405
406        return 'Failed to verify group membership with ' . $group->toString();
407    }
408
409    /**
410     * getAccountObject() - Returns the result entry as a stdClass object
411     *
412     * This resembles the feature {@see Zend\Authentication\Adapter\DbTable::getResultRowObject()}.
413     * Closes ZF-6813
414     *
415     * @param  array $returnAttribs
416     * @param  array $omitAttribs
417     * @return stdClass|bool
418     */
419    public function getAccountObject(array $returnAttribs = array(), array $omitAttribs = array())
420    {
421        if (!$this->authenticatedDn) {
422            return false;
423        }
424
425        $returnObject = new stdClass();
426
427        $returnAttribs = array_map('strtolower', $returnAttribs);
428        $omitAttribs   = array_map('strtolower', $omitAttribs);
429        $returnAttribs = array_diff($returnAttribs, $omitAttribs);
430
431        $entry = $this->getLdap()->getEntry($this->authenticatedDn, $returnAttribs, true);
432        foreach ($entry as $attr => $value) {
433            if (in_array($attr, $omitAttribs)) {
434                // skip attributes marked to be omitted
435                continue;
436            }
437            if (is_array($value)) {
438                $returnObject->$attr = (count($value) > 1) ? $value : $value[0];
439            } else {
440                $returnObject->$attr = $value;
441            }
442        }
443        return $returnObject;
444    }
445
446    /**
447     * Converts options to string
448     *
449     * @param  array $options
450     * @return string
451     */
452    private function optionsToString(array $options)
453    {
454        $str = '';
455        foreach ($options as $key => $val) {
456            if ($key === 'password' || $key === 'credential') {
457                $val = '*****';
458            }
459            if ($str) {
460                $str .= ',';
461            }
462            $str .= $key . '=' . $val;
463        }
464        return $str;
465    }
466}
467