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