1<?php 2 3/** 4 * LDAP authentication backend 5 * 6 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 7 * @author Andreas Gohr <andi@splitbrain.org> 8 * @author Chris Smith <chris@jalakaic.co.uk> 9 * @author Jan Schumann <js@schumann-it.com> 10 */ 11class auth_plugin_authldap extends DokuWiki_Auth_Plugin 12{ 13 /* @var resource $con holds the LDAP connection */ 14 protected $con = null; 15 16 /* @var int $bound What type of connection does already exist? */ 17 protected $bound = 0; // 0: anonymous, 1: user, 2: superuser 18 19 /* @var array $users User data cache */ 20 protected $users = null; 21 22 /* @var array $pattern User filter pattern */ 23 protected $pattern = null; 24 25 /** 26 * Constructor 27 */ 28 public function __construct() 29 { 30 parent::__construct(); 31 32 // ldap extension is needed 33 if (!function_exists('ldap_connect')) { 34 $this->debug("LDAP err: PHP LDAP extension not found.", -1, __LINE__, __FILE__); 35 $this->success = false; 36 return; 37 } 38 39 // Add the capabilities to change the password 40 $this->cando['modPass'] = $this->getConf('modPass'); 41 } 42 43 /** 44 * Check user+password 45 * 46 * Checks if the given user exists and the given 47 * plaintext password is correct by trying to bind 48 * to the LDAP server 49 * 50 * @param string $user 51 * @param string $pass 52 * @return bool 53 * @author Andreas Gohr <andi@splitbrain.org> 54 */ 55 public function checkPass($user, $pass) 56 { 57 // reject empty password 58 if (empty($pass)) return false; 59 if (!$this->openLDAP()) return false; 60 61 // indirect user bind 62 if ($this->getConf('binddn') && $this->getConf('bindpw')) { 63 // use superuser credentials 64 if (!@ldap_bind($this->con, $this->getConf('binddn'), conf_decodeString($this->getConf('bindpw')))) { 65 $this->debug('LDAP bind as superuser: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 66 return false; 67 } 68 $this->bound = 2; 69 } elseif ($this->getConf('binddn') && 70 $this->getConf('usertree') && 71 $this->getConf('userfilter') 72 ) { 73 // special bind string 74 $dn = $this->makeFilter( 75 $this->getConf('binddn'), 76 array('user' => $user, 'server' => $this->getConf('server')) 77 ); 78 } elseif (strpos($this->getConf('usertree'), '%{user}')) { 79 // direct user bind 80 $dn = $this->makeFilter( 81 $this->getConf('usertree'), 82 array('user' => $user, 'server' => $this->getConf('server')) 83 ); 84 } else { 85 // Anonymous bind 86 if (!@ldap_bind($this->con)) { 87 msg("LDAP: can not bind anonymously", -1); 88 $this->debug('LDAP anonymous bind: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 89 return false; 90 } 91 } 92 93 // Try to bind to with the dn if we have one. 94 if (!empty($dn)) { 95 // User/Password bind 96 if (!@ldap_bind($this->con, $dn, $pass)) { 97 $this->debug("LDAP: bind with $dn failed", -1, __LINE__, __FILE__); 98 $this->debug('LDAP user dn bind: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 99 return false; 100 } 101 $this->bound = 1; 102 return true; 103 } else { 104 // See if we can find the user 105 $info = $this->fetchUserData($user, true); 106 if (empty($info['dn'])) { 107 return false; 108 } else { 109 $dn = $info['dn']; 110 } 111 112 // Try to bind with the dn provided 113 if (!@ldap_bind($this->con, $dn, $pass)) { 114 $this->debug("LDAP: bind with $dn failed", -1, __LINE__, __FILE__); 115 $this->debug('LDAP user bind: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 116 return false; 117 } 118 $this->bound = 1; 119 return true; 120 } 121 } 122 123 /** 124 * Return user info 125 * 126 * Returns info about the given user needs to contain 127 * at least these fields: 128 * 129 * name string full name of the user 130 * mail string email addres of the user 131 * grps array list of groups the user is in 132 * 133 * This LDAP specific function returns the following 134 * addional fields: 135 * 136 * dn string distinguished name (DN) 137 * uid string Posix User ID 138 * inbind bool for internal use - avoid loop in binding 139 * 140 * @param string $user 141 * @param bool $requireGroups (optional) - ignored, groups are always supplied by this plugin 142 * @return array containing user data or false 143 * @author <evaldas.auryla@pheur.org> 144 * @author Stephane Chazelas <stephane.chazelas@emerson.com> 145 * @author Steffen Schoch <schoch@dsb.net> 146 * 147 * @author Andreas Gohr <andi@splitbrain.org> 148 * @author Trouble 149 * @author Dan Allen <dan.j.allen@gmail.com> 150 */ 151 public function getUserData($user, $requireGroups = true) 152 { 153 return $this->fetchUserData($user); 154 } 155 156 /** 157 * @param string $user 158 * @param bool $inbind authldap specific, true if in bind phase 159 * @return array containing user data or false 160 */ 161 protected function fetchUserData($user, $inbind = false) 162 { 163 global $conf; 164 if (!$this->openLDAP()) return array(); 165 166 // force superuser bind if wanted and not bound as superuser yet 167 if ($this->getConf('binddn') && $this->getConf('bindpw') && $this->bound < 2) { 168 // use superuser credentials 169 if (!@ldap_bind($this->con, $this->getConf('binddn'), conf_decodeString($this->getConf('bindpw')))) { 170 $this->debug('LDAP bind as superuser: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 171 return array(); 172 } 173 $this->bound = 2; 174 } elseif ($this->bound == 0 && !$inbind) { 175 // in some cases getUserData is called outside the authentication workflow 176 // eg. for sending email notification on subscribed pages. This data might not 177 // be accessible anonymously, so we try to rebind the current user here 178 list($loginuser, $loginsticky, $loginpass) = auth_getCookie(); 179 if ($loginuser && $loginpass) { 180 $loginpass = auth_decrypt($loginpass, auth_cookiesalt(!$loginsticky, true)); 181 $this->checkPass($loginuser, $loginpass); 182 } 183 } 184 185 $info = array(); 186 $info['user'] = $user; 187 $this->debug('LDAP user to find: ' . hsc($info['user']), 0, __LINE__, __FILE__); 188 189 $info['server'] = $this->getConf('server'); 190 $this->debug('LDAP Server: ' . hsc($info['server']), 0, __LINE__, __FILE__); 191 192 //get info for given user 193 $base = $this->makeFilter($this->getConf('usertree'), $info); 194 if ($this->getConf('userfilter')) { 195 $filter = $this->makeFilter($this->getConf('userfilter'), $info); 196 } else { 197 $filter = "(ObjectClass=*)"; 198 } 199 200 $this->debug('LDAP Filter: ' . hsc($filter), 0, __LINE__, __FILE__); 201 202 $this->debug('LDAP user search: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 203 $this->debug('LDAP search at: ' . hsc($base . ' ' . $filter), 0, __LINE__, __FILE__); 204 $sr = $this->ldapSearch($this->con, $base, $filter, $this->getConf('userscope'), $this->getConf('attributes')); 205 206 207 $result = @ldap_get_entries($this->con, $sr); 208 209 // if result is not an array 210 if (!is_array($result)) { 211 // no objects found 212 $this->debug('LDAP search returned non-array result: ' . hsc(print($result)), -1, __LINE__, __FILE__); 213 return array(); 214 } 215 216 // Don't accept more or less than one response 217 if ($result['count'] != 1) { 218 $this->debug( 219 'LDAP search returned ' . hsc($result['count']) . ' results while it should return 1!', 220 -1, 221 __LINE__, 222 __FILE__ 223 ); 224 //for($i = 0; $i < $result["count"]; $i++) { 225 //$this->_debug('result: '.hsc(print_r($result[$i])), 0, __LINE__, __FILE__); 226 //} 227 return array(); 228 } 229 230 $this->debug('LDAP search found single result !', 0, __LINE__, __FILE__); 231 232 $user_result = $result[0]; 233 ldap_free_result($sr); 234 235 // general user info 236 $info['dn'] = $user_result['dn']; 237 $info['gid'] = $user_result['gidnumber'][0]; 238 $info['mail'] = $user_result['mail'][0]; 239 $info['name'] = $user_result['cn'][0]; 240 $info['grps'] = array(); 241 242 // overwrite if other attribs are specified. 243 if (is_array($this->getConf('mapping'))) { 244 foreach ($this->getConf('mapping') as $localkey => $key) { 245 if (is_array($key)) { 246 // use regexp to clean up user_result 247 // $key = array($key=>$regexp), only handles the first key-value 248 $regexp = current($key); 249 $key = key($key); 250 if ($user_result[$key]) foreach ($user_result[$key] as $grpkey => $grp) { 251 if ($grpkey !== 'count' && preg_match($regexp, $grp, $match)) { 252 if ($localkey == 'grps') { 253 $info[$localkey][] = $match[1]; 254 } else { 255 $info[$localkey] = $match[1]; 256 } 257 } 258 } 259 } else { 260 $info[$localkey] = $user_result[$key][0]; 261 } 262 } 263 } 264 $user_result = array_merge($info, $user_result); 265 266 //get groups for given user if grouptree is given 267 if ($this->getConf('grouptree') || $this->getConf('groupfilter')) { 268 $base = $this->makeFilter($this->getConf('grouptree'), $user_result); 269 $filter = $this->makeFilter($this->getConf('groupfilter'), $user_result); 270 $sr = $this->ldapSearch( 271 $this->con, 272 $base, 273 $filter, 274 $this->getConf('groupscope'), 275 array($this->getConf('groupkey')) 276 ); 277 $this->debug('LDAP group search: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 278 $this->debug('LDAP search at: ' . hsc($base . ' ' . $filter), 0, __LINE__, __FILE__); 279 280 if (!$sr) { 281 msg("LDAP: Reading group memberships failed", -1); 282 return array(); 283 } 284 $result = ldap_get_entries($this->con, $sr); 285 ldap_free_result($sr); 286 287 if (is_array($result)) foreach ($result as $grp) { 288 if (!empty($grp[$this->getConf('groupkey')])) { 289 $group = $grp[$this->getConf('groupkey')]; 290 if (is_array($group)) { 291 $group = $group[0]; 292 } else { 293 $this->debug('groupkey did not return a detailled result', 0, __LINE__, __FILE__); 294 } 295 if ($group === '') continue; 296 297 $this->debug('LDAP usergroup: ' . hsc($group), 0, __LINE__, __FILE__); 298 $info['grps'][] = $group; 299 } 300 } 301 } 302 303 // always add the default group to the list of groups 304 if (!$info['grps'] or !in_array($conf['defaultgroup'], $info['grps'])) { 305 $info['grps'][] = $conf['defaultgroup']; 306 } 307 return $info; 308 } 309 310 /** 311 * Definition of the function modifyUser in order to modify the password 312 * 313 * @param string $user nick of the user to be changed 314 * @param array $changes array of field/value pairs to be changed (password will be clear text) 315 * @return bool true on success, false on error 316 */ 317 public function modifyUser($user, $changes) 318 { 319 320 // open the connection to the ldap 321 if (!$this->openLDAP()) { 322 $this->debug('LDAP cannot connect: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 323 return false; 324 } 325 326 // find the information about the user, in particular the "dn" 327 $info = $this->getUserData($user, true); 328 if (empty($info['dn'])) { 329 $this->debug('LDAP cannot find your user dn', 0, __LINE__, __FILE__); 330 return false; 331 } 332 $dn = $info['dn']; 333 334 // find the old password of the user 335 list($loginuser, $loginsticky, $loginpass) = auth_getCookie(); 336 if ($loginuser !== null) { // the user is currently logged in 337 $secret = auth_cookiesalt(!$loginsticky, true); 338 $pass = auth_decrypt($loginpass, $secret); 339 340 // bind with the ldap 341 if (!@ldap_bind($this->con, $dn, $pass)) { 342 $this->debug( 343 'LDAP user bind failed: ' . hsc($dn) . ': ' . hsc(ldap_error($this->con)), 344 0, 345 __LINE__, 346 __FILE__ 347 ); 348 return false; 349 } 350 } elseif ($this->getConf('binddn') && $this->getConf('bindpw')) { 351 // we are changing the password on behalf of the user (eg: forgotten password) 352 // bind with the superuser ldap 353 if (!@ldap_bind($this->con, $this->getConf('binddn'), conf_decodeString($this->getConf('bindpw')))) { 354 $this->debug('LDAP bind as superuser: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 355 return false; 356 } 357 } else { 358 return false; // no otherway 359 } 360 361 // Generate the salted hashed password for LDAP 362 $phash = new \dokuwiki\PassHash(); 363 $hash = $phash->hash_ssha($changes['pass']); 364 365 // change the password 366 if (!@ldap_mod_replace($this->con, $dn, array('userpassword' => $hash))) { 367 $this->debug( 368 'LDAP mod replace failed: ' . hsc($dn) . ': ' . hsc(ldap_error($this->con)), 369 0, 370 __LINE__, 371 __FILE__ 372 ); 373 return false; 374 } 375 376 return true; 377 } 378 379 /** 380 * Most values in LDAP are case-insensitive 381 * 382 * @return bool 383 */ 384 public function isCaseSensitive() 385 { 386 return false; 387 } 388 389 /** 390 * Bulk retrieval of user data 391 * 392 * @param int $start index of first user to be returned 393 * @param int $limit max number of users to be returned 394 * @param array $filter array of field/pattern pairs, null for no filter 395 * @return array of userinfo (refer getUserData for internal userinfo details) 396 * @author Dominik Eckelmann <dokuwiki@cosmocode.de> 397 */ 398 public function retrieveUsers($start = 0, $limit = 0, $filter = array()) 399 { 400 if (!$this->openLDAP()) return array(); 401 402 if (is_null($this->users)) { 403 // Perform the search and grab all their details 404 if ($this->getConf('userfilter')) { 405 $all_filter = str_replace('%{user}', '*', $this->getConf('userfilter')); 406 } else { 407 $all_filter = "(ObjectClass=*)"; 408 } 409 $sr = ldap_search($this->con, $this->getConf('usertree'), $all_filter); 410 $entries = ldap_get_entries($this->con, $sr); 411 $users_array = array(); 412 $userkey = $this->getConf('userkey'); 413 for ($i = 0; $i < $entries["count"]; $i++) { 414 array_push($users_array, $entries[$i][$userkey][0]); 415 } 416 asort($users_array); 417 $result = $users_array; 418 if (!$result) return array(); 419 $this->users = array_fill_keys($result, false); 420 } 421 $i = 0; 422 $count = 0; 423 $this->constructPattern($filter); 424 $result = array(); 425 426 foreach ($this->users as $user => &$info) { 427 if ($i++ < $start) { 428 continue; 429 } 430 if ($info === false) { 431 $info = $this->getUserData($user); 432 } 433 if ($this->filter($user, $info)) { 434 $result[$user] = $info; 435 if (($limit > 0) && (++$count >= $limit)) break; 436 } 437 } 438 return $result; 439 } 440 441 /** 442 * Make LDAP filter strings. 443 * 444 * Used by auth_getUserData to make the filter 445 * strings for grouptree and groupfilter 446 * 447 * @param string $filter ldap search filter with placeholders 448 * @param array $placeholders placeholders to fill in 449 * @return string 450 * @author Troels Liebe Bentsen <tlb@rapanden.dk> 451 */ 452 protected function makeFilter($filter, $placeholders) 453 { 454 preg_match_all("/%{([^}]+)/", $filter, $matches, PREG_PATTERN_ORDER); 455 //replace each match 456 foreach ($matches[1] as $match) { 457 //take first element if array 458 if (is_array($placeholders[$match])) { 459 $value = $placeholders[$match][0]; 460 } else { 461 $value = $placeholders[$match]; 462 } 463 $value = $this->filterEscape($value); 464 $filter = str_replace('%{' . $match . '}', $value, $filter); 465 } 466 return $filter; 467 } 468 469 /** 470 * return true if $user + $info match $filter criteria, false otherwise 471 * 472 * @param string $user the user's login name 473 * @param array $info the user's userinfo array 474 * @return bool 475 * @author Chris Smith <chris@jalakai.co.uk> 476 * 477 */ 478 protected function filter($user, $info) 479 { 480 foreach ($this->pattern as $item => $pattern) { 481 if ($item == 'user') { 482 if (!preg_match($pattern, $user)) return false; 483 } elseif ($item == 'grps') { 484 if (!count(preg_grep($pattern, $info['grps']))) return false; 485 } else { 486 if (!preg_match($pattern, $info[$item])) return false; 487 } 488 } 489 return true; 490 } 491 492 /** 493 * Set the filter pattern 494 * 495 * @param $filter 496 * @return void 497 * @author Chris Smith <chris@jalakai.co.uk> 498 * 499 */ 500 protected function constructPattern($filter) 501 { 502 $this->pattern = array(); 503 foreach ($filter as $item => $pattern) { 504 $this->pattern[$item] = '/' . str_replace('/', '\/', $pattern) . '/i'; // allow regex characters 505 } 506 } 507 508 /** 509 * Escape a string to be used in a LDAP filter 510 * 511 * Ported from Perl's Net::LDAP::Util escape_filter_value 512 * 513 * @param string $string 514 * @return string 515 * @author Andreas Gohr 516 */ 517 protected function filterEscape($string) 518 { 519 // see https://github.com/adldap/adLDAP/issues/22 520 return preg_replace_callback( 521 '/([\x00-\x1F\*\(\)\\\\])/', 522 function ($matches) { 523 return "\\" . join("", unpack("H2", $matches[1])); 524 }, 525 $string 526 ); 527 } 528 529 /** 530 * Opens a connection to the configured LDAP server and sets the wanted 531 * option on the connection 532 * 533 * @author Andreas Gohr <andi@splitbrain.org> 534 */ 535 protected function openLDAP() 536 { 537 if ($this->con) return true; // connection already established 538 539 if ($this->getConf('debug')) { 540 ldap_set_option(null, LDAP_OPT_DEBUG_LEVEL, 7); 541 } 542 543 $this->bound = 0; 544 545 $port = $this->getConf('port'); 546 $bound = false; 547 $servers = explode(',', $this->getConf('server')); 548 foreach ($servers as $server) { 549 $server = trim($server); 550 $this->con = @ldap_connect($server, $port); 551 if (!$this->con) { 552 continue; 553 } 554 555 /* 556 * When OpenLDAP 2.x.x is used, ldap_connect() will always return a resource as it does 557 * not actually connect but just initializes the connecting parameters. The actual 558 * connect happens with the next calls to ldap_* funcs, usually with ldap_bind(). 559 * 560 * So we should try to bind to server in order to check its availability. 561 */ 562 563 //set protocol version and dependend options 564 if ($this->getConf('version')) { 565 if (!@ldap_set_option( 566 $this->con, 567 LDAP_OPT_PROTOCOL_VERSION, 568 $this->getConf('version') 569 ) 570 ) { 571 msg('Setting LDAP Protocol version ' . $this->getConf('version') . ' failed', -1); 572 $this->debug('LDAP version set: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 573 } else { 574 //use TLS (needs version 3) 575 if ($this->getConf('starttls')) { 576 if (!@ldap_start_tls($this->con)) { 577 msg('Starting TLS failed', -1); 578 $this->debug('LDAP TLS set: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 579 } 580 } 581 // needs version 3 582 if ($this->getConf('referrals') > -1) { 583 if (!@ldap_set_option( 584 $this->con, 585 LDAP_OPT_REFERRALS, 586 $this->getConf('referrals') 587 ) 588 ) { 589 msg('Setting LDAP referrals failed', -1); 590 $this->debug('LDAP referal set: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 591 } 592 } 593 } 594 } 595 596 //set deref mode 597 if ($this->getConf('deref')) { 598 if (!@ldap_set_option($this->con, LDAP_OPT_DEREF, $this->getConf('deref'))) { 599 msg('Setting LDAP Deref mode ' . $this->getConf('deref') . ' failed', -1); 600 $this->debug('LDAP deref set: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 601 } 602 } 603 /* As of PHP 5.3.0 we can set timeout to speedup skipping of invalid servers */ 604 if (defined('LDAP_OPT_NETWORK_TIMEOUT')) { 605 ldap_set_option($this->con, LDAP_OPT_NETWORK_TIMEOUT, 1); 606 } 607 608 if ($this->getConf('binddn') && $this->getConf('bindpw')) { 609 $bound = @ldap_bind($this->con, $this->getConf('binddn'), conf_decodeString($this->getConf('bindpw'))); 610 $this->bound = 2; 611 } else { 612 $bound = @ldap_bind($this->con); 613 } 614 if ($bound) { 615 break; 616 } 617 } 618 619 if (!$bound) { 620 msg("LDAP: couldn't connect to LDAP server", -1); 621 $this->debug(ldap_error($this->con), 0, __LINE__, __FILE__); 622 return false; 623 } 624 625 $this->cando['getUsers'] = true; 626 return true; 627 } 628 629 /** 630 * Wraps around ldap_search, ldap_list or ldap_read depending on $scope 631 * 632 * @param resource $link_identifier 633 * @param string $base_dn 634 * @param string $filter 635 * @param string $scope can be 'base', 'one' or 'sub' 636 * @param null|array $attributes 637 * @param int $attrsonly 638 * @param int $sizelimit 639 * @return resource 640 * @author Andreas Gohr <andi@splitbrain.org> 641 */ 642 protected function ldapSearch( 643 $link_identifier, 644 $base_dn, 645 $filter, 646 $scope = 'sub', 647 $attributes = null, 648 $attrsonly = 0, 649 $sizelimit = 0 650 ) 651 { 652 if (is_null($attributes)) $attributes = array(); 653 654 if ($scope == 'base') { 655 return @ldap_read( 656 $link_identifier, 657 $base_dn, 658 $filter, 659 $attributes, 660 $attrsonly, 661 $sizelimit 662 ); 663 } elseif ($scope == 'one') { 664 return @ldap_list( 665 $link_identifier, 666 $base_dn, 667 $filter, 668 $attributes, 669 $attrsonly, 670 $sizelimit 671 ); 672 } else { 673 return @ldap_search( 674 $link_identifier, 675 $base_dn, 676 $filter, 677 $attributes, 678 $attrsonly, 679 $sizelimit 680 ); 681 } 682 } 683 684 /** 685 * Wrapper around msg() but outputs only when debug is enabled 686 * 687 * @param string $message 688 * @param int $err 689 * @param int $line 690 * @param string $file 691 * @return void 692 */ 693 protected function debug($message, $err, $line, $file) 694 { 695 if (!$this->getConf('debug')) return; 696 msg($message, $err, $line, $file); 697 } 698} 699