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