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