1<?php 2// must be run within Dokuwiki 3if(!defined('DOKU_INC')) die(); 4 5require_once(DOKU_PLUGIN.'authad/adLDAP/adLDAP.php'); 6require_once(DOKU_PLUGIN.'authad/adLDAP/classes/adLDAPUtils.php'); 7 8/** 9 * Active Directory authentication backend for DokuWiki 10 * 11 * This makes authentication with a Active Directory server much easier 12 * than when using the normal LDAP backend by utilizing the adLDAP library 13 * 14 * Usage: 15 * Set DokuWiki's local.protected.php auth setting to read 16 * 17 * $conf['authtype'] = 'authad'; 18 * 19 * $conf['plugin']['authad']['account_suffix'] = '@my.domain.org'; 20 * $conf['plugin']['authad']['base_dn'] = 'DC=my,DC=domain,DC=org'; 21 * $conf['plugin']['authad']['domain_controllers'] = 'srv1.domain.org,srv2.domain.org'; 22 * 23 * //optional: 24 * $conf['plugin']['authad']['sso'] = 1; 25 * $conf['plugin']['authad']['admin_username'] = 'root'; 26 * $conf['plugin']['authad']['admin_password'] = 'pass'; 27 * $conf['plugin']['authad']['real_primarygroup'] = 1; 28 * $conf['plugin']['authad']['use_ssl'] = 1; 29 * $conf['plugin']['authad']['use_tls'] = 1; 30 * $conf['plugin']['authad']['debug'] = 1; 31 * // warn user about expiring password this many days in advance: 32 * $conf['plugin']['authad']['expirywarn'] = 5; 33 * 34 * // get additional information to the userinfo array 35 * // add a list of comma separated ldap contact fields. 36 * $conf['plugin']['authad']['additional'] = 'field1,field2'; 37 * 38 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 39 * @author James Van Lommel <jamesvl@gmail.com> 40 * @link http://www.nosq.com/blog/2005/08/ldap-activedirectory-and-dokuwiki/ 41 * @author Andreas Gohr <andi@splitbrain.org> 42 * @author Jan Schumann <js@schumann-it.com> 43 */ 44class auth_plugin_authad extends DokuWiki_Auth_Plugin { 45 46 /** 47 * @var array hold connection data for a specific AD domain 48 */ 49 protected $opts = array(); 50 51 /** 52 * @var array open connections for each AD domain, as adLDAP objects 53 */ 54 protected $adldap = array(); 55 56 /** 57 * @var bool message state 58 */ 59 protected $msgshown = false; 60 61 /** 62 * @var array user listing cache 63 */ 64 protected $users = array(); 65 66 /** 67 * @var array filter patterns for listing users 68 */ 69 protected $_pattern = array(); 70 71 protected $_actualstart = 0; 72 73 protected $_grpsusers = array(); 74 75 /** 76 * Constructor 77 */ 78 public function __construct() { 79 global $INPUT; 80 parent::__construct(); 81 82 // we load the config early to modify it a bit here 83 $this->loadConfig(); 84 85 // additional information fields 86 if(isset($this->conf['additional'])) { 87 $this->conf['additional'] = str_replace(' ', '', $this->conf['additional']); 88 $this->conf['additional'] = explode(',', $this->conf['additional']); 89 } else $this->conf['additional'] = array(); 90 91 // ldap extension is needed 92 if(!function_exists('ldap_connect')) { 93 if($this->conf['debug']) 94 msg("AD Auth: PHP LDAP extension not found.", -1); 95 $this->success = false; 96 return; 97 } 98 99 // Prepare SSO 100 if(!empty($_SERVER['REMOTE_USER'])) { 101 102 // make sure the right encoding is used 103 if($this->getConf('sso_charset')) { 104 $_SERVER['REMOTE_USER'] = iconv($this->getConf('sso_charset'), 'UTF-8', $_SERVER['REMOTE_USER']); 105 } elseif(!utf8_check($_SERVER['REMOTE_USER'])) { 106 $_SERVER['REMOTE_USER'] = utf8_encode($_SERVER['REMOTE_USER']); 107 } 108 109 // trust the incoming user 110 if($this->conf['sso']) { 111 $_SERVER['REMOTE_USER'] = $this->cleanUser($_SERVER['REMOTE_USER']); 112 113 // we need to simulate a login 114 if(empty($_COOKIE[DOKU_COOKIE])) { 115 $INPUT->set('u', $_SERVER['REMOTE_USER']); 116 $INPUT->set('p', 'sso_only'); 117 } 118 } 119 } 120 121 // other can do's are changed in $this->_loadServerConfig() base on domain setup 122 $this->cando['modName'] = true; 123 $this->cando['modMail'] = true; 124 $this->cando['getUserCount'] = true; 125 } 126 127 /** 128 * Load domain config on capability check 129 * 130 * @param string $cap 131 * @return bool 132 */ 133 public function canDo($cap) { 134 //capabilities depend on config, which may change depending on domain 135 $domain = $this->_userDomain($_SERVER['REMOTE_USER']); 136 $this->_loadServerConfig($domain); 137 return parent::canDo($cap); 138 } 139 140 /** 141 * Check user+password [required auth function] 142 * 143 * Checks if the given user exists and the given 144 * plaintext password is correct by trying to bind 145 * to the LDAP server 146 * 147 * @author James Van Lommel <james@nosq.com> 148 * @param string $user 149 * @param string $pass 150 * @return bool 151 */ 152 public function checkPass($user, $pass) { 153 if($_SERVER['REMOTE_USER'] && 154 $_SERVER['REMOTE_USER'] == $user && 155 $this->conf['sso'] 156 ) return true; 157 158 $adldap = $this->_adldap($this->_userDomain($user)); 159 if(!$adldap) return false; 160 161 return $adldap->authenticate($this->_userName($user), $pass); 162 } 163 164 /** 165 * Return user info [required auth function] 166 * 167 * Returns info about the given user needs to contain 168 * at least these fields: 169 * 170 * name string full name of the user 171 * mail string email address of the user 172 * grps array list of groups the user is in 173 * 174 * This AD specific function returns the following 175 * addional fields: 176 * 177 * dn string distinguished name (DN) 178 * uid string samaccountname 179 * lastpwd int timestamp of the date when the password was set 180 * expires true if the password expires 181 * expiresin int seconds until the password expires 182 * any fields specified in the 'additional' config option 183 * 184 * @author James Van Lommel <james@nosq.com> 185 * @param string $user 186 * @param bool $requireGroups (optional) - ignored, groups are always supplied by this plugin 187 * @return array 188 */ 189 public function getUserData($user, $requireGroups=true) { 190 global $conf; 191 global $lang; 192 global $ID; 193 $adldap = $this->_adldap($this->_userDomain($user)); 194 if(!$adldap) return false; 195 196 if($user == '') return array(); 197 198 $fields = array('mail', 'displayname', 'samaccountname', 'lastpwd', 'pwdlastset', 'useraccountcontrol'); 199 200 // add additional fields to read 201 $fields = array_merge($fields, $this->conf['additional']); 202 $fields = array_unique($fields); 203 $fields = array_filter($fields); 204 205 //get info for given user 206 $result = $adldap->user()->info($this->_userName($user), $fields); 207 if($result == false){ 208 return array(); 209 } 210 211 //general user info 212 $info = array(); 213 $info['name'] = $result[0]['displayname'][0]; 214 $info['mail'] = $result[0]['mail'][0]; 215 $info['uid'] = $result[0]['samaccountname'][0]; 216 $info['dn'] = $result[0]['dn']; 217 //last password set (Windows counts from January 1st 1601) 218 $info['lastpwd'] = $result[0]['pwdlastset'][0] / 10000000 - 11644473600; 219 //will it expire? 220 $info['expires'] = !($result[0]['useraccountcontrol'][0] & 0x10000); //ADS_UF_DONT_EXPIRE_PASSWD 221 222 // additional information 223 foreach($this->conf['additional'] as $field) { 224 if(isset($result[0][strtolower($field)])) { 225 $info[$field] = $result[0][strtolower($field)][0]; 226 } 227 } 228 229 // handle ActiveDirectory memberOf 230 $info['grps'] = $adldap->user()->groups($this->_userName($user),(bool) $this->opts['recursive_groups']); 231 232 if(is_array($info['grps'])) { 233 foreach($info['grps'] as $ndx => $group) { 234 $info['grps'][$ndx] = $this->cleanGroup($group); 235 } 236 } 237 238 // always add the default group to the list of groups 239 if(!is_array($info['grps']) || !in_array($conf['defaultgroup'], $info['grps'])) { 240 $info['grps'][] = $conf['defaultgroup']; 241 } 242 243 // add the user's domain to the groups 244 $domain = $this->_userDomain($user); 245 if($domain && !in_array("domain-$domain", (array) $info['grps'])) { 246 $info['grps'][] = $this->cleanGroup("domain-$domain"); 247 } 248 249 // check expiry time 250 if($info['expires'] && $this->conf['expirywarn']){ 251 $expiry = $adldap->user()->passwordExpiry($user); 252 if(is_array($expiry)){ 253 $info['expiresat'] = $expiry['expiryts']; 254 $info['expiresin'] = round(($info['expiresat'] - time())/(24*60*60)); 255 256 // if this is the current user, warn him (once per request only) 257 if(($_SERVER['REMOTE_USER'] == $user) && 258 ($info['expiresin'] <= $this->conf['expirywarn']) && 259 !$this->msgshown 260 ) { 261 $msg = sprintf($this->getLang('authpwdexpire'), $info['expiresin']); 262 if($this->canDo('modPass')) { 263 $url = wl($ID, array('do'=> 'profile')); 264 $msg .= ' <a href="'.$url.'">'.$lang['btn_profile'].'</a>'; 265 } 266 msg($msg); 267 $this->msgshown = true; 268 } 269 } 270 } 271 272 return $info; 273 } 274 275 /** 276 * Make AD group names usable by DokuWiki. 277 * 278 * Removes backslashes ('\'), pound signs ('#'), and converts spaces to underscores. 279 * 280 * @author James Van Lommel (jamesvl@gmail.com) 281 * @param string $group 282 * @return string 283 */ 284 public function cleanGroup($group) { 285 $group = str_replace('\\', '', $group); 286 $group = str_replace('#', '', $group); 287 $group = preg_replace('[\s]', '_', $group); 288 $group = utf8_strtolower(trim($group)); 289 return $group; 290 } 291 292 /** 293 * Sanitize user names 294 * 295 * Normalizes domain parts, does not modify the user name itself (unlike cleanGroup) 296 * 297 * @author Andreas Gohr <gohr@cosmocode.de> 298 * @param string $user 299 * @return string 300 */ 301 public function cleanUser($user) { 302 $domain = ''; 303 304 // get NTLM or Kerberos domain part 305 list($dom, $user) = explode('\\', $user, 2); 306 if(!$user) $user = $dom; 307 if($dom) $domain = $dom; 308 list($user, $dom) = explode('@', $user, 2); 309 if($dom) $domain = $dom; 310 311 // clean up both 312 $domain = utf8_strtolower(trim($domain)); 313 $user = utf8_strtolower(trim($user)); 314 315 // is this a known, valid domain? if not discard 316 if(!is_array($this->conf[$domain])) { 317 $domain = ''; 318 } 319 320 // reattach domain 321 if($domain) $user = "$user@$domain"; 322 return $user; 323 } 324 325 /** 326 * Most values in LDAP are case-insensitive 327 * 328 * @return bool 329 */ 330 public function isCaseSensitive() { 331 return false; 332 } 333 334 /** 335 * Create a Search-String useable by adLDAPUsers::all($includeDescription = false, $search = "*", $sorted = true) 336 * 337 * @param array $filter 338 * @return string 339 */ 340 protected function _constructSearchString($filter){ 341 if (!$filter){ 342 return '*'; 343 } 344 $adldapUtils = new adLDAPUtils($this->_adldap(null)); 345 $result = '*'; 346 if (isset($filter['name'])) { 347 $result .= ')(displayname=*' . $adldapUtils->ldapSlashes($filter['name']) . '*'; 348 unset($filter['name']); 349 } 350 351 if (isset($filter['user'])) { 352 $result .= ')(samAccountName=*' . $adldapUtils->ldapSlashes($filter['user']) . '*'; 353 unset($filter['user']); 354 } 355 356 if (isset($filter['mail'])) { 357 $result .= ')(mail=*' . $adldapUtils->ldapSlashes($filter['mail']) . '*'; 358 unset($filter['mail']); 359 } 360 return $result; 361 } 362 363 /** 364 * Return a count of the number of user which meet $filter criteria 365 * 366 * @param array $filter $filter array of field/pattern pairs, empty array for no filter 367 * @return int number of users 368 */ 369 public function getUserCount($filter = array()) { 370 $adldap = $this->_adldap(null); 371 if(!$adldap) { 372 dbglog("authad/auth.php getUserCount(): _adldap not set."); 373 return -1; 374 } 375 if ($filter == array()) { 376 $result = $adldap->user()->all(); 377 } else { 378 $searchString = $this->_constructSearchString($filter); 379 $result = $adldap->user()->all(false, $searchString); 380 if (isset($filter['grps'])) { 381 $this->users = array_fill_keys($result, false); 382 $usermanager = plugin_load("admin", "usermanager", false); 383 $usermanager->setLastdisabled(true); 384 if (!isset($this->_grpsusers[$this->_filterToString($filter)])){ 385 $this->_fillGroupUserArray($filter,$usermanager->getStart() + 3*$usermanager->getPagesize()); 386 } elseif (count($this->_grpsusers[$this->_filterToString($filter)]) < $usermanager->getStart() + 3*$usermanager->getPagesize()) { 387 $this->_fillGroupUserArray($filter,$usermanager->getStart() + 3*$usermanager->getPagesize() - count($this->_grpsusers[$this->_filterToString($filter)])); 388 } 389 $result = $this->_grpsusers[$this->_filterToString($filter)]; 390 } else { 391 $usermanager = plugin_load("admin", "usermanager", false); 392 $usermanager->setLastdisabled(false); 393 } 394 395 } 396 397 if (!$result) { 398 return 0; 399 } 400 return count($result); 401 } 402 403 /** 404 * 405 * create a unique string for each filter used with a group 406 * 407 * @param array $filter 408 * @return string 409 */ 410 protected function _filterToString ($filter) { 411 $result = ''; 412 if (isset($filter['user'])) { 413 $result .= 'user-' . $filter['user']; 414 } 415 if (isset($filter['name'])) { 416 $result .= 'name-' . $filter['name']; 417 } 418 if (isset($filter['mail'])) { 419 $result .= 'mail-' . $filter['mail']; 420 } 421 if (isset($filter['grps'])) { 422 $result .= 'grps-' . $filter['grps']; 423 } 424 return $result; 425 } 426 427 /** 428 * Create an array of $numberOfAdds users passing a certain $filter, including belonging 429 * to a certain group and save them to a object-wide array. If the array 430 * already exists try to add $numberOfAdds further users to it. 431 * 432 * @param array $filter 433 * @param int $numberOfAdds additional number of users requested 434 * @return int number of Users actually add to Array 435 */ 436 protected function _fillGroupUserArray($filter, $numberOfAdds){ 437 $this->_grpsusers[$this->_filterToString($filter)]; 438 $i = 0; 439 $count = 0; 440 $this->_constructPattern($filter); 441 foreach ($this->users as $user => &$info) { 442 if($i++ < $this->_actualstart) { 443 continue; 444 } 445 if($info === false) { 446 $info = $this->getUserData($user); 447 } 448 if($this->_filter($user, $info)) { 449 $this->_grpsusers[$this->_filterToString($filter)][$user] = $info; 450 if(($numberOfAdds > 0) && (++$count >= $numberOfAdds)) break; 451 } 452 } 453 $this->_actualstart = $i; 454 return $count; 455 } 456 457 /** 458 * Bulk retrieval of user data 459 * 460 * @author Dominik Eckelmann <dokuwiki@cosmocode.de> 461 * 462 * @param int $start index of first user to be returned 463 * @param int $limit max number of users to be returned 464 * @param array $filter array of field/pattern pairs, null for no filter 465 * @return array userinfo (refer getUserData for internal userinfo details) 466 */ 467 public function retrieveUsers($start = 0, $limit = 0, $filter = array()) { 468 $adldap = $this->_adldap(null); 469 if(!$adldap) return false; 470 471 if(!$this->users) { 472 //get info for given user 473 $result = $adldap->user()->all(false, $this->_constructSearchString($filter)); 474 if (!$result) return array(); 475 $this->users = array_fill_keys($result, false); 476 } 477 478 $i = 0; 479 $count = 0; 480 $result = array(); 481 482 if (!isset($filter['grps'])) { 483 $usermanager = plugin_load("admin", "usermanager", false); 484 $usermanager->setLastdisabled(false); 485 $this->_constructPattern($filter); 486 foreach($this->users as $user => &$info) { 487 if($i++ < $start) { 488 continue; 489 } 490 if($info === false) { 491 $info = $this->getUserData($user); 492 } 493 $result[$user] = $info; 494 if(($limit > 0) && (++$count >= $limit)) break; 495 } 496 } else { 497 $usermanager = plugin_load("admin", "usermanager", false); 498 $usermanager->setLastdisabled(true); 499 if (!isset($this->_grpsusers[$this->_filterToString($filter)]) || count($this->_grpsusers[$this->_filterToString($filter)]) < ($start+$limit)) { 500 $this->_fillGroupUserArray($filter,$start+$limit - count($this->_grpsusers[$this->_filterToString($filter)]) +1); 501 } 502 if (!$this->_grpsusers[$this->_filterToString($filter)]) return false; 503 foreach($this->_grpsusers[$this->_filterToString($filter)] as $user => &$info) { 504 if($i++ < $start) { 505 continue; 506 } 507 $result[$user] = $info; 508 if(($limit > 0) && (++$count >= $limit)) break; 509 } 510 511 } 512 return $result; 513 } 514 515 /** 516 * Modify user data 517 * 518 * @param string $user nick of the user to be changed 519 * @param array $changes array of field/value pairs to be changed 520 * @return bool 521 */ 522 public function modifyUser($user, $changes) { 523 $return = true; 524 $adldap = $this->_adldap($this->_userDomain($user)); 525 if(!$adldap) return false; 526 527 // password changing 528 if(isset($changes['pass'])) { 529 try { 530 $return = $adldap->user()->password($this->_userName($user),$changes['pass']); 531 } catch (adLDAPException $e) { 532 if ($this->conf['debug']) msg('AD Auth: '.$e->getMessage(), -1); 533 $return = false; 534 } 535 if(!$return) msg('AD Auth: failed to change the password. Maybe the password policy was not met?', -1); 536 } 537 538 // changing user data 539 $adchanges = array(); 540 if(isset($changes['name'])) { 541 // get first and last name 542 $parts = explode(' ', $changes['name']); 543 $adchanges['surname'] = array_pop($parts); 544 $adchanges['firstname'] = join(' ', $parts); 545 $adchanges['display_name'] = $changes['name']; 546 } 547 if(isset($changes['mail'])) { 548 $adchanges['email'] = $changes['mail']; 549 } 550 if(count($adchanges)) { 551 try { 552 $return = $return & $adldap->user()->modify($this->_userName($user),$adchanges); 553 } catch (adLDAPException $e) { 554 if ($this->conf['debug']) msg('AD Auth: '.$e->getMessage(), -1); 555 $return = false; 556 } 557 } 558 559 return $return; 560 } 561 562 /** 563 * Initialize the AdLDAP library and connect to the server 564 * 565 * When you pass null as domain, it will reuse any existing domain. 566 * Eg. the one of the logged in user. It falls back to the default 567 * domain if no current one is available. 568 * 569 * @param string|null $domain The AD domain to use 570 * @return adLDAP|bool true if a connection was established 571 */ 572 protected function _adldap($domain) { 573 if(is_null($domain) && is_array($this->opts)) { 574 $domain = $this->opts['domain']; 575 } 576 577 $this->opts = $this->_loadServerConfig((string) $domain); 578 if(isset($this->adldap[$domain])) return $this->adldap[$domain]; 579 580 // connect 581 try { 582 $this->adldap[$domain] = new adLDAP($this->opts); 583 return $this->adldap[$domain]; 584 } catch(adLDAPException $e) { 585 if($this->conf['debug']) { 586 msg('AD Auth: '.$e->getMessage(), -1); 587 } 588 $this->success = false; 589 $this->adldap[$domain] = null; 590 } 591 return false; 592 } 593 594 /** 595 * Get the domain part from a user 596 * 597 * @param string $user 598 * @return string 599 */ 600 public function _userDomain($user) { 601 list(, $domain) = explode('@', $user, 2); 602 return $domain; 603 } 604 605 /** 606 * Get the user part from a user 607 * 608 * @param string $user 609 * @return string 610 */ 611 public function _userName($user) { 612 list($name) = explode('@', $user, 2); 613 return $name; 614 } 615 616 /** 617 * Fetch the configuration for the given AD domain 618 * 619 * @param string $domain current AD domain 620 * @return array 621 */ 622 protected function _loadServerConfig($domain) { 623 // prepare adLDAP standard configuration 624 $opts = $this->conf; 625 626 $opts['domain'] = $domain; 627 628 // add possible domain specific configuration 629 if($domain && is_array($this->conf[$domain])) foreach($this->conf[$domain] as $key => $val) { 630 $opts[$key] = $val; 631 } 632 633 // handle multiple AD servers 634 $opts['domain_controllers'] = explode(',', $opts['domain_controllers']); 635 $opts['domain_controllers'] = array_map('trim', $opts['domain_controllers']); 636 $opts['domain_controllers'] = array_filter($opts['domain_controllers']); 637 638 // compatibility with old option name 639 if(empty($opts['admin_username']) && !empty($opts['ad_username'])) $opts['admin_username'] = $opts['ad_username']; 640 if(empty($opts['admin_password']) && !empty($opts['ad_password'])) $opts['admin_password'] = $opts['ad_password']; 641 642 // we can change the password if SSL is set 643 if($opts['use_ssl'] || $opts['use_tls']) { 644 $this->cando['modPass'] = true; 645 } else { 646 $this->cando['modPass'] = false; 647 } 648 649 // adLDAP expects empty user/pass as NULL, we're less strict FS#2781 650 if(empty($opts['admin_username'])) $opts['admin_username'] = null; 651 if(empty($opts['admin_password'])) $opts['admin_password'] = null; 652 653 // user listing needs admin priviledges 654 if(!empty($opts['admin_username']) && !empty($opts['admin_password'])) { 655 $this->cando['getUsers'] = true; 656 } else { 657 $this->cando['getUsers'] = false; 658 } 659 660 return $opts; 661 } 662 663 /** 664 * Returns a list of configured domains 665 * 666 * The default domain has an empty string as key 667 * 668 * @return array associative array(key => domain) 669 */ 670 public function _getConfiguredDomains() { 671 $domains = array(); 672 if(empty($this->conf['account_suffix'])) return $domains; // not configured yet 673 674 // add default domain, using the name from account suffix 675 $domains[''] = ltrim($this->conf['account_suffix'], '@'); 676 677 // find additional domains 678 foreach($this->conf as $key => $val) { 679 if(is_array($val) && isset($val['account_suffix'])) { 680 $domains[$key] = ltrim($val['account_suffix'], '@'); 681 } 682 } 683 ksort($domains); 684 685 return $domains; 686 } 687 688 /** 689 * Check provided user and userinfo for matching patterns 690 * 691 * The patterns are set up with $this->_constructPattern() 692 * 693 * @author Chris Smith <chris@jalakai.co.uk> 694 * 695 * @param string $user 696 * @param array $info 697 * @return bool 698 */ 699 protected function _filter($user, $info) { 700 foreach($this->_pattern as $item => $pattern) { 701 if($item == 'user') { 702 if(!preg_match($pattern, $user)) return false; 703 } else if($item == 'grps') { 704 if(!count(preg_grep($pattern, $info['grps']))) return false; 705 } else { 706 if(!preg_match($pattern, $info[$item])) return false; 707 } 708 } 709 return true; 710 } 711 712 /** 713 * Create a pattern for $this->_filter() 714 * 715 * @author Chris Smith <chris@jalakai.co.uk> 716 * 717 * @param array $filter 718 */ 719 protected function _constructPattern($filter) { 720 $this->_pattern = array(); 721 foreach($filter as $item => $pattern) { 722 $this->_pattern[$item] = '/'.str_replace('/', '\/', $pattern).'/i'; // allow regex characters 723 } 724 } 725} 726