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