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 /** 19 * @var GroupHierarchyCache 20 * @see getGroupHierarchyCache 21 */ 22 protected $gch = null; 23 24 /** @inheritDoc */ 25 public function getUser($username, $fetchgroups = true) 26 { 27 $entry = $this->getUserEntry($username); 28 if ($entry === null) return null; 29 return $this->entry2User($entry); 30 } 31 32 /** 33 * Get the LDAP entry for the given user 34 * 35 * @param string $username 36 * @return Entry|null 37 */ 38 protected function getUserEntry($username) 39 { 40 if (!$this->autoAuth()) return null; 41 $username = $this->simpleUser($username); 42 43 $filter = Filters::and( 44 Filters::equal('objectClass', 'user'), 45 Filters::equal('sAMAccountName', $this->simpleUser($username)) 46 ); 47 $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 48 49 try { 50 /** @var Entries $entries */ 51 $attributes = $this->userAttributes(); 52 $entries = $this->ldap->search(Operations::search($filter, ...$attributes)); 53 } catch (OperationException $e) { 54 $this->fatal($e); 55 return null; 56 } 57 if ($entries->count() !== 1) return null; 58 return $entries->first(); 59 } 60 61 /** @inheritDoc */ 62 public function setPassword($username, $newpass, $oldpass = null) 63 { 64 if (!$this->autoAuth()) return false; 65 66 $entry = $this->getUserEntry($username); 67 if ($entry === null) { 68 $this->error("User '$username' not found", __FILE__, __LINE__); 69 return false; 70 } 71 72 if ($oldpass) { 73 // if an old password is given, this is a self-service password change 74 // this has to be executed as the user themselves, not as the admin 75 if ($this->isAuthenticated !== $this->prepareBindUser($username)) { 76 if (!$this->authenticate($username, $oldpass)) { 77 $this->error("Old password for '$username' is wrong", __FILE__, __LINE__); 78 return false; 79 } 80 } 81 82 $entry->remove('unicodePwd', $this->encodePassword($oldpass)); 83 $entry->add('unicodePwd', $this->encodePassword($newpass)); 84 } else { 85 // run as admin user 86 $entry->set('unicodePwd', $this->encodePassword($newpass)); 87 } 88 89 try { 90 $this->ldap->update($entry); 91 } catch (OperationException $e) { 92 $this->fatal($e); 93 return false; 94 } 95 return true; 96 } 97 98 /** @inheritDoc */ 99 public function getGroups($match = null, $filtermethod = self::FILTER_EQUAL) 100 { 101 if (!$this->autoAuth()) return []; 102 103 $filter = Filters::and( 104 Filters::equal('objectClass', 'group') 105 ); 106 if ($match !== null) { 107 // FIXME this is a workaround that removes regex anchors and quoting as passed by the groupuser plugin 108 // a proper fix requires splitbrain/dokuwiki#3028 to be implemented 109 $match = ltrim($match, '^'); 110 $match = rtrim($match, '$'); 111 $match = stripslashes($match); 112 113 $filter->add(Filters::$filtermethod('cn', $match)); 114 } 115 116 $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 117 $search = Operations::search($filter, 'cn'); 118 $paging = $this->ldap->paging($search); 119 120 $groups = []; 121 while ($paging->hasEntries()) { 122 try { 123 $entries = $paging->getEntries(); 124 } catch (OperationException $e) { 125 $this->fatal($e); 126 return $groups; // we return what we got so far 127 } 128 129 foreach ($entries as $entry) { 130 /** @var Entry $entry */ 131 $groups[$entry->getDn()->toString()] = $this->cleanGroup($this->attr2str($entry->get('cn'))); 132 } 133 } 134 135 asort($groups); 136 return $groups; 137 } 138 139 /** 140 * Fetch users matching the given filters 141 * 142 * @param array $match 143 * @param string $filtermethod The method to use for filtering 144 * @return array 145 */ 146 public function getFilteredUsers($match, $filtermethod = self::FILTER_EQUAL) 147 { 148 if (!$this->autoAuth()) return []; 149 150 $filter = Filters::and(Filters::equal('objectClass', 'user')); 151 if (isset($match['user'])) { 152 $filter->add(Filters::$filtermethod('sAMAccountName', $this->simpleUser($match['user']))); 153 } 154 if (isset($match['name'])) { 155 $filter->add(Filters::$filtermethod('displayName', $match['name'])); 156 } 157 if (isset($match['mail'])) { 158 $filter->add(Filters::$filtermethod('mail', $match['mail'])); 159 } 160 if (isset($match['grps'])) { 161 // memberOf can not be checked with a substring match, so we need to get the right groups first 162 $groups = $this->getGroups($match['grps'], $filtermethod); 163 $groupDNs = array_keys($groups); 164 165 if ($this->config['recursivegroups']) { 166 $gch = $this->getGroupHierarchyCache(); 167 foreach ($groupDNs as $dn) { 168 $groupDNs = array_merge($groupDNs, $gch->getChildren($dn)); 169 } 170 $groupDNs = array_unique($groupDNs); 171 } 172 173 $or = Filters::or(); 174 foreach ($groupDNs as $dn) { 175 // domain users membership is in primary group 176 if ($this->dn2group($dn) === $this->config['primarygroup']) { 177 $or->add(Filters::equal('primaryGroupID', 513)); 178 continue; 179 } 180 // find members of this exact group 181 $or->add(Filters::equal('memberOf', $dn)); 182 } 183 $filter->add($or); 184 } 185 186 $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 187 $attributes = $this->userAttributes(); 188 $search = Operations::search($filter, ...$attributes); 189 $paging = $this->ldap->paging($search); 190 191 $users = []; 192 while ($paging->hasEntries()) { 193 try { 194 $entries = $paging->getEntries(); 195 } catch (OperationException $e) { 196 $this->fatal($e); 197 break; // we abort and return what we have so far 198 } 199 200 foreach ($entries as $entry) { 201 $userinfo = $this->entry2User($entry); 202 $users[$userinfo['user']] = $userinfo; 203 } 204 } 205 206 ksort($users); 207 return $users; 208 } 209 210 /** @inheritDoc */ 211 public function cleanUser($user) 212 { 213 return $this->simpleUser($user); 214 } 215 216 /** @inheritDoc */ 217 public function cleanGroup($group) 218 { 219 return PhpString::strtolower($group); 220 } 221 222 /** @inheritDoc */ 223 public function prepareBindUser($user) 224 { 225 // add account suffix 226 return $this->qualifiedUser($user); 227 } 228 229 /** 230 * Initializes the Group Cache for nested groups 231 * 232 * @return GroupHierarchyCache 233 */ 234 public function getGroupHierarchyCache() 235 { 236 if ($this->gch === null) { 237 if (!$this->autoAuth()) return null; 238 $this->gch = new GroupHierarchyCache($this->ldap, $this->config['usefscache']); 239 } 240 return $this->gch; 241 } 242 243 /** 244 * userPrincipalName in the form <user>@<suffix> 245 * 246 * @param string $user 247 * @return string 248 */ 249 protected function qualifiedUser($user) 250 { 251 $user = $this->simpleUser($user); // strip any existing qualifiers 252 if (!$this->config['suffix']) { 253 $this->error('No account suffix set. Logins may fail.', __FILE__, __LINE__); 254 } 255 256 return $user . '@' . $this->config['suffix']; 257 } 258 259 /** 260 * Removes the account suffix from the given user. Should match the SAMAccountName 261 * 262 * @param string $user 263 * @return string 264 */ 265 protected function simpleUser($user) 266 { 267 $user = PhpString::strtolower($user); 268 $user = preg_replace('/@.*$/', '', $user); 269 $user = preg_replace('/^.*\\\\/', '', $user); 270 return $user; 271 } 272 273 /** 274 * Transform an LDAP entry to a user info array 275 * 276 * @param Entry $entry 277 * @return array 278 */ 279 protected function entry2User(Entry $entry) 280 { 281 $user = [ 282 'user' => $this->simpleUser($this->attr2str($entry->get('sAMAccountName'))), 283 'name' => $this->attr2str($entry->get('DisplayName')) ?: $this->attr2str($entry->get('Name')), 284 'mail' => $this->attr2str($entry->get('mail')), 285 'dn' => $entry->getDn()->toString(), 286 'grps' => $this->getUserGroups($entry), // we always return groups because its currently inexpensive 287 ]; 288 289 // get additional attributes 290 foreach ($this->config['attributes'] as $attr) { 291 $user[$attr] = $this->attr2str($entry->get($attr)); 292 } 293 294 return $user; 295 } 296 297 /** 298 * Get the list of groups the given user is member of 299 * 300 * This method currently does no LDAP queries and thus is inexpensive. 301 * 302 * @param Entry $userentry 303 * @return array 304 */ 305 protected function getUserGroups(Entry $userentry) 306 { 307 $groups = []; 308 309 if ($userentry->has('memberOf')) { 310 $groupDNs = $userentry->get('memberOf')->getValues(); 311 312 if ($this->config['recursivegroups']) { 313 $gch = $this->getGroupHierarchyCache(); 314 foreach ($groupDNs as $dn) { 315 $groupDNs = array_merge($groupDNs, $gch->getParents($dn)); 316 } 317 318 $groupDNs = array_unique($groupDNs); 319 } 320 $groups = array_map([$this, 'dn2group'], $groupDNs); 321 } 322 323 $groups[] = $this->config['defaultgroup']; // always add default 324 325 // resolving the primary group in AD is complicated but basically never needed 326 // http://support.microsoft.com/?kbid=321360 327 $gid = $userentry->get('primaryGroupID')->firstValue(); 328 if ($gid == 513) { 329 $groups[] = $this->cleanGroup($this->config['primarygroup']); 330 } 331 332 sort($groups); 333 return $groups; 334 } 335 336 /** @inheritDoc */ 337 protected function userAttributes() 338 { 339 $attr = parent::userAttributes(); 340 $attr[] = new Attribute('sAMAccountName'); 341 $attr[] = new Attribute('Name'); 342 $attr[] = new Attribute('primaryGroupID'); 343 $attr[] = new Attribute('memberOf'); 344 345 return $attr; 346 } 347 348 /** 349 * Extract the group name from the DN 350 * 351 * @param string $dn 352 * @return string 353 */ 354 protected function dn2group($dn) 355 { 356 list($cn) = explode(',', $dn, 2); 357 return $this->cleanGroup(substr($cn, 3)); 358 } 359 360 /** 361 * Encode a password for transmission over LDAP 362 * 363 * Passwords are encoded as UTF-16LE strings encapsulated in quotes. 364 * 365 * @param string $password The password to encode 366 * @return string 367 */ 368 protected function encodePassword($password) 369 { 370 $password = "\"" . $password . "\""; 371 372 if (function_exists('iconv')) { 373 $adpassword = iconv('UTF-8', 'UTF-16LE', $password); 374 } elseif (function_exists('mb_convert_encoding')) { 375 $adpassword = mb_convert_encoding($password, "UTF-16LE", "UTF-8"); 376 } else { 377 // this will only work for ASCII7 passwords 378 $adpassword = ''; 379 for ($i = 0; $i < strlen($password); $i++) { 380 $adpassword .= "$password[$i]\000"; 381 } 382 } 383 return $adpassword; 384 } 385} 386