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