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 * @author Andreas Gohr <andi@splitbrain.org> 51 * @param string $user 52 * @param string $pass 53 * @return bool 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 * @author Andreas Gohr <andi@splitbrain.org> 141 * @author Trouble 142 * @author Dan Allen <dan.j.allen@gmail.com> 143 * @author <evaldas.auryla@pheur.org> 144 * @author Stephane Chazelas <stephane.chazelas@emerson.com> 145 * @author Steffen Schoch <schoch@dsb.net> 146 * 147 * @param string $user 148 * @param bool $requireGroups (optional) - ignored, groups are always supplied by this plugin 149 * @return array containing user data or false 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')); 205 206 $result = @ldap_get_entries($this->con, $sr); 207 208 // if result is not an array 209 if (!is_array($result)) { 210 // no objects found 211 $this->debug('LDAP search returned non-array result: ' . hsc(print($result)), -1, __LINE__, __FILE__); 212 return array(); 213 } 214 215 // Don't accept more or less than one response 216 if ($result['count'] != 1) { 217 $this->debug( 218 'LDAP search returned ' . hsc($result['count']) . ' results while it should return 1!', 219 -1, 220 __LINE__, 221 __FILE__ 222 ); 223 //for($i = 0; $i < $result["count"]; $i++) { 224 //$this->_debug('result: '.hsc(print_r($result[$i])), 0, __LINE__, __FILE__); 225 //} 226 return array(); 227 } 228 229 $this->debug('LDAP search found single result !', 0, __LINE__, __FILE__); 230 231 $user_result = $result[0]; 232 ldap_free_result($sr); 233 234 // general user info 235 $info['dn'] = $user_result['dn']; 236 $info['gid'] = $user_result['gidnumber'][0]; 237 $info['mail'] = $user_result['mail'][0]; 238 $info['name'] = $user_result['cn'][0]; 239 $info['grps'] = array(); 240 241 // overwrite if other attribs are specified. 242 if (is_array($this->getConf('mapping'))) { 243 foreach ($this->getConf('mapping') as $localkey => $key) { 244 if (is_array($key)) { 245 // use regexp to clean up user_result 246 // $key = array($key=>$regexp), only handles the first key-value 247 $regexp = current($key); 248 $key = key($key); 249 if ($user_result[$key]) foreach ($user_result[$key] as $grpkey => $grp) { 250 if ($grpkey !== 'count' && preg_match($regexp, $grp, $match)) { 251 if ($localkey == 'grps') { 252 $info[$localkey][] = $match[1]; 253 } else { 254 $info[$localkey] = $match[1]; 255 } 256 } 257 } 258 } else { 259 $info[$localkey] = $user_result[$key][0]; 260 } 261 } 262 } 263 $user_result = array_merge($info, $user_result); 264 265 //get groups for given user if grouptree is given 266 if ($this->getConf('grouptree') || $this->getConf('groupfilter')) { 267 $base = $this->makeFilter($this->getConf('grouptree'), $user_result); 268 $filter = $this->makeFilter($this->getConf('groupfilter'), $user_result); 269 $sr = $this->ldapSearch( 270 $this->con, 271 $base, 272 $filter, 273 $this->getConf('groupscope'), 274 array($this->getConf('groupkey')) 275 ); 276 $this->debug('LDAP group search: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 277 $this->debug('LDAP search at: ' . hsc($base . ' ' . $filter), 0, __LINE__, __FILE__); 278 279 if (!$sr) { 280 msg("LDAP: Reading group memberships failed", -1); 281 return array(); 282 } 283 $result = ldap_get_entries($this->con, $sr); 284 ldap_free_result($sr); 285 286 if (is_array($result)) foreach ($result as $grp) { 287 if (!empty($grp[$this->getConf('groupkey')])) { 288 $group = $grp[$this->getConf('groupkey')]; 289 if (is_array($group)) { 290 $group = $group[0]; 291 } else { 292 $this->debug('groupkey did not return a detailled result', 0, __LINE__, __FILE__); 293 } 294 if ($group === '') continue; 295 296 $this->debug('LDAP usergroup: ' . hsc($group), 0, __LINE__, __FILE__); 297 $info['grps'][] = $group; 298 } 299 } 300 } 301 302 // always add the default group to the list of groups 303 if (!$info['grps'] or !in_array($conf['defaultgroup'], $info['grps'])) { 304 $info['grps'][] = $conf['defaultgroup']; 305 } 306 return $info; 307 } 308 309 /** 310 * Definition of the function modifyUser in order to modify the password 311 * 312 * @param string $user nick of the user to be changed 313 * @param array $changes array of field/value pairs to be changed (password will be clear text) 314 * @return bool true on success, false on error 315 */ 316 public function modifyUser($user, $changes) 317 { 318 319 // open the connection to the ldap 320 if (!$this->openLDAP()) { 321 $this->debug('LDAP cannot connect: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 322 return false; 323 } 324 325 // find the information about the user, in particular the "dn" 326 $info = $this->getUserData($user, true); 327 if (empty($info['dn'])) { 328 $this->debug('LDAP cannot find your user dn', 0, __LINE__, __FILE__); 329 return false; 330 } 331 $dn = $info['dn']; 332 333 // find the old password of the user 334 list($loginuser, $loginsticky, $loginpass) = auth_getCookie(); 335 if ($loginuser !== null) { // the user is currently logged in 336 $secret = auth_cookiesalt(!$loginsticky, true); 337 $pass = auth_decrypt($loginpass, $secret); 338 339 // bind with the ldap 340 if (!@ldap_bind($this->con, $dn, $pass)) { 341 $this->debug( 342 'LDAP user bind failed: ' . hsc($dn) . ': ' . hsc(ldap_error($this->con)), 343 0, 344 __LINE__, 345 __FILE__ 346 ); 347 return false; 348 } 349 } elseif ($this->getConf('binddn') && $this->getConf('bindpw')) { 350 // we are changing the password on behalf of the user (eg: forgotten password) 351 // bind with the superuser ldap 352 if (!@ldap_bind($this->con, $this->getConf('binddn'), conf_decodeString($this->getConf('bindpw')))) { 353 $this->debug('LDAP bind as superuser: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 354 return false; 355 } 356 } else { 357 return false; // no otherway 358 } 359 360 // Generate the salted hashed password for LDAP 361 $phash = new \dokuwiki\PassHash(); 362 $hash = $phash->hash_ssha($changes['pass']); 363 364 // change the password 365 if (!@ldap_mod_replace($this->con, $dn, array('userpassword' => $hash))) { 366 $this->debug( 367 'LDAP mod replace failed: ' . hsc($dn) . ': ' . hsc(ldap_error($this->con)), 368 0, 369 __LINE__, 370 __FILE__ 371 ); 372 return false; 373 } 374 375 return true; 376 } 377 378 /** 379 * Most values in LDAP are case-insensitive 380 * 381 * @return bool 382 */ 383 public function isCaseSensitive() 384 { 385 return false; 386 } 387 388 /** 389 * Bulk retrieval of user data 390 * 391 * @author Dominik Eckelmann <dokuwiki@cosmocode.de> 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 */ 397 public function retrieveUsers($start = 0, $limit = 0, $filter = array()) 398 { 399 if (!$this->openLDAP()) return array(); 400 401 if (is_null($this->users)) { 402 // Perform the search and grab all their details 403 if ($this->getConf('userfilter')) { 404 $all_filter = str_replace('%{user}', '*', $this->getConf('userfilter')); 405 } else { 406 $all_filter = "(ObjectClass=*)"; 407 } 408 $sr = ldap_search($this->con, $this->getConf('usertree'), $all_filter); 409 $entries = ldap_get_entries($this->con, $sr); 410 $users_array = array(); 411 $userkey = $this->getConf('userkey'); 412 for ($i = 0; $i < $entries["count"]; $i++) { 413 array_push($users_array, $entries[$i][$userkey][0]); 414 } 415 asort($users_array); 416 $result = $users_array; 417 if (!$result) return array(); 418 $this->users = array_fill_keys($result, false); 419 } 420 $i = 0; 421 $count = 0; 422 $this->constructPattern($filter); 423 $result = array(); 424 425 foreach ($this->users as $user => &$info) { 426 if ($i++ < $start) { 427 continue; 428 } 429 if ($info === false) { 430 $info = $this->getUserData($user); 431 } 432 if ($this->filter($user, $info)) { 433 $result[$user] = $info; 434 if (($limit > 0) && (++$count >= $limit)) break; 435 } 436 } 437 return $result; 438 } 439 440 /** 441 * Make LDAP filter strings. 442 * 443 * Used by auth_getUserData to make the filter 444 * strings for grouptree and groupfilter 445 * 446 * @author Troels Liebe Bentsen <tlb@rapanden.dk> 447 * @param string $filter ldap search filter with placeholders 448 * @param array $placeholders placeholders to fill in 449 * @return string 450 */ 451 protected function makeFilter($filter, $placeholders) 452 { 453 preg_match_all("/%{([^}]+)/", $filter, $matches, PREG_PATTERN_ORDER); 454 //replace each match 455 foreach ($matches[1] as $match) { 456 //take first element if array 457 if (is_array($placeholders[$match])) { 458 $value = $placeholders[$match][0]; 459 } else { 460 $value = $placeholders[$match]; 461 } 462 $value = $this->filterEscape($value); 463 $filter = str_replace('%{' . $match . '}', $value, $filter); 464 } 465 return $filter; 466 } 467 468 /** 469 * return true if $user + $info match $filter criteria, false otherwise 470 * 471 * @author Chris Smith <chris@jalakai.co.uk> 472 * 473 * @param string $user the user's login name 474 * @param array $info the user's userinfo array 475 * @return bool 476 */ 477 protected function filter($user, $info) 478 { 479 foreach ($this->pattern as $item => $pattern) { 480 if ($item == 'user') { 481 if (!preg_match($pattern, $user)) return false; 482 } elseif ($item == 'grps') { 483 if (!count(preg_grep($pattern, $info['grps']))) return false; 484 } else { 485 if (!preg_match($pattern, $info[$item])) return false; 486 } 487 } 488 return true; 489 } 490 491 /** 492 * Set the filter pattern 493 * 494 * @author Chris Smith <chris@jalakai.co.uk> 495 * 496 * @param $filter 497 * @return void 498 */ 499 protected function constructPattern($filter) 500 { 501 $this->pattern = array(); 502 foreach ($filter as $item => $pattern) { 503 $this->pattern[$item] = '/' . str_replace('/', '\/', $pattern) . '/i'; // allow regex characters 504 } 505 } 506 507 /** 508 * Escape a string to be used in a LDAP filter 509 * 510 * Ported from Perl's Net::LDAP::Util escape_filter_value 511 * 512 * @author Andreas Gohr 513 * @param string $string 514 * @return string 515 */ 516 protected function filterEscape($string) 517 { 518 // see https://github.com/adldap/adLDAP/issues/22 519 return preg_replace_callback( 520 '/([\x00-\x1F\*\(\)\\\\])/', 521 function ($matches) { 522 return "\\" . join("", unpack("H2", $matches[1])); 523 }, 524 $string 525 ); 526 } 527 528 /** 529 * Opens a connection to the configured LDAP server and sets the wanted 530 * option on the connection 531 * 532 * @author Andreas Gohr <andi@splitbrain.org> 533 */ 534 protected function openLDAP() 535 { 536 if ($this->con) return true; // connection already established 537 538 if ($this->getConf('debug')) { 539 ldap_set_option(null, LDAP_OPT_DEBUG_LEVEL, 7); 540 } 541 542 $this->bound = 0; 543 544 $port = $this->getConf('port'); 545 $bound = false; 546 $servers = explode(',', $this->getConf('server')); 547 foreach ($servers as $server) { 548 $server = trim($server); 549 $this->con = @ldap_connect($server, $port); 550 if (!$this->con) { 551 continue; 552 } 553 554 /* 555 * When OpenLDAP 2.x.x is used, ldap_connect() will always return a resource as it does 556 * not actually connect but just initializes the connecting parameters. The actual 557 * connect happens with the next calls to ldap_* funcs, usually with ldap_bind(). 558 * 559 * So we should try to bind to server in order to check its availability. 560 */ 561 562 //set protocol version and dependend options 563 if ($this->getConf('version')) { 564 if (!@ldap_set_option( 565 $this->con, 566 LDAP_OPT_PROTOCOL_VERSION, 567 $this->getConf('version') 568 ) 569 ) { 570 msg('Setting LDAP Protocol version ' . $this->getConf('version') . ' failed', -1); 571 $this->debug('LDAP version set: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 572 } else { 573 //use TLS (needs version 3) 574 if ($this->getConf('starttls')) { 575 if (!@ldap_start_tls($this->con)) { 576 msg('Starting TLS failed', -1); 577 $this->debug('LDAP TLS set: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 578 } 579 } 580 // needs version 3 581 if ($this->getConf('referrals') > -1) { 582 if (!@ldap_set_option( 583 $this->con, 584 LDAP_OPT_REFERRALS, 585 $this->getConf('referrals') 586 ) 587 ) { 588 msg('Setting LDAP referrals failed', -1); 589 $this->debug('LDAP referal set: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 590 } 591 } 592 } 593 } 594 595 //set deref mode 596 if ($this->getConf('deref')) { 597 if (!@ldap_set_option($this->con, LDAP_OPT_DEREF, $this->getConf('deref'))) { 598 msg('Setting LDAP Deref mode ' . $this->getConf('deref') . ' failed', -1); 599 $this->debug('LDAP deref set: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 600 } 601 } 602 /* As of PHP 5.3.0 we can set timeout to speedup skipping of invalid servers */ 603 if (defined('LDAP_OPT_NETWORK_TIMEOUT')) { 604 ldap_set_option($this->con, LDAP_OPT_NETWORK_TIMEOUT, 1); 605 } 606 607 if ($this->getConf('binddn') && $this->getConf('bindpw')) { 608 $bound = @ldap_bind($this->con, $this->getConf('binddn'), conf_decodeString($this->getConf('bindpw'))); 609 $this->bound = 2; 610 } else { 611 $bound = @ldap_bind($this->con); 612 } 613 if ($bound) { 614 break; 615 } 616 } 617 618 if (!$bound) { 619 msg("LDAP: couldn't connect to LDAP server", -1); 620 $this->debug(ldap_error($this->con), 0, __LINE__, __FILE__); 621 return false; 622 } 623 624 $this->cando['getUsers'] = true; 625 return true; 626 } 627 628 /** 629 * Wraps around ldap_search, ldap_list or ldap_read depending on $scope 630 * 631 * @author Andreas Gohr <andi@splitbrain.org> 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 */ 641 protected function ldapSearch( 642 $link_identifier, 643 $base_dn, 644 $filter, 645 $scope = 'sub', 646 $attributes = null, 647 $attrsonly = 0, 648 $sizelimit = 0 649 ) { 650 if (is_null($attributes)) $attributes = array(); 651 652 if ($scope == 'base') { 653 return @ldap_read( 654 $link_identifier, 655 $base_dn, 656 $filter, 657 $attributes, 658 $attrsonly, 659 $sizelimit 660 ); 661 } elseif ($scope == 'one') { 662 return @ldap_list( 663 $link_identifier, 664 $base_dn, 665 $filter, 666 $attributes, 667 $attrsonly, 668 $sizelimit 669 ); 670 } else { 671 return @ldap_search( 672 $link_identifier, 673 $base_dn, 674 $filter, 675 $attributes, 676 $attrsonly, 677 $sizelimit 678 ); 679 } 680 } 681 682 /** 683 * Wrapper around msg() but outputs only when debug is enabled 684 * 685 * @param string $message 686 * @param int $err 687 * @param int $line 688 * @param string $file 689 * @return void 690 */ 691 protected function debug($message, $err, $line, $file) 692 { 693 if (!$this->getConf('debug')) return; 694 msg($message, $err, $line, $file); 695 } 696} 697