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