1<?php 2 3namespace dokuwiki\plugin\pureldap\classes; 4 5use dokuwiki\Utf8\PhpString; 6use FreeDSx\Ldap\Entry\Attribute; 7use FreeDSx\Ldap\Entry\Entries; 8use FreeDSx\Ldap\Entry\Entry; 9use FreeDSx\Ldap\Exception\OperationException; 10use FreeDSx\Ldap\Operations; 11use FreeDSx\Ldap\Search\Filters; 12 13/** 14 * Implement Active Directory Specifics 15 */ 16class ADClient extends Client 17{ 18 public const ADS_UF_DONT_EXPIRE_PASSWD = 0x10000; 19 20 /** 21 * @var GroupHierarchyCache 22 * @see getGroupHierarchyCache 23 */ 24 protected $gch; 25 26 /** @inheritDoc */ 27 public function getUser($username, $fetchgroups = true) 28 { 29 $entry = $this->getUserEntry($username); 30 if ($entry === null) return null; 31 return $this->entry2User($entry); 32 } 33 34 /** 35 * Get the LDAP entry for the given user 36 * 37 * @param string $username 38 * @return Entry|null 39 */ 40 protected function getUserEntry($username) 41 { 42 if (!$this->autoAuth()) return null; 43 $samaccountname = $this->simpleUser($username); 44 $userprincipal = $this->qualifiedUser($username); 45 46 $filter = Filters::and( 47 Filters::equal('objectClass', 'user'), 48 Filters::or( 49 Filters::equal('sAMAccountName', $samaccountname), 50 Filters::equal('userPrincipalName', $userprincipal) 51 ) 52 53 ); 54 $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 55 56 try { 57 $attributes = $this->userAttributes(); 58 $entries = $this->ldap->search(Operations::search($filter, ...$attributes)); 59 } catch (OperationException $e) { 60 $this->fatal($e); 61 return null; 62 } 63 if ($entries->count() !== 1) return null; 64 return $entries->first(); 65 } 66 67 /** @inheritDoc */ 68 public function setPassword($username, $newpass, $oldpass = null) 69 { 70 if (!$this->autoAuth()) return false; 71 72 $entry = $this->getUserEntry($username); 73 if ($entry === null) { 74 $this->error("User '$username' not found", __FILE__, __LINE__); 75 return false; 76 } 77 78 if ($oldpass) { 79 // if an old password is given, this is a self-service password change 80 // this has to be executed as the user themselves, not as the admin 81 if ($this->isAuthenticated !== $this->prepareBindUser($username)) { 82 try { 83 $this->authenticate($username, $oldpass); 84 } catch (\Exception $e) { 85 $this->error("Old password for '$username' is wrong", __FILE__, __LINE__); 86 return false; 87 } 88 } 89 90 $entry->remove('unicodePwd', $this->encodePassword($oldpass)); 91 $entry->add('unicodePwd', $this->encodePassword($newpass)); 92 } else { 93 // run as admin user 94 $entry->set('unicodePwd', $this->encodePassword($newpass)); 95 } 96 97 try { 98 $this->ldap->update($entry); 99 } catch (OperationException $e) { 100 $this->fatal($e); 101 return false; 102 } 103 return true; 104 } 105 106 /** @inheritDoc */ 107 public function getGroups($match = null, $filtermethod = self::FILTER_EQUAL) 108 { 109 if (!$this->autoAuth()) return []; 110 111 $filter = Filters::and( 112 Filters::equal('objectClass', 'group') 113 ); 114 if ($match !== null) { 115 // FIXME this is a workaround that removes regex anchors and quoting as passed by the groupuser plugin 116 // a proper fix requires splitbrain/dokuwiki#3028 to be implemented 117 $match = ltrim($match, '^'); 118 $match = rtrim($match, '$'); 119 $match = stripslashes($match); 120 121 $filter->add(Filters::$filtermethod('cn', $match)); 122 } 123 124 $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 125 $search = Operations::search($filter, 'cn'); 126 $paging = $this->ldap->paging($search); 127 128 $groups = []; 129 while ($paging->hasEntries()) { 130 try { 131 $entries = $paging->getEntries(); 132 } catch (OperationException $e) { 133 $this->fatal($e); 134 return $groups; // we return what we got so far 135 } 136 137 foreach ($entries as $entry) { 138 /** @var Entry $entry */ 139 $groups[$entry->getDn()->toString()] = $this->cleanGroup($this->attr2str($entry->get('cn'))); 140 } 141 } 142 143 asort($groups); 144 return $groups; 145 } 146 147 /** 148 * Fetch users matching the given filters 149 * 150 * @param array $match 151 * @param string $filtermethod The method to use for filtering 152 * @return array 153 */ 154 public function getFilteredUsers($match, $filtermethod = self::FILTER_EQUAL) 155 { 156 if (!$this->autoAuth()) return []; 157 158 $filter = Filters::and(Filters::equal('objectClass', 'user')); 159 if (isset($match['user'])) { 160 $filter->add(Filters::$filtermethod('sAMAccountName', $this->simpleUser($match['user']))); 161 } 162 if (isset($match['name'])) { 163 $filter->add(Filters::$filtermethod('displayName', $match['name'])); 164 } 165 if (isset($match['mail'])) { 166 $filter->add(Filters::$filtermethod('mail', $match['mail'])); 167 } 168 if (isset($match['grps'])) { 169 // memberOf can not be checked with a substring match, so we need to get the right groups first 170 $groups = $this->getGroups($match['grps'], $filtermethod); 171 $groupDNs = array_keys($groups); 172 173 if ($this->config['recursivegroups']) { 174 $gch = $this->getGroupHierarchyCache(); 175 foreach ($groupDNs as $dn) { 176 $groupDNs = array_merge($groupDNs, $gch->getChildren($dn)); 177 } 178 $groupDNs = array_unique($groupDNs); 179 } 180 181 $or = Filters::or(); 182 foreach ($groupDNs as $dn) { 183 // domain users membership is in primary group 184 if ($this->dn2group($dn) === $this->config['primarygroup']) { 185 $or->add(Filters::equal('primaryGroupID', 513)); 186 continue; 187 } 188 // find members of this exact group 189 $or->add(Filters::equal('memberOf', $dn)); 190 } 191 $filter->add($or); 192 } 193 194 $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 195 $attributes = $this->userAttributes(); 196 $search = Operations::search($filter, ...$attributes); 197 $paging = $this->ldap->paging($search); 198 199 $users = []; 200 while ($paging->hasEntries()) { 201 try { 202 $entries = $paging->getEntries(); 203 } catch (OperationException $e) { 204 $this->fatal($e); 205 break; // we abort and return what we have so far 206 } 207 208 foreach ($entries as $entry) { 209 $userinfo = $this->entry2User($entry); 210 $users[$userinfo['user']] = $userinfo; 211 } 212 } 213 214 ksort($users); 215 return $users; 216 } 217 218 /** @inheritDoc */ 219 public function cleanUser($user) 220 { 221 return $this->simpleUser($user); 222 } 223 224 /** @inheritDoc */ 225 public function cleanGroup($group) 226 { 227 return PhpString::strtolower($group); 228 } 229 230 /** @inheritDoc */ 231 protected function prepareBindUser($user) 232 { 233 // add account suffix 234 return $this->qualifiedUser($user); 235 } 236 237 /** 238 * Initializes the Group Cache for nested groups 239 * 240 * @return GroupHierarchyCache 241 */ 242 public function getGroupHierarchyCache() 243 { 244 if ($this->gch === null) { 245 if (!$this->autoAuth()) return null; 246 $this->gch = new GroupHierarchyCache($this->ldap, $this->config['usefscache']); 247 } 248 return $this->gch; 249 } 250 251 /** 252 * userPrincipalName in the form <user>@<suffix> 253 * 254 * @param string $user 255 * @return string 256 */ 257 protected function qualifiedUser($user) 258 { 259 $user = $this->simpleUser($user); // strip any existing qualifiers 260 if (!$this->config['suffix']) { 261 $this->error('No account suffix set. Logins may fail.', __FILE__, __LINE__); 262 } 263 264 return $user . '@' . $this->config['suffix']; 265 } 266 267 /** 268 * Removes the account suffix from the given user. Should match the SAMAccountName 269 * 270 * @param string $user 271 * @return string 272 */ 273 protected function simpleUser($user) 274 { 275 $user = PhpString::strtolower($user); 276 $user = preg_replace('/@.*$/', '', $user); 277 $user = preg_replace('/^.*\\\\/', '', $user); 278 return $user; 279 } 280 281 /** 282 * Transform an LDAP entry to a user info array 283 * 284 * @param Entry $entry 285 * @return array 286 */ 287 protected function entry2User(Entry $entry) 288 { 289 // prefer userPrincipalName over sAMAccountName 290 $user = $this->simpleUser($this->attr2str($entry->get('userPrincipalName'))); 291 if($user === '') $user = $this->simpleUser($this->attr2str($entry->get('sAMAccountName'))); 292 293 $user = [ 294 'user' => $user, 295 'name' => $this->attr2str($entry->get('DisplayName')) ?: $this->attr2str($entry->get('Name')), 296 'mail' => $this->attr2str($entry->get('mail')), 297 'dn' => $entry->getDn()->toString(), 298 'grps' => $this->getUserGroups($entry), // we always return groups because its currently inexpensive 299 ]; 300 301 // handle password expiry info 302 $lastChange = $this->attr2str($entry->get('pwdlastset')); 303 if ($lastChange) { 304 $lastChange = (int)substr($lastChange, 0, -7); // remove last 7 digits (100ns intervals to seconds) 305 $lastChange -= 11_644_473_600; // convert from 1601 to 1970 epoch 306 } 307 $user['lastpwd'] = (int)$lastChange; 308 $user['expires'] = !($this->attr2str($entry->get('useraccountcontrol')) & self::ADS_UF_DONT_EXPIRE_PASSWD); 309 310 // get additional attributes 311 foreach ($this->config['attributes'] as $attr) { 312 $user[$attr] = $this->attr2str($entry->get($attr)); 313 } 314 315 return $user; 316 } 317 318 /** 319 * Get the list of groups the given user is member of 320 * 321 * This method currently does no LDAP queries and thus is inexpensive. 322 * 323 * @param Entry $userentry 324 * @return array 325 */ 326 protected function getUserGroups(Entry $userentry) 327 { 328 $groups = []; 329 330 if ($userentry->has('memberOf')) { 331 $groupDNs = $userentry->get('memberOf')->getValues(); 332 333 if ($this->config['recursivegroups']) { 334 $gch = $this->getGroupHierarchyCache(); 335 foreach ($groupDNs as $dn) { 336 $groupDNs = array_merge($groupDNs, $gch->getParents($dn)); 337 } 338 339 $groupDNs = array_unique($groupDNs); 340 } 341 $groups = array_map([$this, 'dn2group'], $groupDNs); 342 } 343 344 $groups[] = $this->config['defaultgroup']; // always add default 345 346 // resolving the primary group in AD is complicated but basically never needed 347 // http://support.microsoft.com/?kbid=321360 348 $gid = $userentry->get('primaryGroupID')->firstValue(); 349 if ($gid == 513) { 350 $groups[] = $this->cleanGroup($this->config['primarygroup']); 351 } 352 353 sort($groups); 354 return $groups; 355 } 356 357 /** @inheritDoc */ 358 protected function userAttributes() 359 { 360 $attr = parent::userAttributes(); 361 $attr[] = new Attribute('sAMAccountName'); 362 $attr[] = new Attribute('userPrincipalName'); 363 $attr[] = new Attribute('Name'); 364 $attr[] = new Attribute('primaryGroupID'); 365 $attr[] = new Attribute('memberOf'); 366 $attr[] = new Attribute('pwdlastset'); 367 $attr[] = new Attribute('useraccountcontrol'); 368 369 return $attr; 370 } 371 372 /** 373 * Queries the maximum password age from the AD server 374 * 375 * Note: we do not check if passwords actually are set to expire here. This is encoded in the lower 32bit 376 * of the returned 64bit integer (see link below). We do not check this because it would require us to 377 * actually do large integer math and we can simply assume it's enabled when the age check was requested in 378 * DokuWiki configuration. 379 * 380 * @link http://msdn.microsoft.com/en-us/library/ms974598.aspx 381 * @param bool $useCache should a filesystem cache be used if available? 382 * @return int The maximum password age in seconds 383 */ 384 public function getMaxPasswordAge($useCache = true) 385 { 386 global $conf; 387 $cachename = getCacheName('maxPwdAge', '.pureldap-maxPwdAge'); 388 $cachetime = @filemtime($cachename); 389 390 // valid file system cache? use it 391 if ($useCache && $cachetime && (time() - $cachetime) < $conf['auth_security_timeout']) { 392 return (int)file_get_contents($cachename); 393 } 394 395 if (!$this->autoAuth()) return 0; 396 397 $attr = new Attribute('maxPwdAge'); 398 try { 399 $entry = $this->ldap->read( 400 $this->getConf('base_dn'), 401 [$attr] 402 ); 403 } catch (OperationException $e) { 404 $this->fatal($e); 405 return 0; 406 } 407 if (!$entry) return 0; 408 $maxPwdAge = $entry->get($attr)->firstValue(); 409 410 // MS returns 100 nanosecond intervals, we want seconds 411 // we operate on strings to avoid integer overflow 412 // we also want a positive value, so we trim off the leading minus sign 413 // only then we convert to int 414 $maxPwdAge = (int)ltrim(substr($maxPwdAge, 0, -7), '-'); 415 416 file_put_contents($cachename, $maxPwdAge); 417 return $maxPwdAge; 418 } 419 420 /** 421 * Extract the group name from the DN 422 * 423 * @param string $dn 424 * @return string 425 */ 426 protected function dn2group($dn) 427 { 428 [$cn] = explode(',', $dn, 2); 429 return $this->cleanGroup(substr($cn, 3)); 430 } 431 432 /** 433 * Encode a password for transmission over LDAP 434 * 435 * Passwords are encoded as UTF-16LE strings encapsulated in quotes. 436 * 437 * @param string $password The password to encode 438 * @return string 439 */ 440 protected function encodePassword($password) 441 { 442 $password = "\"" . $password . "\""; 443 444 if (function_exists('iconv')) { 445 $adpassword = iconv('UTF-8', 'UTF-16LE', $password); 446 } elseif (function_exists('mb_convert_encoding')) { 447 $adpassword = mb_convert_encoding($password, "UTF-16LE", "UTF-8"); 448 } else { 449 // this will only work for ASCII7 passwords 450 $adpassword = ''; 451 for ($i = 0; $i < strlen($password); $i++) { 452 $adpassword .= "$password[$i]\000"; 453 } 454 } 455 return $adpassword; 456 } 457} 458