1<?php 2/** 3 * Copyright 1999-2017 Horde LLC (http://www.horde.org/) 4 * 5 * See the enclosed file COPYING for license information (LGPL). If you did 6 * not receive this file, see http://www.horde.org/licenses/lgpl21. 7 * 8 * @author Jon Parise <jon@horde.org> 9 * @category Horde 10 * @license http://www.horde.org/licenses/lgpl21 LGPL-2.1 11 * @package Auth 12 */ 13 14/** 15 * The Horde_Auth_Ldap class provides an LDAP implementation of the Horde 16 * authentication system. 17 * 18 * 'preauthenticate' hook should return LDAP connection information in the 19 * 'ldap' credentials key. 20 * 21 * @author Jon Parise <jon@horde.org> 22 * @category Horde 23 * @copyright 1999-2017 Horde LLC 24 * @license http://www.horde.org/licenses/lgpl21 LGPL-2.1 25 * @package Auth 26 */ 27class Horde_Auth_Ldap extends Horde_Auth_Base 28{ 29 /** 30 * An array of capabilities, so that the driver can report which 31 * operations it supports and which it doesn't. 32 * 33 * @var array 34 */ 35 protected $_capabilities = array( 36 'add' => true, 37 'update' => true, 38 'resetpassword' => true, 39 'remove' => true, 40 'list' => true, 41 'authenticate' => true, 42 ); 43 44 /** 45 * LDAP object 46 * 47 * @var Horde_Ldap 48 */ 49 protected $_ldap; 50 51 /** 52 * Constructor. 53 * 54 * @param array $params Required parameters: 55 * <pre> 56 * 'basedn' - (string) [REQUIRED] The base DN for the LDAP server. 57 * 'filter' - (string) The LDAP formatted search filter to search for 58 * users. This setting overrides the 'objectclass' parameter. 59 * 'ldap' - (Horde_Ldap) [REQUIRED] Horde LDAP object. 60 * 'objectclass - (string|array): The objectclass filter used to search 61 * for users. Either a single or an array of objectclasses. 62 * 'uid' - (string) [REQUIRED] The username search key. 63 * </pre> 64 * 65 * @throws Horde_Auth_Exception 66 * @throws InvalidArgumentException 67 */ 68 public function __construct(array $params = array()) 69 { 70 foreach (array('basedn', 'ldap', 'uid') as $val) { 71 if (!isset($params[$val])) { 72 throw new InvalidArgumentException(__CLASS__ . ': Missing ' . $val . ' parameter.'); 73 } 74 } 75 76 if (!empty($params['ad'])) { 77 $this->_capabilities['resetpassword'] = false; 78 } 79 80 $this->_ldap = $params['ldap']; 81 unset($params['ldap']); 82 83 parent::__construct($params); 84 } 85 86 /** 87 * Checks for shadowLastChange and shadowMin/Max support and returns their 88 * values. We will also check for pwdLastSet if Active Directory is 89 * support is requested. For this check to succeed we need to be bound 90 * to the directory. 91 * 92 * @param string $dn The dn of the user. 93 * 94 * @return array Array with keys being "shadowlastchange", "shadowmin" 95 * "shadowmax", "shadowwarning" and containing their 96 * respective values or false for no support. 97 */ 98 protected function _lookupShadow($dn) 99 { 100 /* Init the return array. */ 101 $lookupshadow = array( 102 'shadowlastchange' => false, 103 'shadowmin' => false, 104 'shadowmax' => false, 105 'shadowwarning' => false 106 ); 107 108 /* According to LDAP standard, to read operational attributes, you 109 * must request them explicitly. Attributes involved in password 110 * expiration policy: 111 * pwdlastset: Active Directory 112 * shadow*: shadowUser schema 113 * passwordexpirationtime: Sun and Fedora Directory Server */ 114 try { 115 $result = $this->_ldap->search(null, '(objectClass=*)', array( 116 'attributes' => array( 117 'pwdlastset', 118 'shadowmax', 119 'shadowmin', 120 'shadowlastchange', 121 'shadowwarning', 122 'passwordexpirationtime' 123 ), 124 'scope' => 'base' 125 )); 126 } catch (Horde_Ldap_Exception $e) { 127 return $lookupshadow; 128 } 129 130 if (!$result) { 131 return $lookupshadow; 132 } 133 134 $info = reset($result); 135 136 // TODO: 'ad'? 137 if (!empty($this->_params['ad'])) { 138 if (isset($info['pwdlastset'][0])) { 139 /* Active Directory handles timestamps a bit differently. 140 * Convert the timestamp to a UNIX timestamp. */ 141 $lookupshadow['shadowlastchange'] = floor((($info['pwdlastset'][0] / 10000000) - 11644406783) / 86400) - 1; 142 143 /* Password expiry attributes are in a policy. We cannot 144 * read them so use the Horde config. */ 145 $lookupshadow['shadowwarning'] = $this->_params['warnage']; 146 $lookupshadow['shadowmin'] = $this->_params['minage']; 147 $lookupshadow['shadowmax'] = $this->_params['maxage']; 148 } 149 } elseif (isset($info['passwordexpirationtime'][0])) { 150 /* Sun/Fedora Directory Server uses a special attribute 151 * passwordexpirationtime. It has precedence over shadow* 152 * because it actually locks the expired password at the LDAP 153 * server level. The correct way to check expiration should 154 * be using LDAP controls, unfortunately PHP doesn't support 155 * controls on bind() responses. */ 156 $ldaptimepattern = "/([0-9]{4})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})Z/"; 157 if (preg_match($ldaptimepattern, $info['passwordexpirationtime'][0], $regs)) { 158 /* Sun/Fedora Directory Server return expiration time, not 159 * last change time. We emulate the behaviour taking it 160 * back to maxage. */ 161 $lookupshadow['shadowlastchange'] = floor(mktime($regs[4], $regs[5], $regs[6], $regs[2], $regs[3], $regs[1]) / 86400) - $this->_params['maxage']; 162 163 /* Password expiry attributes are in not accessible policy 164 * entry. */ 165 $lookupshadow['shadowwarning'] = $this->_params['warnage']; 166 $lookupshadow['shadowmin'] = $this->_params['minage']; 167 $lookupshadow['shadowmax'] = $this->_params['maxage']; 168 } elseif ($this->_logger) { 169 $this->_logger->log('Wrong time format: ' . $info['passwordexpirationtime'][0], 'ERR'); 170 } 171 } else { 172 if (isset($info['shadowmax'][0])) { 173 $lookupshadow['shadowmax'] = $info['shadowmax'][0]; 174 } 175 if (isset($info['shadowmin'][0])) { 176 $lookupshadow['shadowmin'] = $info['shadowmin'][0]; 177 } 178 if (isset($info['shadowlastchange'][0])) { 179 $lookupshadow['shadowlastchange'] = $info['shadowlastchange'][0]; 180 } 181 if (isset($info['shadowwarning'][0])) { 182 $lookupshadow['shadowwarning'] = $info['shadowwarning'][0]; 183 } 184 } 185 186 return $lookupshadow; 187 } 188 189 /** 190 * Find out if the given set of login credentials are valid. 191 * 192 * @param string $userId The userId to check. 193 * @param array $credentials An array of login credentials. 194 * 195 * @throws Horde_Auth_Exception 196 */ 197 protected function _authenticate($userId, $credentials) 198 { 199 if (!strlen($credentials['password'])) { 200 throw new Horde_Auth_Exception('', Horde_Auth::REASON_BADLOGIN); 201 } 202 203 /* Search for the user's full DN. */ 204 $this->_ldap->bind(); 205 try { 206 $dn = $this->_ldap->findUserDN($userId); 207 } catch (Horde_Exception_NotFound $e) { 208 throw new Horde_Auth_Exception('', Horde_Auth::REASON_BADLOGIN); 209 } catch (Horde_Exception_Ldap $e) { 210 throw new Horde_Auth_Exception($e->getMessage(), Horde_Auth::REASON_MESSAGE); 211 } 212 213 /* Attempt to bind to the LDAP server as the user. */ 214 try { 215 $this->_ldap->bind($dn, $credentials['password']); 216 // Be sure we rebind as the configured user. 217 $this->_ldap->bind(); 218 } catch (Horde_Ldap_Exception $e) { 219 // Be sure we rebind as the configured user. 220 $this->_ldap->bind(); 221 if (Horde_Ldap::errorName($e->getCode() == 'LDAP_INVALID_CREDENTIALS')) { 222 throw new Horde_Auth_Exception('', Horde_Auth::REASON_BADLOGIN); 223 } 224 throw new Horde_Auth_Exception($e->getMessage(), Horde_Auth::REASON_MESSAGE); 225 } 226 227 if ($this->_params['password_expiration'] == 'yes') { 228 $shadow = $this->_lookupShadow($dn); 229 if ($shadow['shadowmax'] && $shadow['shadowlastchange'] && 230 $shadow['shadowwarning']) { 231 $today = floor(time() / 86400); 232 $toexpire = $shadow['shadowlastchange'] + 233 $shadow['shadowmax'] - $today; 234 235 $warnday = $shadow['shadowlastchange'] + $shadow['shadowmax'] - $shadow['shadowwarning']; 236 if ($today >= $warnday) { 237 $this->setCredential('expire', $toexpire); 238 } 239 240 if ($toexpire == 0) { 241 $this->setCredential('change', true); 242 } elseif ($toexpire < 0) { 243 throw new Horde_Auth_Exception('', Horde_Auth::REASON_EXPIRED); 244 } 245 } 246 } 247 } 248 249 /** 250 * Add a set of authentication credentials. 251 * 252 * @param string $userId The userId to add. 253 * @param array $credentials The credentials to be set. 254 * 255 * @throws Horde_Auth_Exception 256 */ 257 public function addUser($userId, $credentials) 258 { 259 if (!empty($this->_params['ad'])) { 260 throw new Horde_Auth_Exception(__CLASS__ . ': Adding users is not supported for Active Directory.'); 261 } 262 263 if (isset($credentials['ldap'])) { 264 $entry = $credentials['ldap']; 265 $dn = $entry['dn']; 266 267 /* Remove the dn entry from the array. */ 268 unset($entry['dn']); 269 } else { 270 /* Try this simple default and hope it works. */ 271 $dn = $this->_params['uid'] . '=' . $userId . ',' 272 . $this->_params['basedn']; 273 $entry['cn'] = $userId; 274 $entry['sn'] = $userId; 275 $entry[$this->_params['uid']] = $userId; 276 $entry['objectclass'] = array_merge( 277 array('top'), 278 $this->_params['newuser_objectclass']); 279 $entry['userPassword'] = Horde_Auth::getCryptedPassword( 280 $credentials['password'], '', 281 $this->_params['encryption'], 282 'true'); 283 284 if ($this->_params['password_expiration'] == 'yes') { 285 $entry['shadowMin'] = $this->_params['minage']; 286 $entry['shadowMax'] = $this->_params['maxage']; 287 $entry['shadowWarning'] = $this->_params['warnage']; 288 $entry['shadowLastChange'] = floor(time() / 86400); 289 } 290 } 291 292 try { 293 $this->_ldap->add(Horde_Ldap_Entry::createFresh($dn, $entry)); 294 } catch (Horde_Ldap_Exception $e) { 295 throw new Horde_Auth_Exception(sprintf(__CLASS__ . ': Unable to add user "%s". This is what the server said: ', $userId) . $e->getMessage()); 296 } 297 } 298 299 /** 300 * Remove a set of authentication credentials. 301 * 302 * @param string $userId The userId to add. 303 * @param string $dn TODO 304 * 305 * @throws Horde_Auth_Exception 306 */ 307 public function removeUser($userId, $dn = null) 308 { 309 if (!empty($this->_params['ad'])) { 310 throw new Horde_Auth_Exception(__CLASS__ . ': Removing users is not supported for Active Directory'); 311 } 312 313 if (is_null($dn)) { 314 /* Search for the user's full DN. */ 315 try { 316 $dn = $this->_ldap->findUserDN($userId); 317 } catch (Horde_Exception_Ldap $e) { 318 throw new Horde_Auth_Exception($e); 319 } 320 } 321 322 try { 323 $this->_ldap->delete($dn); 324 } catch (Horde_Ldap_Exception $e) { 325 throw new Horde_Auth_Exception(sprintf(__CLASS__ . ': Unable to remove user "%s"', $userId)); 326 } 327 } 328 329 /** 330 * Update a set of authentication credentials. 331 * 332 * @todo Clean this up for Horde 5. 333 * 334 * @param string $oldID The old userId. 335 * @param string $newID The new userId. 336 * @param array $credentials The new credentials. 337 * @param string $olddn The old user DN. 338 * @param string $newdn The new user DN. 339 * 340 * @throws Horde_Auth_Exception 341 */ 342 public function updateUser($oldID, $newID, $credentials, $olddn = null, 343 $newdn = null) 344 { 345 if (!empty($this->_params['ad'])) { 346 throw new Horde_Auth_Exception(__CLASS__ . ': Updating users is not supported for Active Directory.'); 347 } 348 349 if (is_null($olddn)) { 350 /* Search for the user's full DN. */ 351 try { 352 $dn = $this->_ldap->findUserDN($oldID); 353 } catch (Horde_Exception_Ldap $e) { 354 throw new Horde_Auth_Exception($e); 355 } 356 357 $olddn = $dn; 358 $newdn = preg_replace('/uid=.*?,/', 'uid=' . $newID . ',', $dn, 1); 359 $shadow = $this->_lookupShadow($dn); 360 361 /* If shadowmin hasn't yet expired only change when we are 362 administrator */ 363 if ($shadow['shadowlastchange'] && 364 $shadow['shadowmin'] && 365 ($shadow['shadowlastchange'] + $shadow['shadowmin'] > (time() / 86400))) { 366 throw new Horde_Auth_Exception('Minimum password age has not yet expired'); 367 } 368 369 /* Set the lastchange field */ 370 if ($shadow['shadowlastchange']) { 371 $entry['shadowlastchange'] = floor(time() / 86400); 372 } 373 374 /* Encrypt the new password */ 375 $entry['userpassword'] = Horde_Auth::getCryptedPassword( 376 $credentials['password'], '', 377 $this->_params['encryption'], 378 'true'); 379 } else { 380 $entry = $credentials; 381 unset($entry['dn']); 382 } 383 384 try { 385 if ($oldID != $newID) { 386 $this->_ldap->move($olddn, $newdn); 387 $this->_ldap->modify($newdn, array('replace' => $entry)); 388 } else { 389 $this->_ldap->modify($olddn, array('replace' => $entry)); 390 } 391 } catch (Horde_Ldap_Exception $e) { 392 throw new Horde_Auth_Exception(sprintf(__CLASS__ . ': Unable to update user "%s"', $newID)); 393 } 394 } 395 396 /** 397 * Reset a user's password. Used for example when the user does not 398 * remember the existing password. 399 * 400 * @param string $userId The user id for which to reset the password. 401 * 402 * @return string The new password on success. 403 * @throws Horde_Auth_Exception 404 */ 405 public function resetPassword($userId) 406 { 407 if (!empty($this->_params['ad'])) { 408 throw new Horde_Auth_Exception(__CLASS__ . ': Updating users is not supported for Active Directory.'); 409 } 410 411 /* Search for the user's full DN. */ 412 try { 413 $dn = $this->_ldap->findUserDN($userId); 414 } catch (Horde_Exception_Ldap $e) { 415 throw new Horde_Auth_Exception($e); 416 } 417 418 /* Get a new random password. */ 419 $password = Horde_Auth::genRandomPassword(); 420 421 /* Encrypt the new password */ 422 $entry = array( 423 'userpassword' => Horde_Auth::getCryptedPassword($password, 424 '', 425 $this->_params['encryption'], 426 'true')); 427 428 /* Set the lastchange field */ 429 $shadow = $this->_lookupShadow($dn); 430 if ($shadow['shadowlastchange']) { 431 $entry['shadowlastchange'] = floor(time() / 86400); 432 } 433 434 /* Update user entry. */ 435 try { 436 $this->_ldap->modify($dn, array('replace' => $entry)); 437 } catch (Horde_Ldap_Exception $e) { 438 throw new Horde_Auth_Exception($e); 439 } 440 441 return $password; 442 } 443 444 /** 445 * Lists all users in the system. 446 * 447 * @param boolean $sort Sort the users? 448 * 449 * @return array The array of userIds. 450 * @throws Horde_Auth_Exception 451 */ 452 public function listUsers($sort = false) 453 { 454 $params = array( 455 'attributes' => array($this->_params['uid']), 456 'scope' => $this->_params['scope'], 457 'sizelimit' => isset($this->_params['sizelimit']) ? $this->_params['sizelimit'] : 0 458 ); 459 460 /* Add a sizelimit, if specified. Default is 0, which means no limit. 461 * Note: You cannot override a server-side limit with this. */ 462 $userlist = array(); 463 try { 464 $search = $this->_ldap->search( 465 $this->_params['basedn'], 466 Horde_Ldap_Filter::build(array('filter' => $this->_params['filter'])), 467 $params); 468 $uid = Horde_String::lower($this->_params['uid']); 469 foreach ($search as $val) { 470 $userlist[] = $val->getValue($uid, 'single'); 471 } 472 } catch (Horde_Ldap_Exception $e) { 473 } 474 475 return $this->_sort($userlist, $sort); 476 } 477 478 /** 479 * Checks if $userId exists in the LDAP backend system. 480 * 481 * @author Marco Ferrante, University of Genova (I) 482 * 483 * @param string $userId User ID for which to check 484 * 485 * @return boolean Whether or not $userId already exists. 486 */ 487 public function exists($userId) 488 { 489 $params = array( 490 'scope' => $this->_params['scope'] 491 ); 492 493 try { 494 $uidfilter = Horde_Ldap_Filter::create($this->_params['uid'], 'equals', $userId); 495 $classfilter = Horde_Ldap_Filter::build(array('filter' => $this->_params['filter'])); 496 497 $search = $this->_ldap->search( 498 $this->_params['basedn'], 499 Horde_Ldap_Filter::combine('and', array($uidfilter, $classfilter)), 500 $params); 501 if ($search->count() < 1) { 502 return false; 503 } 504 if ($search->count() > 1 && $this->_logger) { 505 $this->_logger->log('Multiple LDAP entries with user identifier ' . $userId, 'WARN'); 506 } 507 return true; 508 } catch (Horde_Ldap_Exception $e) { 509 if ($this->_logger) { 510 $this->_logger->log('Error searching LDAP user: ' . $e->getMessage(), 'ERR'); 511 } 512 return false; 513 } 514 } 515} 516