11078ec26SAndreas Gohr<?php 21078ec26SAndreas Gohr 31078ec26SAndreas Gohrnamespace dokuwiki\plugin\pureldap\classes; 41078ec26SAndreas Gohr 580ac552fSAndreas Gohruse dokuwiki\Utf8\PhpString; 69c590892SAndreas Gohruse FreeDSx\Ldap\Entry\Attribute; 71078ec26SAndreas Gohruse FreeDSx\Ldap\Entry\Entries; 81078ec26SAndreas Gohruse FreeDSx\Ldap\Entry\Entry; 91078ec26SAndreas Gohruse FreeDSx\Ldap\Exception\OperationException; 101078ec26SAndreas Gohruse FreeDSx\Ldap\Operations; 111078ec26SAndreas Gohruse FreeDSx\Ldap\Search\Filters; 121078ec26SAndreas Gohr 13f17bb68bSAndreas Gohr/** 14f17bb68bSAndreas Gohr * Implement Active Directory Specifics 15f17bb68bSAndreas Gohr */ 161078ec26SAndreas Gohrclass ADClient extends Client 171078ec26SAndreas Gohr{ 18e7339d5aSAndreas Gohr /** 19e7339d5aSAndreas Gohr * @var GroupHierarchyCache 20e7339d5aSAndreas Gohr * @see getGroupHierarchyCache 21e7339d5aSAndreas Gohr */ 22e7339d5aSAndreas Gohr protected $gch = null; 231078ec26SAndreas Gohr 241078ec26SAndreas Gohr /** @inheritDoc */ 251078ec26SAndreas Gohr public function getUser($username, $fetchgroups = true) 261078ec26SAndreas Gohr { 27*08ace392SAndreas Gohr $entry = $this->getUserEntry($username); 28*08ace392SAndreas Gohr if ($entry === null) return null; 29*08ace392SAndreas Gohr return $this->entry2User($entry); 30*08ace392SAndreas Gohr } 31*08ace392SAndreas Gohr 32*08ace392SAndreas Gohr /** 33*08ace392SAndreas Gohr * Get the LDAP entry for the given user 34*08ace392SAndreas Gohr * 35*08ace392SAndreas Gohr * @param string $username 36*08ace392SAndreas Gohr * @return Entry|null 37*08ace392SAndreas Gohr */ 38*08ace392SAndreas Gohr protected function getUserEntry($username) 39*08ace392SAndreas Gohr { 401078ec26SAndreas Gohr if (!$this->autoAuth()) return null; 41a1128cc0SAndreas Gohr $username = $this->simpleUser($username); 421078ec26SAndreas Gohr 431078ec26SAndreas Gohr $filter = Filters::and( 441078ec26SAndreas Gohr Filters::equal('objectClass', 'user'), 45a1128cc0SAndreas Gohr Filters::equal('sAMAccountName', $this->simpleUser($username)) 461078ec26SAndreas Gohr ); 47b21740b4SAndreas Gohr $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 481078ec26SAndreas Gohr 491078ec26SAndreas Gohr try { 501078ec26SAndreas Gohr /** @var Entries $entries */ 519c590892SAndreas Gohr $attributes = $this->userAttributes(); 529c590892SAndreas Gohr $entries = $this->ldap->search(Operations::search($filter, ...$attributes)); 531078ec26SAndreas Gohr } catch (OperationException $e) { 54b21740b4SAndreas Gohr $this->fatal($e); 551078ec26SAndreas Gohr return null; 561078ec26SAndreas Gohr } 571078ec26SAndreas Gohr if ($entries->count() !== 1) return null; 58*08ace392SAndreas Gohr return $entries->first(); 59*08ace392SAndreas Gohr } 60*08ace392SAndreas Gohr 61*08ace392SAndreas Gohr /** @inheritDoc */ 62*08ace392SAndreas Gohr public function setPassword($username, $newpass, $oldpass = null) 63*08ace392SAndreas Gohr { 64*08ace392SAndreas Gohr if (!$this->autoAuth()) return false; 65*08ace392SAndreas Gohr 66*08ace392SAndreas Gohr $entry = $this->getUserEntry($username); 67*08ace392SAndreas Gohr if ($entry === null) { 68*08ace392SAndreas Gohr $this->error("User '$username' not found", __FILE__, __LINE__); 69*08ace392SAndreas Gohr return false; 70*08ace392SAndreas Gohr } 71*08ace392SAndreas Gohr 72*08ace392SAndreas Gohr if ($oldpass) { 73*08ace392SAndreas Gohr // if an old password is given, this is a self-service password change 74*08ace392SAndreas Gohr // this has to be executed as the user themselves, not as the admin 75*08ace392SAndreas Gohr if ($this->isAuthenticated !== $this->prepareBindUser($username)) { 76*08ace392SAndreas Gohr if (!$this->authenticate($username, $oldpass)) { 77*08ace392SAndreas Gohr $this->error("Old password for '$username' is wrong", __FILE__, __LINE__); 78*08ace392SAndreas Gohr return false; 79*08ace392SAndreas Gohr } 80*08ace392SAndreas Gohr } 81*08ace392SAndreas Gohr 82*08ace392SAndreas Gohr $entry->remove('unicodePwd', $this->encodePassword($oldpass)); 83*08ace392SAndreas Gohr $entry->add('unicodePwd', $this->encodePassword($newpass)); 84*08ace392SAndreas Gohr } else { 85*08ace392SAndreas Gohr // run as admin user 86*08ace392SAndreas Gohr $entry->set('unicodePwd', $this->encodePassword($newpass)); 87*08ace392SAndreas Gohr } 88*08ace392SAndreas Gohr 89*08ace392SAndreas Gohr try { 90*08ace392SAndreas Gohr $this->ldap->update($entry); 91*08ace392SAndreas Gohr } catch (OperationException $e) { 92*08ace392SAndreas Gohr $this->fatal($e); 93*08ace392SAndreas Gohr return false; 94*08ace392SAndreas Gohr } 95*08ace392SAndreas Gohr return true; 96b21740b4SAndreas Gohr } 971078ec26SAndreas Gohr 98b21740b4SAndreas Gohr /** @inheritDoc */ 99204fba68SAndreas Gohr public function getGroups($match = null, $filtermethod = self::FILTER_EQUAL) 100b21740b4SAndreas Gohr { 101b21740b4SAndreas Gohr if (!$this->autoAuth()) return []; 102b21740b4SAndreas Gohr 103b21740b4SAndreas Gohr $filter = Filters::and( 104b21740b4SAndreas Gohr Filters::equal('objectClass', 'group') 105b21740b4SAndreas Gohr ); 106b21740b4SAndreas Gohr if ($match !== null) { 107e7c3e817SAndreas Gohr // FIXME this is a workaround that removes regex anchors and quoting as passed by the groupuser plugin 108e7c3e817SAndreas Gohr // a proper fix requires splitbrain/dokuwiki#3028 to be implemented 109fce018daSAndreas Gohr $match = ltrim($match, '^'); 110fce018daSAndreas Gohr $match = rtrim($match, '$'); 111e7c3e817SAndreas Gohr $match = stripslashes($match); 112fce018daSAndreas Gohr 113b21740b4SAndreas Gohr $filter->add(Filters::$filtermethod('cn', $match)); 114b21740b4SAndreas Gohr } 115b21740b4SAndreas Gohr 116b21740b4SAndreas Gohr $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 117b21740b4SAndreas Gohr $search = Operations::search($filter, 'cn'); 118b21740b4SAndreas Gohr $paging = $this->ldap->paging($search); 119b21740b4SAndreas Gohr 120b21740b4SAndreas Gohr $groups = []; 121b21740b4SAndreas Gohr while ($paging->hasEntries()) { 122b21740b4SAndreas Gohr try { 123b21740b4SAndreas Gohr $entries = $paging->getEntries(); 124*08ace392SAndreas Gohr } catch (OperationException $e) { 125b21740b4SAndreas Gohr $this->fatal($e); 126b21740b4SAndreas Gohr return $groups; // we return what we got so far 127b21740b4SAndreas Gohr } 128b21740b4SAndreas Gohr 129b21740b4SAndreas Gohr foreach ($entries as $entry) { 130b21740b4SAndreas Gohr /** @var Entry $entry */ 131204fba68SAndreas Gohr $groups[$entry->getDn()->toString()] = $this->cleanGroup($this->attr2str($entry->get('cn'))); 132b21740b4SAndreas Gohr } 133b21740b4SAndreas Gohr } 134b21740b4SAndreas Gohr 1351b0eb9b3SAndreas Gohr asort($groups); 136b21740b4SAndreas Gohr return $groups; 137b21740b4SAndreas Gohr } 138b21740b4SAndreas Gohr 139b21740b4SAndreas Gohr /** 140b21740b4SAndreas Gohr * Fetch users matching the given filters 141b21740b4SAndreas Gohr * 142b21740b4SAndreas Gohr * @param array $match 143b21740b4SAndreas Gohr * @param string $filtermethod The method to use for filtering 144b21740b4SAndreas Gohr * @return array 145b21740b4SAndreas Gohr */ 146204fba68SAndreas Gohr public function getFilteredUsers($match, $filtermethod = self::FILTER_EQUAL) 147b21740b4SAndreas Gohr { 148b21740b4SAndreas Gohr if (!$this->autoAuth()) return []; 149b21740b4SAndreas Gohr 150b21740b4SAndreas Gohr $filter = Filters::and(Filters::equal('objectClass', 'user')); 151b21740b4SAndreas Gohr if (isset($match['user'])) { 152a1128cc0SAndreas Gohr $filter->add(Filters::$filtermethod('sAMAccountName', $this->simpleUser($match['user']))); 153b21740b4SAndreas Gohr } 154b21740b4SAndreas Gohr if (isset($match['name'])) { 155b21740b4SAndreas Gohr $filter->add(Filters::$filtermethod('displayName', $match['name'])); 156b21740b4SAndreas Gohr } 157b21740b4SAndreas Gohr if (isset($match['mail'])) { 158b21740b4SAndreas Gohr $filter->add(Filters::$filtermethod('mail', $match['mail'])); 159b21740b4SAndreas Gohr } 160b21740b4SAndreas Gohr if (isset($match['grps'])) { 161b21740b4SAndreas Gohr // memberOf can not be checked with a substring match, so we need to get the right groups first 162b21740b4SAndreas Gohr $groups = $this->getGroups($match['grps'], $filtermethod); 163e7339d5aSAndreas Gohr $groupDNs = array_keys($groups); 164e7339d5aSAndreas Gohr 165e7339d5aSAndreas Gohr if ($this->config['recursivegroups']) { 166e7339d5aSAndreas Gohr $gch = $this->getGroupHierarchyCache(); 167e7339d5aSAndreas Gohr foreach ($groupDNs as $dn) { 168e7339d5aSAndreas Gohr $groupDNs = array_merge($groupDNs, $gch->getChildren($dn)); 169e7339d5aSAndreas Gohr } 170e7339d5aSAndreas Gohr $groupDNs = array_unique($groupDNs); 171e7339d5aSAndreas Gohr } 172e7339d5aSAndreas Gohr 173b21740b4SAndreas Gohr $or = Filters::or(); 174e7339d5aSAndreas Gohr foreach ($groupDNs as $dn) { 175204fba68SAndreas Gohr // domain users membership is in primary group 176e7339d5aSAndreas Gohr if ($this->dn2group($dn) === $this->config['primarygroup']) { 177204fba68SAndreas Gohr $or->add(Filters::equal('primaryGroupID', 513)); 178204fba68SAndreas Gohr continue; 179204fba68SAndreas Gohr } 1807a36c1b4SAndreas Gohr // find members of this exact group 1817a36c1b4SAndreas Gohr $or->add(Filters::equal('memberOf', $dn)); 182b21740b4SAndreas Gohr } 183b21740b4SAndreas Gohr $filter->add($or); 184b21740b4SAndreas Gohr } 185e7339d5aSAndreas Gohr 186b21740b4SAndreas Gohr $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 1879c590892SAndreas Gohr $attributes = $this->userAttributes(); 1889c590892SAndreas Gohr $search = Operations::search($filter, ...$attributes); 189b21740b4SAndreas Gohr $paging = $this->ldap->paging($search); 190b21740b4SAndreas Gohr 191b21740b4SAndreas Gohr $users = []; 192b21740b4SAndreas Gohr while ($paging->hasEntries()) { 193b21740b4SAndreas Gohr try { 194b21740b4SAndreas Gohr $entries = $paging->getEntries(); 195*08ace392SAndreas Gohr } catch (OperationException $e) { 196b21740b4SAndreas Gohr $this->fatal($e); 19780ac552fSAndreas Gohr break; // we abort and return what we have so far 198b21740b4SAndreas Gohr } 199b21740b4SAndreas Gohr 200b21740b4SAndreas Gohr foreach ($entries as $entry) { 201*08ace392SAndreas Gohr $userinfo = $this->entry2User($entry); 202746af42cSAndreas Gohr $users[$userinfo['user']] = $userinfo; 203b21740b4SAndreas Gohr } 204b21740b4SAndreas Gohr } 205b21740b4SAndreas Gohr 2061b0eb9b3SAndreas Gohr ksort($users); 207b21740b4SAndreas Gohr return $users; 208b21740b4SAndreas Gohr } 209b21740b4SAndreas Gohr 210a1128cc0SAndreas Gohr /** @inheritDoc */ 211a1128cc0SAndreas Gohr public function cleanUser($user) 21280ac552fSAndreas Gohr { 213a1128cc0SAndreas Gohr return $this->simpleUser($user); 21480ac552fSAndreas Gohr } 21580ac552fSAndreas Gohr 216a1128cc0SAndreas Gohr /** @inheritDoc */ 217a1128cc0SAndreas Gohr public function cleanGroup($group) 218a1128cc0SAndreas Gohr { 219a1128cc0SAndreas Gohr return PhpString::strtolower($group); 220a1128cc0SAndreas Gohr } 221a1128cc0SAndreas Gohr 222a1128cc0SAndreas Gohr /** @inheritDoc */ 223a1128cc0SAndreas Gohr public function prepareBindUser($user) 224a1128cc0SAndreas Gohr { 225*08ace392SAndreas Gohr // add account suffix 226*08ace392SAndreas Gohr return $this->qualifiedUser($user); 22780ac552fSAndreas Gohr } 22880ac552fSAndreas Gohr 22980ac552fSAndreas Gohr /** 230e7339d5aSAndreas Gohr * Initializes the Group Cache for nested groups 231e7339d5aSAndreas Gohr * 232e7339d5aSAndreas Gohr * @return GroupHierarchyCache 233e7339d5aSAndreas Gohr */ 234e7339d5aSAndreas Gohr public function getGroupHierarchyCache() 235e7339d5aSAndreas Gohr { 236e7339d5aSAndreas Gohr if ($this->gch === null) { 237e7339d5aSAndreas Gohr if (!$this->autoAuth()) return null; 2385dcabedaSAndreas Gohr $this->gch = new GroupHierarchyCache($this->ldap, $this->config['usefscache']); 239e7339d5aSAndreas Gohr } 240e7339d5aSAndreas Gohr return $this->gch; 241e7339d5aSAndreas Gohr } 242e7339d5aSAndreas Gohr 243e7339d5aSAndreas Gohr /** 244a1128cc0SAndreas Gohr * userPrincipalName in the form <user>@<suffix> 245*08ace392SAndreas Gohr * 246*08ace392SAndreas Gohr * @param string $user 247*08ace392SAndreas Gohr * @return string 24880ac552fSAndreas Gohr */ 249a1128cc0SAndreas Gohr protected function qualifiedUser($user) 250a1128cc0SAndreas Gohr { 251a1128cc0SAndreas Gohr $user = $this->simpleUser($user); // strip any existing qualifiers 252a1128cc0SAndreas Gohr if (!$this->config['suffix']) { 253a1128cc0SAndreas Gohr $this->error('No account suffix set. Logins may fail.', __FILE__, __LINE__); 254a1128cc0SAndreas Gohr } 255a1128cc0SAndreas Gohr 256a1128cc0SAndreas Gohr return $user . '@' . $this->config['suffix']; 257a1128cc0SAndreas Gohr } 258a1128cc0SAndreas Gohr 259a1128cc0SAndreas Gohr /** 260a1128cc0SAndreas Gohr * Removes the account suffix from the given user. Should match the SAMAccountName 261*08ace392SAndreas Gohr * 262*08ace392SAndreas Gohr * @param string $user 263*08ace392SAndreas Gohr * @return string 264a1128cc0SAndreas Gohr */ 265a1128cc0SAndreas Gohr protected function simpleUser($user) 26680ac552fSAndreas Gohr { 26780ac552fSAndreas Gohr $user = PhpString::strtolower($user); 268a1128cc0SAndreas Gohr $user = preg_replace('/@.*$/', '', $user); 269a1128cc0SAndreas Gohr $user = preg_replace('/^.*\\\\/', '', $user); 27080ac552fSAndreas Gohr return $user; 27180ac552fSAndreas Gohr } 27280ac552fSAndreas Gohr 27380ac552fSAndreas Gohr /** 274b21740b4SAndreas Gohr * Transform an LDAP entry to a user info array 275b21740b4SAndreas Gohr * 276b21740b4SAndreas Gohr * @param Entry $entry 277b21740b4SAndreas Gohr * @return array 278b21740b4SAndreas Gohr */ 279b21740b4SAndreas Gohr protected function entry2User(Entry $entry) 280b21740b4SAndreas Gohr { 281b914569fSAndreas Gohr $user = [ 282a1128cc0SAndreas Gohr 'user' => $this->simpleUser($this->attr2str($entry->get('sAMAccountName'))), 2831078ec26SAndreas Gohr 'name' => $this->attr2str($entry->get('DisplayName')) ?: $this->attr2str($entry->get('Name')), 2841078ec26SAndreas Gohr 'mail' => $this->attr2str($entry->get('mail')), 2851078ec26SAndreas Gohr 'dn' => $entry->getDn()->toString(), 2861078ec26SAndreas Gohr 'grps' => $this->getUserGroups($entry), // we always return groups because its currently inexpensive 2871078ec26SAndreas Gohr ]; 288b914569fSAndreas Gohr 289b914569fSAndreas Gohr // get additional attributes 290b914569fSAndreas Gohr foreach ($this->config['attributes'] as $attr) { 291b914569fSAndreas Gohr $user[$attr] = $this->attr2str($entry->get($attr)); 292b914569fSAndreas Gohr } 293b914569fSAndreas Gohr 294b914569fSAndreas Gohr return $user; 2951078ec26SAndreas Gohr } 2961078ec26SAndreas Gohr 2971078ec26SAndreas Gohr /** 2981078ec26SAndreas Gohr * Get the list of groups the given user is member of 2991078ec26SAndreas Gohr * 3001078ec26SAndreas Gohr * This method currently does no LDAP queries and thus is inexpensive. 3011078ec26SAndreas Gohr * 3021078ec26SAndreas Gohr * @param Entry $userentry 3031078ec26SAndreas Gohr * @return array 3041078ec26SAndreas Gohr */ 3051078ec26SAndreas Gohr protected function getUserGroups(Entry $userentry) 3061078ec26SAndreas Gohr { 307e7339d5aSAndreas Gohr $groups = []; 308e7339d5aSAndreas Gohr 309e7339d5aSAndreas Gohr if ($userentry->has('memberOf')) { 310e7339d5aSAndreas Gohr $groupDNs = $userentry->get('memberOf')->getValues(); 311e7339d5aSAndreas Gohr 312e7339d5aSAndreas Gohr if ($this->config['recursivegroups']) { 313e7339d5aSAndreas Gohr $gch = $this->getGroupHierarchyCache(); 314e7339d5aSAndreas Gohr foreach ($groupDNs as $dn) { 315e7339d5aSAndreas Gohr $groupDNs = array_merge($groupDNs, $gch->getParents($dn)); 316e7339d5aSAndreas Gohr } 317e7339d5aSAndreas Gohr 318e7339d5aSAndreas Gohr $groupDNs = array_unique($groupDNs); 319e7339d5aSAndreas Gohr } 320e7339d5aSAndreas Gohr $groups = array_map([$this, 'dn2group'], $groupDNs); 321e7339d5aSAndreas Gohr } 322e7339d5aSAndreas Gohr 323e7339d5aSAndreas Gohr $groups[] = $this->config['defaultgroup']; // always add default 3241078ec26SAndreas Gohr 3251078ec26SAndreas Gohr // resolving the primary group in AD is complicated but basically never needed 3261078ec26SAndreas Gohr // http://support.microsoft.com/?kbid=321360 3271078ec26SAndreas Gohr $gid = $userentry->get('primaryGroupID')->firstValue(); 3281078ec26SAndreas Gohr if ($gid == 513) { 329e7339d5aSAndreas Gohr $groups[] = $this->cleanGroup($this->config['primarygroup']); 33051e92298SAndreas Gohr } 33151e92298SAndreas Gohr 332f17bb68bSAndreas Gohr sort($groups); 333f17bb68bSAndreas Gohr return $groups; 33451e92298SAndreas Gohr } 33551e92298SAndreas Gohr 3369c590892SAndreas Gohr /** @inheritDoc */ 3379c590892SAndreas Gohr protected function userAttributes() 3389c590892SAndreas Gohr { 3399c590892SAndreas Gohr $attr = parent::userAttributes(); 340a1128cc0SAndreas Gohr $attr[] = new Attribute('sAMAccountName'); 3419c590892SAndreas Gohr $attr[] = new Attribute('Name'); 3429c590892SAndreas Gohr $attr[] = new Attribute('primaryGroupID'); 3439c590892SAndreas Gohr $attr[] = new Attribute('memberOf'); 3449c590892SAndreas Gohr 3459c590892SAndreas Gohr return $attr; 3469c590892SAndreas Gohr } 347e7339d5aSAndreas Gohr 348e7339d5aSAndreas Gohr /** 349e7339d5aSAndreas Gohr * Extract the group name from the DN 350e7339d5aSAndreas Gohr * 351e7339d5aSAndreas Gohr * @param string $dn 352e7339d5aSAndreas Gohr * @return string 353e7339d5aSAndreas Gohr */ 354e7339d5aSAndreas Gohr protected function dn2group($dn) 355e7339d5aSAndreas Gohr { 356e7339d5aSAndreas Gohr list($cn) = explode(',', $dn, 2); 357e7339d5aSAndreas Gohr return $this->cleanGroup(substr($cn, 3)); 358e7339d5aSAndreas Gohr } 359*08ace392SAndreas Gohr 360*08ace392SAndreas Gohr /** 361*08ace392SAndreas Gohr * Encode a password for transmission over LDAP 362*08ace392SAndreas Gohr * 363*08ace392SAndreas Gohr * Passwords are encoded as UTF-16LE strings encapsulated in quotes. 364*08ace392SAndreas Gohr * 365*08ace392SAndreas Gohr * @param string $password The password to encode 366*08ace392SAndreas Gohr * @return string 367*08ace392SAndreas Gohr */ 368*08ace392SAndreas Gohr protected function encodePassword($password) 369*08ace392SAndreas Gohr { 370*08ace392SAndreas Gohr $password = "\"" . $password . "\""; 371*08ace392SAndreas Gohr 372*08ace392SAndreas Gohr if (function_exists('iconv')) { 373*08ace392SAndreas Gohr $adpassword = iconv('UTF-8', 'UTF-16LE', $password); 374*08ace392SAndreas Gohr } elseif (function_exists('mb_convert_encoding')) { 375*08ace392SAndreas Gohr $adpassword = mb_convert_encoding($password, "UTF-16LE", "UTF-8"); 376*08ace392SAndreas Gohr } else { 377*08ace392SAndreas Gohr // this will only work for ASCII7 passwords 378*08ace392SAndreas Gohr $adpassword = ''; 379*08ace392SAndreas Gohr for ($i = 0; $i < strlen($password); $i++) { 380*08ace392SAndreas Gohr $adpassword .= "$password[$i]\000"; 381*08ace392SAndreas Gohr } 382*08ace392SAndreas Gohr } 383*08ace392SAndreas Gohr return $adpassword; 384*08ace392SAndreas Gohr } 3851078ec26SAndreas Gohr} 386