1<?php 2use dokuwiki\PassHash; 3use dokuwiki\Utf8\Sort; 4 5/** 6 * LDAP authentication backend 7 * 8 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 9 * @author Andreas Gohr <andi@splitbrain.org> 10 * @author Chris Smith <chris@jalakaic.co.uk> 11 * @author Jan Schumann <js@schumann-it.com> 12 */ 13class auth_plugin_authldap extends DokuWiki_Auth_Plugin 14{ 15 /* @var resource $con holds the LDAP connection */ 16 protected $con; 17 18 /* @var int $bound What type of connection does already exist? */ 19 protected $bound = 0; // 0: anonymous, 1: user, 2: superuser 20 21 /* @var array $users User data cache */ 22 protected $users; 23 24 /* @var array $pattern User filter pattern */ 25 protected $pattern; 26 27 /** 28 * Constructor 29 */ 30 public function __construct() 31 { 32 parent::__construct(); 33 34 // ldap extension is needed 35 if (!function_exists('ldap_connect')) { 36 $this->debug("LDAP err: PHP LDAP extension not found.", -1, __LINE__, __FILE__); 37 $this->success = false; 38 return; 39 } 40 41 // Add the capabilities to change the password 42 $this->cando['modPass'] = $this->getConf('modPass'); 43 } 44 45 /** 46 * Check user+password 47 * 48 * Checks if the given user exists and the given 49 * plaintext password is correct by trying to bind 50 * to the LDAP server 51 * 52 * @param string $user 53 * @param string $pass 54 * @return bool 55 * @author Andreas Gohr <andi@splitbrain.org> 56 */ 57 public function checkPass($user, $pass) 58 { 59 // reject empty password 60 if (empty($pass)) return false; 61 if (!$this->openLDAP()) return false; 62 63 // indirect user bind 64 if ($this->getConf('binddn') && $this->getConf('bindpw')) { 65 // use superuser credentials 66 if (!@ldap_bind($this->con, $this->getConf('binddn'), conf_decodeString($this->getConf('bindpw')))) { 67 $this->debug('LDAP bind as superuser: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 68 return false; 69 } 70 $this->bound = 2; 71 } elseif ($this->getConf('binddn') && 72 $this->getConf('usertree') && 73 $this->getConf('userfilter') 74 ) { 75 // special bind string 76 $dn = $this->makeFilter( 77 $this->getConf('binddn'), 78 ['user' => $user, 'server' => $this->getConf('server')] 79 ); 80 } elseif (strpos($this->getConf('usertree'), '%{user}')) { 81 // direct user bind 82 $dn = $this->makeFilter( 83 $this->getConf('usertree'), 84 ['user' => $user, 'server' => $this->getConf('server')] 85 ); 86 } elseif (!@ldap_bind($this->con)) { 87 // Anonymous bind 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 // 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 []; 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 []; 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 [$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 = []; 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 if ($sr === false) { 206 $this->debug('User ldap_search failed. Check configuration.', 0, __LINE__, __FILE__); 207 return false; 208 } 209 210 $result = @ldap_get_entries($this->con, $sr); 211 212 // if result is not an array 213 if (!is_array($result)) { 214 // no objects found 215 $this->debug('LDAP search returned non-array result: ' . hsc(print($result)), -1, __LINE__, __FILE__); 216 return []; 217 } 218 219 // Don't accept more or less than one response 220 if ($result['count'] != 1) { 221 $this->debug( 222 'LDAP search returned ' . hsc($result['count']) . ' results while it should return 1!', 223 -1, 224 __LINE__, 225 __FILE__ 226 ); 227 //for($i = 0; $i < $result["count"]; $i++) { 228 //$this->_debug('result: '.hsc(print_r($result[$i])), 0, __LINE__, __FILE__); 229 //} 230 return []; 231 } 232 233 $this->debug('LDAP search found single result !', 0, __LINE__, __FILE__); 234 235 $user_result = $result[0]; 236 ldap_free_result($sr); 237 238 // general user info 239 $info['dn'] = $user_result['dn']; 240 $info['gid'] = $user_result['gidnumber'][0] ?? null; 241 $info['mail'] = $user_result['mail'][0]; 242 $info['name'] = $user_result['cn'][0]; 243 $info['grps'] = []; 244 245 // overwrite if other attribs are specified. 246 if (is_array($this->getConf('mapping'))) { 247 foreach ($this->getConf('mapping') as $localkey => $key) { 248 if (is_array($key)) { 249 // use regexp to clean up user_result 250 // $key = array($key=>$regexp), only handles the first key-value 251 $regexp = current($key); 252 $key = key($key); 253 if ($user_result[$key]) foreach ($user_result[$key] as $grpkey => $grp) { 254 if ($grpkey !== 'count' && preg_match($regexp, $grp, $match)) { 255 if ($localkey == 'grps') { 256 $info[$localkey][] = $match[1]; 257 } else { 258 $info[$localkey] = $match[1]; 259 } 260 } 261 } 262 } else { 263 $info[$localkey] = $user_result[$key][0]; 264 } 265 } 266 } 267 $user_result = array_merge($info, $user_result); 268 269 //get groups for given user if grouptree is given 270 if ($this->getConf('grouptree') || $this->getConf('groupfilter')) { 271 $base = $this->makeFilter($this->getConf('grouptree'), $user_result); 272 $filter = $this->makeFilter($this->getConf('groupfilter'), $user_result); 273 $sr = $this->ldapSearch( 274 $this->con, 275 $base, 276 $filter, 277 $this->getConf('groupscope'), 278 [$this->getConf('groupkey')] 279 ); 280 $this->debug('LDAP group search: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 281 $this->debug('LDAP search at: ' . hsc($base . ' ' . $filter), 0, __LINE__, __FILE__); 282 283 if (!$sr) { 284 msg("LDAP: Reading group memberships failed", -1); 285 return []; 286 } 287 $result = ldap_get_entries($this->con, $sr); 288 ldap_free_result($sr); 289 290 if (is_array($result)) foreach ($result as $grp) { 291 if (!empty($grp[$this->getConf('groupkey')])) { 292 $group = $grp[$this->getConf('groupkey')]; 293 if (is_array($group)) { 294 $group = $group[0]; 295 } else { 296 $this->debug('groupkey did not return a detailled result', 0, __LINE__, __FILE__); 297 } 298 if ($group === '') continue; 299 300 $this->debug('LDAP usergroup: ' . hsc($group), 0, __LINE__, __FILE__); 301 $info['grps'][] = $group; 302 } 303 } 304 } 305 306 // always add the default group to the list of groups 307 if (!$info['grps'] || !in_array($conf['defaultgroup'], $info['grps'])) { 308 $info['grps'][] = $conf['defaultgroup']; 309 } 310 return $info; 311 } 312 313 /** 314 * Definition of the function modifyUser in order to modify the password 315 * 316 * @param string $user nick of the user to be changed 317 * @param array $changes array of field/value pairs to be changed (password will be clear text) 318 * @return bool true on success, false on error 319 */ 320 public function modifyUser($user, $changes) 321 { 322 323 // open the connection to the ldap 324 if (!$this->openLDAP()) { 325 $this->debug('LDAP cannot connect: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 326 return false; 327 } 328 329 // find the information about the user, in particular the "dn" 330 $info = $this->getUserData($user, true); 331 if (empty($info['dn'])) { 332 $this->debug('LDAP cannot find your user dn', 0, __LINE__, __FILE__); 333 return false; 334 } 335 $dn = $info['dn']; 336 337 // find the old password of the user 338 [$loginuser, $loginsticky, $loginpass] = auth_getCookie(); 339 if ($loginuser !== null) { // the user is currently logged in 340 $secret = auth_cookiesalt(!$loginsticky, true); 341 $pass = auth_decrypt($loginpass, $secret); 342 343 // bind with the ldap 344 if (!@ldap_bind($this->con, $dn, $pass)) { 345 $this->debug( 346 'LDAP user bind failed: ' . hsc($dn) . ': ' . hsc(ldap_error($this->con)), 347 0, 348 __LINE__, 349 __FILE__ 350 ); 351 return false; 352 } 353 } elseif ($this->getConf('binddn') && $this->getConf('bindpw')) { 354 // we are changing the password on behalf of the user (eg: forgotten password) 355 // bind with the superuser ldap 356 if (!@ldap_bind($this->con, $this->getConf('binddn'), conf_decodeString($this->getConf('bindpw')))) { 357 $this->debug('LDAP bind as superuser: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__); 358 return false; 359 } 360 } else { 361 return false; // no otherway 362 } 363 364 // Generate the salted hashed password for LDAP 365 $phash = new PassHash(); 366 $hash = $phash->hash_ssha($changes['pass']); 367 368 // change the password 369 if (!@ldap_mod_replace($this->con, $dn, ['userpassword' => $hash])) { 370 $this->debug( 371 'LDAP mod replace failed: ' . hsc($dn) . ': ' . hsc(ldap_error($this->con)), 372 0, 373 __LINE__, 374 __FILE__ 375 ); 376 return false; 377 } 378 379 return true; 380 } 381 382 /** 383 * Most values in LDAP are case-insensitive 384 * 385 * @return bool 386 */ 387 public function isCaseSensitive() 388 { 389 return false; 390 } 391 392 /** 393 * Bulk retrieval of user data 394 * 395 * @param int $start index of first user to be returned 396 * @param int $limit max number of users to be returned 397 * @param array $filter array of field/pattern pairs, null for no filter 398 * @return array of userinfo (refer getUserData for internal userinfo details) 399 * @author Dominik Eckelmann <dokuwiki@cosmocode.de> 400 */ 401 public function retrieveUsers($start = 0, $limit = 0, $filter = []) 402 { 403 if (!$this->openLDAP()) return []; 404 405 if (is_null($this->users)) { 406 // Perform the search and grab all their details 407 if ($this->getConf('userfilter')) { 408 $all_filter = str_replace('%{user}', '*', $this->getConf('userfilter')); 409 } else { 410 $all_filter = "(ObjectClass=*)"; 411 } 412 $sr = ldap_search($this->con, $this->getConf('usertree'), $all_filter); 413 $entries = ldap_get_entries($this->con, $sr); 414 $users_array = []; 415 $userkey = $this->getConf('userkey'); 416 for ($i = 0; $i < $entries["count"]; $i++) { 417 $users_array[] = $entries[$i][$userkey][0]; 418 } 419 Sort::asort($users_array); 420 $result = $users_array; 421 if (!$result) return []; 422 $this->users = array_fill_keys($result, false); 423 } 424 $i = 0; 425 $count = 0; 426 $this->constructPattern($filter); 427 $result = []; 428 429 foreach ($this->users as $user => &$info) { 430 if ($i++ < $start) { 431 continue; 432 } 433 if ($info === false) { 434 $info = $this->getUserData($user); 435 } 436 if ($this->filter($user, $info)) { 437 $result[$user] = $info; 438 if (($limit > 0) && (++$count >= $limit)) break; 439 } 440 } 441 return $result; 442 } 443 444 /** 445 * Make LDAP filter strings. 446 * 447 * Used by auth_getUserData to make the filter 448 * strings for grouptree and groupfilter 449 * 450 * @param string $filter ldap search filter with placeholders 451 * @param array $placeholders placeholders to fill in 452 * @return string 453 * @author Troels Liebe Bentsen <tlb@rapanden.dk> 454 */ 455 protected function makeFilter($filter, $placeholders) 456 { 457 preg_match_all("/%{([^}]+)/", $filter, $matches, PREG_PATTERN_ORDER); 458 //replace each match 459 foreach ($matches[1] as $match) { 460 //take first element if array 461 if (is_array($placeholders[$match])) { 462 $value = $placeholders[$match][0]; 463 } else { 464 $value = $placeholders[$match]; 465 } 466 $value = $this->filterEscape($value); 467 $filter = str_replace('%{' . $match . '}', $value, $filter); 468 } 469 return $filter; 470 } 471 472 /** 473 * return true if $user + $info match $filter criteria, false otherwise 474 * 475 * @param string $user the user's login name 476 * @param array $info the user's userinfo array 477 * @return bool 478 * @author Chris Smith <chris@jalakai.co.uk> 479 * 480 */ 481 protected function filter($user, $info) 482 { 483 foreach ($this->pattern as $item => $pattern) { 484 if ($item == 'user') { 485 if (!preg_match($pattern, $user)) return false; 486 } elseif ($item == 'grps') { 487 if (!count(preg_grep($pattern, $info['grps']))) return false; 488 } elseif (!preg_match($pattern, $info[$item])) { 489 return false; 490 } 491 } 492 return true; 493 } 494 495 /** 496 * Set the filter pattern 497 * 498 * @param $filter 499 * @return void 500 * @author Chris Smith <chris@jalakai.co.uk> 501 * 502 */ 503 protected function constructPattern($filter) 504 { 505 $this->pattern = []; 506 foreach ($filter as $item => $pattern) { 507 $this->pattern[$item] = '/' . str_replace('/', '\/', $pattern) . '/i'; // allow regex characters 508 } 509 } 510 511 /** 512 * Escape a string to be used in a LDAP filter 513 * 514 * Ported from Perl's Net::LDAP::Util escape_filter_value 515 * 516 * @param string $string 517 * @return string 518 * @author Andreas Gohr 519 */ 520 protected function filterEscape($string) 521 { 522 // see https://github.com/adldap/adLDAP/issues/22 523 return preg_replace_callback( 524 '/([\x00-\x1F\*\(\)\\\\])/', 525 static fn($matches) => "\\" . implode("", unpack("H2", $matches[1])), 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 = []; 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