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; 105a3b9122SAndreas Gohruse FreeDSx\Ldap\Exception\ProtocolException; 111078ec26SAndreas Gohruse FreeDSx\Ldap\Operations; 121078ec26SAndreas Gohruse FreeDSx\Ldap\Search\Filters; 137a36c1b4SAndreas Gohruse FreeDSx\Ldap\Search\Paging; 147a36c1b4SAndreas Gohruse PHP_CodeSniffer\Filters\Filter; 151078ec26SAndreas Gohr 16f17bb68bSAndreas Gohr/** 17f17bb68bSAndreas Gohr * Implement Active Directory Specifics 18f17bb68bSAndreas Gohr */ 191078ec26SAndreas Gohrclass ADClient extends Client 201078ec26SAndreas Gohr{ 21f17bb68bSAndreas Gohr // see https://docs.microsoft.com/en-us/windows/win32/adsi/search-filter-syntax 22f17bb68bSAndreas Gohr const LDAP_MATCHING_RULE_IN_CHAIN = '1.2.840.113556.1.4.1941'; 231078ec26SAndreas Gohr 241078ec26SAndreas Gohr /** @inheritDoc */ 251078ec26SAndreas Gohr public function getUser($username, $fetchgroups = true) 261078ec26SAndreas Gohr { 271078ec26SAndreas Gohr if (!$this->autoAuth()) return null; 28a1128cc0SAndreas Gohr $username = $this->simpleUser($username); 291078ec26SAndreas Gohr 301078ec26SAndreas Gohr $filter = Filters::and( 311078ec26SAndreas Gohr Filters::equal('objectClass', 'user'), 32a1128cc0SAndreas Gohr Filters::equal('sAMAccountName', $this->simpleUser($username)) 331078ec26SAndreas Gohr ); 34b21740b4SAndreas Gohr $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 351078ec26SAndreas Gohr 361078ec26SAndreas Gohr try { 371078ec26SAndreas Gohr /** @var Entries $entries */ 389c590892SAndreas Gohr $attributes = $this->userAttributes(); 399c590892SAndreas Gohr $entries = $this->ldap->search(Operations::search($filter, ...$attributes)); 401078ec26SAndreas Gohr } catch (OperationException $e) { 41b21740b4SAndreas Gohr $this->fatal($e); 421078ec26SAndreas Gohr return null; 431078ec26SAndreas Gohr } 441078ec26SAndreas Gohr if ($entries->count() !== 1) return null; 451078ec26SAndreas Gohr $entry = $entries->first(); 46b21740b4SAndreas Gohr return $this->entry2User($entry); 47b21740b4SAndreas Gohr } 481078ec26SAndreas Gohr 49b21740b4SAndreas Gohr /** @inheritDoc */ 50204fba68SAndreas Gohr public function getGroups($match = null, $filtermethod = self::FILTER_EQUAL) 51b21740b4SAndreas Gohr { 52b21740b4SAndreas Gohr if (!$this->autoAuth()) return []; 53b21740b4SAndreas Gohr 54b21740b4SAndreas Gohr $filter = Filters::and( 55b21740b4SAndreas Gohr Filters::equal('objectClass', 'group') 56b21740b4SAndreas Gohr ); 57b21740b4SAndreas Gohr if ($match !== null) { 58e7c3e817SAndreas Gohr // FIXME this is a workaround that removes regex anchors and quoting as passed by the groupuser plugin 59e7c3e817SAndreas Gohr // a proper fix requires splitbrain/dokuwiki#3028 to be implemented 60fce018daSAndreas Gohr $match = ltrim($match, '^'); 61fce018daSAndreas Gohr $match = rtrim($match, '$'); 62e7c3e817SAndreas Gohr $match = stripslashes($match); 63fce018daSAndreas Gohr 64b21740b4SAndreas Gohr $filter->add(Filters::$filtermethod('cn', $match)); 65b21740b4SAndreas Gohr } 66b21740b4SAndreas Gohr 67b21740b4SAndreas Gohr $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 68b21740b4SAndreas Gohr $search = Operations::search($filter, 'cn'); 69b21740b4SAndreas Gohr $paging = $this->ldap->paging($search); 70b21740b4SAndreas Gohr 71b21740b4SAndreas Gohr $groups = []; 72b21740b4SAndreas Gohr while ($paging->hasEntries()) { 73b21740b4SAndreas Gohr try { 74b21740b4SAndreas Gohr $entries = $paging->getEntries(); 75b21740b4SAndreas Gohr } catch (ProtocolException $e) { 76b21740b4SAndreas Gohr $this->fatal($e); 77b21740b4SAndreas Gohr return $groups; // we return what we got so far 78b21740b4SAndreas Gohr } 79b21740b4SAndreas Gohr 80b21740b4SAndreas Gohr foreach ($entries as $entry) { 81b21740b4SAndreas Gohr /** @var Entry $entry */ 82204fba68SAndreas Gohr $groups[$entry->getDn()->toString()] = $this->cleanGroup($this->attr2str($entry->get('cn'))); 83b21740b4SAndreas Gohr } 84b21740b4SAndreas Gohr } 85b21740b4SAndreas Gohr 861b0eb9b3SAndreas Gohr asort($groups); 87b21740b4SAndreas Gohr return $groups; 88b21740b4SAndreas Gohr } 89b21740b4SAndreas Gohr 90b21740b4SAndreas Gohr /** 91b21740b4SAndreas Gohr * Fetch users matching the given filters 92b21740b4SAndreas Gohr * 93b21740b4SAndreas Gohr * @param array $match 94b21740b4SAndreas Gohr * @param string $filtermethod The method to use for filtering 95b21740b4SAndreas Gohr * @return array 96b21740b4SAndreas Gohr */ 97204fba68SAndreas Gohr public function getFilteredUsers($match, $filtermethod = self::FILTER_EQUAL) 98b21740b4SAndreas Gohr { 99b21740b4SAndreas Gohr if (!$this->autoAuth()) return []; 100b21740b4SAndreas Gohr 101b21740b4SAndreas Gohr $filter = Filters::and(Filters::equal('objectClass', 'user')); 102b21740b4SAndreas Gohr if (isset($match['user'])) { 103a1128cc0SAndreas Gohr $filter->add(Filters::$filtermethod('sAMAccountName', $this->simpleUser($match['user']))); 104b21740b4SAndreas Gohr } 105b21740b4SAndreas Gohr if (isset($match['name'])) { 106b21740b4SAndreas Gohr $filter->add(Filters::$filtermethod('displayName', $match['name'])); 107b21740b4SAndreas Gohr } 108b21740b4SAndreas Gohr if (isset($match['mail'])) { 109b21740b4SAndreas Gohr $filter->add(Filters::$filtermethod('mail', $match['mail'])); 110b21740b4SAndreas Gohr } 111b21740b4SAndreas Gohr if (isset($match['grps'])) { 112b21740b4SAndreas Gohr // memberOf can not be checked with a substring match, so we need to get the right groups first 113b21740b4SAndreas Gohr $groups = $this->getGroups($match['grps'], $filtermethod); 114b21740b4SAndreas Gohr $or = Filters::or(); 115b21740b4SAndreas Gohr foreach ($groups as $dn => $group) { 116204fba68SAndreas Gohr // domain users membership is in primary group 117c2500b44SAndreas Gohr if ($group === $this->config['primarygroup']) { 118204fba68SAndreas Gohr $or->add(Filters::equal('primaryGroupID', 513)); 119204fba68SAndreas Gohr continue; 120204fba68SAndreas Gohr } 1217a36c1b4SAndreas Gohr // find members of this exact group 1227a36c1b4SAndreas Gohr $or->add(Filters::equal('memberOf', $dn)); 123b21740b4SAndreas Gohr } 1247a36c1b4SAndreas Gohr 1257a36c1b4SAndreas Gohr // find members of the nested groups 1267a36c1b4SAndreas Gohr // we resolve the nested groups first, before we're running the user query as this is usually 1277a36c1b4SAndreas Gohr // faster than doing a full recursive user query. Unfortunately it is still pretty slow 1287a36c1b4SAndreas Gohr if ($this->config['recursivegroups']) { 1297a36c1b4SAndreas Gohr $paging = $this->resolveRecursiveMembership(array_keys($groups), 'memberOf'); 1307a36c1b4SAndreas Gohr while ($paging->hasEntries()) { 1317a36c1b4SAndreas Gohr try { 1327a36c1b4SAndreas Gohr $entries = $paging->getEntries(); 1337a36c1b4SAndreas Gohr } catch (ProtocolException $e) { 1347a36c1b4SAndreas Gohr continue; 1357a36c1b4SAndreas Gohr } 1367a36c1b4SAndreas Gohr /** @var Entry $entry */ 1377a36c1b4SAndreas Gohr foreach ($entries as $entry) { 1387a36c1b4SAndreas Gohr $or->add(Filters::equal('memberOf', (string)$entry->getDn())); 1397a36c1b4SAndreas Gohr } 1407a36c1b4SAndreas Gohr } 1417a36c1b4SAndreas Gohr } 1427a36c1b4SAndreas Gohr 143b21740b4SAndreas Gohr $filter->add($or); 144b21740b4SAndreas Gohr } 145b21740b4SAndreas Gohr $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 1469c590892SAndreas Gohr $attributes = $this->userAttributes(); 1479c590892SAndreas Gohr $search = Operations::search($filter, ...$attributes); 148b21740b4SAndreas Gohr $paging = $this->ldap->paging($search); 149b21740b4SAndreas Gohr 150b21740b4SAndreas Gohr $users = []; 151b21740b4SAndreas Gohr while ($paging->hasEntries()) { 152b21740b4SAndreas Gohr try { 153b21740b4SAndreas Gohr $entries = $paging->getEntries(); 154b21740b4SAndreas Gohr } catch (ProtocolException $e) { 155b21740b4SAndreas Gohr $this->fatal($e); 15680ac552fSAndreas Gohr break; // we abort and return what we have so far 157b21740b4SAndreas Gohr } 158b21740b4SAndreas Gohr 159b21740b4SAndreas Gohr foreach ($entries as $entry) { 160*746af42cSAndreas Gohr $userinfo = $this->entry2User($entry, false); 161*746af42cSAndreas Gohr $users[$userinfo['user']] = $userinfo; 162b21740b4SAndreas Gohr } 163b21740b4SAndreas Gohr } 164b21740b4SAndreas Gohr 1651b0eb9b3SAndreas Gohr ksort($users); 166b21740b4SAndreas Gohr return $users; 167b21740b4SAndreas Gohr } 168b21740b4SAndreas Gohr 169a1128cc0SAndreas Gohr /** @inheritDoc */ 170a1128cc0SAndreas Gohr public function cleanUser($user) 17180ac552fSAndreas Gohr { 172a1128cc0SAndreas Gohr return $this->simpleUser($user); 17380ac552fSAndreas Gohr } 17480ac552fSAndreas Gohr 175a1128cc0SAndreas Gohr /** @inheritDoc */ 176a1128cc0SAndreas Gohr public function cleanGroup($group) 177a1128cc0SAndreas Gohr { 178a1128cc0SAndreas Gohr return PhpString::strtolower($group); 179a1128cc0SAndreas Gohr } 180a1128cc0SAndreas Gohr 181a1128cc0SAndreas Gohr /** @inheritDoc */ 182a1128cc0SAndreas Gohr public function prepareBindUser($user) 183a1128cc0SAndreas Gohr { 184a1128cc0SAndreas Gohr $user = $this->qualifiedUser($user); // add account suffix 185a1128cc0SAndreas Gohr return $user; 18680ac552fSAndreas Gohr } 18780ac552fSAndreas Gohr 18880ac552fSAndreas Gohr /** 18980ac552fSAndreas Gohr * @inheritDoc 190a1128cc0SAndreas Gohr * userPrincipalName in the form <user>@<suffix> 19180ac552fSAndreas Gohr */ 192a1128cc0SAndreas Gohr protected function qualifiedUser($user) 193a1128cc0SAndreas Gohr { 194a1128cc0SAndreas Gohr $user = $this->simpleUser($user); // strip any existing qualifiers 195a1128cc0SAndreas Gohr if (!$this->config['suffix']) { 196a1128cc0SAndreas Gohr $this->error('No account suffix set. Logins may fail.', __FILE__, __LINE__); 197a1128cc0SAndreas Gohr } 198a1128cc0SAndreas Gohr 199a1128cc0SAndreas Gohr return $user . '@' . $this->config['suffix']; 200a1128cc0SAndreas Gohr } 201a1128cc0SAndreas Gohr 202a1128cc0SAndreas Gohr /** 203a1128cc0SAndreas Gohr * @inheritDoc 204a1128cc0SAndreas Gohr * Removes the account suffix from the given user. Should match the SAMAccountName 205a1128cc0SAndreas Gohr */ 206a1128cc0SAndreas Gohr protected function simpleUser($user) 20780ac552fSAndreas Gohr { 20880ac552fSAndreas Gohr $user = PhpString::strtolower($user); 209a1128cc0SAndreas Gohr $user = preg_replace('/@.*$/', '', $user); 210a1128cc0SAndreas Gohr $user = preg_replace('/^.*\\\\/', '', $user); 21180ac552fSAndreas Gohr return $user; 21280ac552fSAndreas Gohr } 21380ac552fSAndreas Gohr 21480ac552fSAndreas Gohr /** 215b21740b4SAndreas Gohr * Transform an LDAP entry to a user info array 216b21740b4SAndreas Gohr * 217b21740b4SAndreas Gohr * @param Entry $entry 218b21740b4SAndreas Gohr * @return array 219b21740b4SAndreas Gohr */ 220b21740b4SAndreas Gohr protected function entry2User(Entry $entry) 221b21740b4SAndreas Gohr { 222b914569fSAndreas Gohr $user = [ 223a1128cc0SAndreas Gohr 'user' => $this->simpleUser($this->attr2str($entry->get('sAMAccountName'))), 2241078ec26SAndreas Gohr 'name' => $this->attr2str($entry->get('DisplayName')) ?: $this->attr2str($entry->get('Name')), 2251078ec26SAndreas Gohr 'mail' => $this->attr2str($entry->get('mail')), 2261078ec26SAndreas Gohr 'dn' => $entry->getDn()->toString(), 2271078ec26SAndreas Gohr 'grps' => $this->getUserGroups($entry), // we always return groups because its currently inexpensive 2281078ec26SAndreas Gohr ]; 229b914569fSAndreas Gohr 230b914569fSAndreas Gohr // get additional attributes 231b914569fSAndreas Gohr foreach ($this->config['attributes'] as $attr) { 232b914569fSAndreas Gohr $user[$attr] = $this->attr2str($entry->get($attr)); 233b914569fSAndreas Gohr } 234b914569fSAndreas Gohr 235b914569fSAndreas Gohr return $user; 2361078ec26SAndreas Gohr } 2371078ec26SAndreas Gohr 2381078ec26SAndreas Gohr /** 2391078ec26SAndreas Gohr * Get the list of groups the given user is member of 2401078ec26SAndreas Gohr * 2411078ec26SAndreas Gohr * This method currently does no LDAP queries and thus is inexpensive. 2421078ec26SAndreas Gohr * 2431078ec26SAndreas Gohr * @param Entry $userentry 2441078ec26SAndreas Gohr * @return array 2451078ec26SAndreas Gohr */ 2461078ec26SAndreas Gohr protected function getUserGroups(Entry $userentry) 2471078ec26SAndreas Gohr { 2481078ec26SAndreas Gohr $groups = [$this->config['defaultgroup']]; // always add default 2491078ec26SAndreas Gohr 2501078ec26SAndreas Gohr // resolving the primary group in AD is complicated but basically never needed 2511078ec26SAndreas Gohr // http://support.microsoft.com/?kbid=321360 2521078ec26SAndreas Gohr $gid = $userentry->get('primaryGroupID')->firstValue(); 2531078ec26SAndreas Gohr if ($gid == 513) { 254a1128cc0SAndreas Gohr $groups[] = $this->cleanGroup('domain users'); 2551078ec26SAndreas Gohr } 2561078ec26SAndreas Gohr 257f17bb68bSAndreas Gohr if ($this->config['recursivegroups']) { 258f17bb68bSAndreas Gohr // we do an additional query for the user's groups asking the AD server to resolve nested 259f17bb68bSAndreas Gohr // groups for us 2607a36c1b4SAndreas Gohr $paging = $this->resolveRecursiveMembership([(string)$userentry->getDn()]); 26151e92298SAndreas Gohr while ($paging->hasEntries()) { 26251e92298SAndreas Gohr try { 26351e92298SAndreas Gohr $entries = $paging->getEntries(); 26451e92298SAndreas Gohr } catch (ProtocolException $e) { 265f17bb68bSAndreas Gohr return $groups; // return what we have 26651e92298SAndreas Gohr } 26751e92298SAndreas Gohr /** @var Entry $entry */ 26851e92298SAndreas Gohr foreach ($entries as $entry) { 269f17bb68bSAndreas Gohr $groups[] = $this->cleanGroup(($entry->get('name')->getValues())[0]); 27051e92298SAndreas Gohr } 27151e92298SAndreas Gohr } 27251e92298SAndreas Gohr 273f17bb68bSAndreas Gohr } elseif ($userentry->has('memberOf')) { 274f17bb68bSAndreas Gohr // we simply take the first CN= part of the group DN and return it as the group name 275f17bb68bSAndreas Gohr // this should be correct for ActiveDirectory and saves us additional LDAP queries 276f17bb68bSAndreas Gohr foreach ($userentry->get('memberOf')->getValues() as $dn) { 277f17bb68bSAndreas Gohr list($cn) = explode(',', $dn, 2); 278f17bb68bSAndreas Gohr $groups[] = $this->cleanGroup(substr($cn, 3)); 279f17bb68bSAndreas Gohr } 28051e92298SAndreas Gohr } 28151e92298SAndreas Gohr 282f17bb68bSAndreas Gohr sort($groups); 283f17bb68bSAndreas Gohr return $groups; 28451e92298SAndreas Gohr } 28551e92298SAndreas Gohr 2867a36c1b4SAndreas Gohr /** 2877a36c1b4SAndreas Gohr * Get nested groups for the given DN 2887a36c1b4SAndreas Gohr * 2897a36c1b4SAndreas Gohr * @todo this is slow, doing many recursive calls might actually be faster 2907a36c1b4SAndreas Gohr * @see https://stackoverflow.com/q/40024425 2917a36c1b4SAndreas Gohr * @param string[] $DNs this can either be a user or group dn 2927a36c1b4SAndreas Gohr * @param string $attribute Are we looking down (member) or up (memberOf)? 2937a36c1b4SAndreas Gohr * @return Paging|null 2947a36c1b4SAndreas Gohr */ 2957a36c1b4SAndreas Gohr protected function resolveRecursiveMembership($DNs, $attribute='member') 2967a36c1b4SAndreas Gohr { 2977a36c1b4SAndreas Gohr if (!$this->autoAuth()) return null; 2987a36c1b4SAndreas Gohr 2997a36c1b4SAndreas Gohr $filter = Filters::or(); 3007a36c1b4SAndreas Gohr foreach ($DNs as $dn) { 3017a36c1b4SAndreas Gohr $filter->add( 3027a36c1b4SAndreas Gohr Filters::extensible($attribute, $dn, self::LDAP_MATCHING_RULE_IN_CHAIN, true) 3037a36c1b4SAndreas Gohr ); 3047a36c1b4SAndreas Gohr } 3057a36c1b4SAndreas Gohr $filter = Filters::and( 3067a36c1b4SAndreas Gohr Filters::equal('objectCategory', 'group'), 3077a36c1b4SAndreas Gohr $filter 3087a36c1b4SAndreas Gohr ); 3097a36c1b4SAndreas Gohr 3107a36c1b4SAndreas Gohr $search = Operations::search($filter, 'name'); 3117a36c1b4SAndreas Gohr $paging = $this->ldap->paging($search); 3127a36c1b4SAndreas Gohr return $paging; 3137a36c1b4SAndreas Gohr } 3147a36c1b4SAndreas Gohr 3159c590892SAndreas Gohr /** @inheritDoc */ 3169c590892SAndreas Gohr protected function userAttributes() 3179c590892SAndreas Gohr { 3189c590892SAndreas Gohr $attr = parent::userAttributes(); 319a1128cc0SAndreas Gohr $attr[] = new Attribute('sAMAccountName'); 3209c590892SAndreas Gohr $attr[] = new Attribute('Name'); 3219c590892SAndreas Gohr $attr[] = new Attribute('primaryGroupID'); 3229c590892SAndreas Gohr $attr[] = new Attribute('memberOf'); 3239c590892SAndreas Gohr 3249c590892SAndreas Gohr return $attr; 3259c590892SAndreas Gohr } 3261078ec26SAndreas Gohr} 327