1<?php 2// must be run within Dokuwiki 3if(!defined('DOKU_INC')) die(); 4 5require_once(DOKU_INC.'inc/adLDAP.php'); 6 7/** 8 * Active Directory authentication backend for DokuWiki 9 * 10 * This makes authentication with a Active Directory server much easier 11 * than when using the normal LDAP backend by utilizing the adLDAP library 12 * 13 * Usage: 14 * Set DokuWiki's local.protected.php auth setting to read 15 * 16 * $conf['useacl'] = 1; 17 * $conf['disableactions'] = 'register'; 18 * $conf['autopasswd'] = 0; 19 * $conf['authtype'] = 'authad'; 20 * $conf['passcrypt'] = 'ssha'; 21 * 22 * $conf['auth']['ad']['account_suffix'] = ' 23 * 24 * @my.domain.org'; 25 * $conf['auth']['ad']['base_dn'] = 'DC=my,DC=domain,DC=org'; 26 * $conf['auth']['ad']['domain_controllers'] = 'srv1.domain.org,srv2.domain.org'; 27 * 28 * //optional: 29 * $conf['auth']['ad']['sso'] = 1; 30 * $conf['auth']['ad']['ad_username'] = 'root'; 31 * $conf['auth']['ad']['ad_password'] = 'pass'; 32 * $conf['auth']['ad']['real_primarygroup'] = 1; 33 * $conf['auth']['ad']['use_ssl'] = 1; 34 * $conf['auth']['ad']['use_tls'] = 1; 35 * $conf['auth']['ad']['debug'] = 1; 36 * // warn user about expiring password this many days in advance: 37 * $conf['auth']['ad']['expirywarn'] = 5; 38 * 39 * // get additional information to the userinfo array 40 * // add a list of comma separated ldap contact fields. 41 * $conf['plugin']['authad']['additional'] = 'field1,field2'; 42 * 43 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 44 * @author James Van Lommel <jamesvl@gmail.com> 45 * @link http://www.nosq.com/blog/2005/08/ldap-activedirectory-and-dokuwiki/ 46 * @author Andreas Gohr <andi@splitbrain.org> 47 * @author Jan Schumann <js@schumann-it.com> 48 */ 49 50require_once(DOKU_INC.'inc/adLDAP/adLDAP.php'); 51 52class auth_plugin_authad extends DokuWiki_Auth_Plugin { 53 /** 54 * @var array copy of the auth backend configuration 55 */ 56 protected $cnf = array(); 57 /** 58 * @var array hold connection data for a specific AD domain 59 */ 60 protected $opts = array(); 61 /** 62 * @var array open connections for each AD domain, as adLDAP objects 63 */ 64 protected $adldap = array(); 65 66 /** 67 * @var bool message state 68 */ 69 protected $msgshown = false; 70 71 /** 72 * @var array user listing cache 73 */ 74 protected $users = array(); 75 76 /** 77 * @var array filter patterns for listing users 78 */ 79 protected $_pattern = array(); 80 81 /** 82 * Constructor 83 */ 84 public function __construct() { 85 global $conf; 86 $this->cnf = $conf['auth']['ad']; 87 88 // additional information fields 89 if(isset($this->cnf['additional'])) { 90 $this->cnf['additional'] = str_replace(' ', '', $this->cnf['additional']); 91 $this->cnf['additional'] = explode(',', $this->cnf['additional']); 92 } else $this->cnf['additional'] = array(); 93 94 // ldap extension is needed 95 if(!function_exists('ldap_connect')) { 96 if($this->cnf['debug']) 97 msg("AD Auth: PHP LDAP extension not found.", -1); 98 $this->success = false; 99 return; 100 } 101 102 // Prepare SSO 103 if(!utf8_check($_SERVER['REMOTE_USER'])) { 104 $_SERVER['REMOTE_USER'] = utf8_encode($_SERVER['REMOTE_USER']); 105 } 106 if($_SERVER['REMOTE_USER'] && $this->cnf['sso']) { 107 $_SERVER['REMOTE_USER'] = $this->cleanUser($_SERVER['REMOTE_USER']); 108 109 // we need to simulate a login 110 if(empty($_COOKIE[DOKU_COOKIE])) { 111 $_REQUEST['u'] = $_SERVER['REMOTE_USER']; 112 $_REQUEST['p'] = 'sso_only'; 113 } 114 } 115 116 // other can do's are changed in $this->_loadServerConfig() base on domain setup 117 $this->cando['modName'] = true; 118 $this->cando['modMail'] = true; 119 } 120 121 /** 122 * Check user+password [required auth function] 123 * 124 * Checks if the given user exists and the given 125 * plaintext password is correct by trying to bind 126 * to the LDAP server 127 * 128 * @author James Van Lommel <james@nosq.com> 129 * @param string $user 130 * @param string $pass 131 * @return bool 132 */ 133 public function checkPass($user, $pass) { 134 if($_SERVER['REMOTE_USER'] && 135 $_SERVER['REMOTE_USER'] == $user && 136 $this->cnf['sso'] 137 ) return true; 138 139 $adldap = $this->_adldap($this->_userDomain($user)); 140 if(!$adldap) return false; 141 142 return $adldap->authenticate($this->_userName($user), $pass); 143 } 144 145 /** 146 * Return user info [required auth function] 147 * 148 * Returns info about the given user needs to contain 149 * at least these fields: 150 * 151 * name string full name of the user 152 * mail string email address of the user 153 * grps array list of groups the user is in 154 * 155 * This AD specific function returns the following 156 * addional fields: 157 * 158 * dn string distinguished name (DN) 159 * uid string samaccountname 160 * lastpwd int timestamp of the date when the password was set 161 * expires true if the password expires 162 * expiresin int seconds until the password expires 163 * any fields specified in the 'additional' config option 164 * 165 * @author James Van Lommel <james@nosq.com> 166 * @param string $user 167 * @return array 168 */ 169 public function getUserData($user) { 170 global $conf; 171 global $lang; 172 global $ID; 173 $adldap = $this->_adldap($this->_userDomain($user)); 174 if(!$adldap) return false; 175 176 if($user == '') return array(); 177 178 $fields = array('mail', 'displayname', 'samaccountname', 'lastpwd', 'pwdlastset', 'useraccountcontrol'); 179 180 // add additional fields to read 181 $fields = array_merge($fields, $this->cnf['additional']); 182 $fields = array_unique($fields); 183 184 //get info for given user 185 $result = $this->adldap->user()->info($this->_userName($user), $fields); 186 if($result == false){ 187 return array(); 188 } 189 190 //general user info 191 $info['name'] = $result[0]['displayname'][0]; 192 $info['mail'] = $result[0]['mail'][0]; 193 $info['uid'] = $result[0]['samaccountname'][0]; 194 $info['dn'] = $result[0]['dn']; 195 //last password set (Windows counts from January 1st 1601) 196 $info['lastpwd'] = $result[0]['pwdlastset'][0] / 10000000 - 11644473600; 197 //will it expire? 198 $info['expires'] = !($result[0]['useraccountcontrol'][0] & 0x10000); //ADS_UF_DONT_EXPIRE_PASSWD 199 200 // additional information 201 foreach($this->cnf['additional'] as $field) { 202 if(isset($result[0][strtolower($field)])) { 203 $info[$field] = $result[0][strtolower($field)][0]; 204 } 205 } 206 207 // handle ActiveDirectory memberOf 208 $info['grps'] = $this->adldap->user()->groups($this->_userName($user),(bool) $this->opts['recursive_groups']); 209 210 if(is_array($info['grps'])) { 211 foreach($info['grps'] as $ndx => $group) { 212 $info['grps'][$ndx] = $this->cleanGroup($group); 213 } 214 } 215 216 // always add the default group to the list of groups 217 if(!is_array($info['grps']) || !in_array($conf['defaultgroup'], $info['grps'])) { 218 $info['grps'][] = $conf['defaultgroup']; 219 } 220 221 // add the user's domain to the groups 222 $domain = $this->_userDomain($user); 223 if($domain && !in_array("domain-$domain", (array) $info['grps'])) { 224 $info['grps'][] = $this->cleanGroup("domain-$domain"); 225 } 226 227 // check expiry time 228 if($info['expires'] && $this->cnf['expirywarn']){ 229 $timeleft = $this->adldap->user()->passwordExpiry($user); // returns unixtime 230 $timeleft = round($timeleft/(24*60*60)); 231 $info['expiresin'] = $timeleft; 232 233 // if this is the current user, warn him (once per request only) 234 if(($_SERVER['REMOTE_USER'] == $user) && 235 ($timeleft <= $this->cnf['expirywarn']) && 236 !$this->msgshown 237 ) { 238 $msg = sprintf($lang['authpwdexpire'], $timeleft); 239 if($this->canDo('modPass')) { 240 $url = wl($ID, array('do'=> 'profile')); 241 $msg .= ' <a href="'.$url.'">'.$lang['btn_profile'].'</a>'; 242 } 243 msg($msg); 244 $this->msgshown = true; 245 } 246 } 247 248 return $info; 249 } 250 251 /** 252 * Make AD group names usable by DokuWiki. 253 * 254 * Removes backslashes ('\'), pound signs ('#'), and converts spaces to underscores. 255 * 256 * @author James Van Lommel (jamesvl@gmail.com) 257 * @param string $group 258 * @return string 259 */ 260 public function cleanGroup($group) { 261 $group = str_replace('\\', '', $group); 262 $group = str_replace('#', '', $group); 263 $group = preg_replace('[\s]', '_', $group); 264 $group = utf8_strtolower(trim($group)); 265 return $group; 266 } 267 268 /** 269 * Sanitize user names 270 * 271 * Normalizes domain parts, does not modify the user name itself (unlike cleanGroup) 272 * 273 * @author Andreas Gohr <gohr@cosmocode.de> 274 * @param string $user 275 * @return string 276 */ 277 public function cleanUser($user) { 278 $domain = ''; 279 280 // get NTLM or Kerberos domain part 281 list($dom, $user) = explode('\\', $user, 2); 282 if(!$user) $user = $dom; 283 if($dom) $domain = $dom; 284 list($user, $dom) = explode('@', $user, 2); 285 if($dom) $domain = $dom; 286 287 // clean up both 288 $domain = utf8_strtolower(trim($domain)); 289 $user = utf8_strtolower(trim($user)); 290 291 // is this a known, valid domain? if not discard 292 if(!is_array($this->cnf[$domain])) { 293 $domain = ''; 294 } 295 296 // reattach domain 297 if($domain) $user = "$user@$domain"; 298 return $user; 299 } 300 301 /** 302 * Most values in LDAP are case-insensitive 303 * 304 * @return bool 305 */ 306 public function isCaseSensitive() { 307 return false; 308 } 309 310 /** 311 * Bulk retrieval of user data 312 * 313 * @author Dominik Eckelmann <dokuwiki@cosmocode.de> 314 * @param int $start index of first user to be returned 315 * @param int $limit max number of users to be returned 316 * @param array $filter array of field/pattern pairs, null for no filter 317 * @return array userinfo (refer getUserData for internal userinfo details) 318 */ 319 public function retrieveUsers($start = 0, $limit = -1, $filter = array()) { 320 $adldap = $this->_adldap(null); 321 if(!$adldap) return false; 322 323 if($this->users === null) { 324 //get info for given user 325 $result = $this->adldap->user()->all(); 326 if (!$result) return array(); 327 $this->users = array_fill_keys($result, false); 328 } 329 330 $i = 0; 331 $count = 0; 332 $this->_constructPattern($filter); 333 $result = array(); 334 335 foreach($this->users as $user => &$info) { 336 if($i++ < $start) { 337 continue; 338 } 339 if($info === false) { 340 $info = $this->getUserData($user); 341 } 342 if($this->_filter($user, $info)) { 343 $result[$user] = $info; 344 if(($limit >= 0) && (++$count >= $limit)) break; 345 } 346 } 347 return $result; 348 } 349 350 /** 351 * Modify user data 352 * 353 * @param string $user nick of the user to be changed 354 * @param array $changes array of field/value pairs to be changed 355 * @return bool 356 */ 357 public function modifyUser($user, $changes) { 358 $return = true; 359 $adldap = $this->_adldap($this->_userDomain($user)); 360 if(!$adldap) return false; 361 362 // password changing 363 if(isset($changes['pass'])) { 364 try { 365 $return = $this->adldap->user()->password($this->_userName($user),$changes['pass']); 366 } catch (adLDAPException $e) { 367 if ($this->cnf['debug']) msg('AD Auth: '.$e->getMessage(), -1); 368 $return = false; 369 } 370 if(!$return) msg('AD Auth: failed to change the password. Maybe the password policy was not met?', -1); 371 } 372 373 // changing user data 374 $adchanges = array(); 375 if(isset($changes['name'])) { 376 // get first and last name 377 $parts = explode(' ', $changes['name']); 378 $adchanges['surname'] = array_pop($parts); 379 $adchanges['firstname'] = join(' ', $parts); 380 $adchanges['display_name'] = $changes['name']; 381 } 382 if(isset($changes['mail'])) { 383 $adchanges['email'] = $changes['mail']; 384 } 385 if(count($adchanges)) { 386 try { 387 $return = $return & $this->adldap->user()->modify($this->_userName($user),$adchanges); 388 } catch (adLDAPException $e) { 389 if ($this->cnf['debug']) msg('AD Auth: '.$e->getMessage(), -1); 390 $return = false; 391 } 392 } 393 394 return $return; 395 } 396 397 /** 398 * Initialize the AdLDAP library and connect to the server 399 * 400 * When you pass null as domain, it will reuse any existing domain. 401 * Eg. the one of the logged in user. It falls back to the default 402 * domain if no current one is available. 403 * 404 * @param string|null $domain The AD domain to use 405 * @return adLDAP|bool true if a connection was established 406 */ 407 protected function _adldap($domain) { 408 if(is_null($domain) && is_array($this->opts)) { 409 $domain = $this->opts['domain']; 410 } 411 412 $this->opts = $this->_loadServerConfig((string) $domain); 413 if(isset($this->adldap[$domain])) return $this->adldap[$domain]; 414 415 // connect 416 try { 417 $this->adldap[$domain] = new adLDAP($this->opts); 418 return $this->adldap[$domain]; 419 } catch(adLDAPException $e) { 420 if($this->cnf['debug']) { 421 msg('AD Auth: '.$e->getMessage(), -1); 422 } 423 $this->success = false; 424 $this->adldap[$domain] = null; 425 } 426 return false; 427 } 428 429 /** 430 * Get the domain part from a user 431 * 432 * @param $user 433 * @return string 434 */ 435 public function _userDomain($user) { 436 list(, $domain) = explode('@', $user, 2); 437 return $domain; 438 } 439 440 /** 441 * Get the user part from a user 442 * 443 * @param $user 444 * @return string 445 */ 446 public function _userName($user) { 447 list($name) = explode('@', $user, 2); 448 return $name; 449 } 450 451 /** 452 * Fetch the configuration for the given AD domain 453 * 454 * @param string $domain current AD domain 455 * @return array 456 */ 457 protected function _loadServerConfig($domain) { 458 // prepare adLDAP standard configuration 459 $opts = $this->cnf; 460 461 $opts['domain'] = $domain; 462 463 // add possible domain specific configuration 464 if($domain && is_array($this->cnf[$domain])) foreach($this->cnf[$domain] as $key => $val) { 465 $opts[$key] = $val; 466 } 467 468 // handle multiple AD servers 469 $opts['domain_controllers'] = explode(',', $opts['domain_controllers']); 470 $opts['domain_controllers'] = array_map('trim', $opts['domain_controllers']); 471 $opts['domain_controllers'] = array_filter($opts['domain_controllers']); 472 473 // we can change the password if SSL is set 474 if($opts['use_ssl'] || $opts['use_tls']) { 475 $this->cando['modPass'] = true; 476 } else { 477 $this->cando['modPass'] = false; 478 } 479 480 if(isset($opts['ad_username']) && isset($opts['ad_password'])) { 481 $this->cando['getUsers'] = true; 482 } else { 483 $this->cando['getUsers'] = true; 484 } 485 486 return $opts; 487 } 488 489 /** 490 * Check provided user and userinfo for matching patterns 491 * 492 * The patterns are set up with $this->_constructPattern() 493 * 494 * @author Chris Smith <chris@jalakai.co.uk> 495 * @param string $user 496 * @param array $info 497 * @return bool 498 */ 499 protected function _filter($user, $info) { 500 foreach($this->_pattern as $item => $pattern) { 501 if($item == 'user') { 502 if(!preg_match($pattern, $user)) return false; 503 } else if($item == 'grps') { 504 if(!count(preg_grep($pattern, $info['grps']))) return false; 505 } else { 506 if(!preg_match($pattern, $info[$item])) return false; 507 } 508 } 509 return true; 510 } 511 512 /** 513 * Create a pattern for $this->_filter() 514 * 515 * @author Chris Smith <chris@jalakai.co.uk> 516 * @param array $filter 517 */ 518 protected function _constructPattern($filter) { 519 $this->_pattern = array(); 520 foreach($filter as $item => $pattern) { 521 $this->_pattern[$item] = '/'.str_replace('/', '\/', $pattern).'/i'; // allow regex characters 522 } 523 } 524} 525