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