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; 131078ec26SAndreas Gohr 14*f17bb68bSAndreas Gohr/** 15*f17bb68bSAndreas Gohr * Implement Active Directory Specifics 16*f17bb68bSAndreas Gohr */ 171078ec26SAndreas Gohrclass ADClient extends Client 181078ec26SAndreas Gohr{ 19*f17bb68bSAndreas Gohr // see https://docs.microsoft.com/en-us/windows/win32/adsi/search-filter-syntax 20*f17bb68bSAndreas Gohr const LDAP_MATCHING_RULE_IN_CHAIN = '1.2.840.113556.1.4.1941'; 211078ec26SAndreas Gohr 221078ec26SAndreas Gohr /** @inheritDoc */ 231078ec26SAndreas Gohr public function getUser($username, $fetchgroups = true) 241078ec26SAndreas Gohr { 251078ec26SAndreas Gohr if (!$this->autoAuth()) return null; 26a1128cc0SAndreas Gohr $username = $this->simpleUser($username); 271078ec26SAndreas Gohr 281078ec26SAndreas Gohr $filter = Filters::and( 291078ec26SAndreas Gohr Filters::equal('objectClass', 'user'), 30a1128cc0SAndreas Gohr Filters::equal('sAMAccountName', $this->simpleUser($username)) 311078ec26SAndreas Gohr ); 32b21740b4SAndreas Gohr $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 331078ec26SAndreas Gohr 341078ec26SAndreas Gohr try { 351078ec26SAndreas Gohr /** @var Entries $entries */ 369c590892SAndreas Gohr $attributes = $this->userAttributes(); 379c590892SAndreas Gohr $entries = $this->ldap->search(Operations::search($filter, ...$attributes)); 381078ec26SAndreas Gohr } catch (OperationException $e) { 39b21740b4SAndreas Gohr $this->fatal($e); 401078ec26SAndreas Gohr return null; 411078ec26SAndreas Gohr } 421078ec26SAndreas Gohr if ($entries->count() !== 1) return null; 431078ec26SAndreas Gohr $entry = $entries->first(); 44b21740b4SAndreas Gohr return $this->entry2User($entry); 45b21740b4SAndreas Gohr } 461078ec26SAndreas Gohr 47b21740b4SAndreas Gohr /** @inheritDoc */ 48204fba68SAndreas Gohr public function getGroups($match = null, $filtermethod = self::FILTER_EQUAL) 49b21740b4SAndreas Gohr { 50b21740b4SAndreas Gohr if (!$this->autoAuth()) return []; 51b21740b4SAndreas Gohr 52b21740b4SAndreas Gohr $filter = Filters::and( 53b21740b4SAndreas Gohr Filters::equal('objectClass', 'group') 54b21740b4SAndreas Gohr ); 55b21740b4SAndreas Gohr if ($match !== null) { 56e7c3e817SAndreas Gohr // FIXME this is a workaround that removes regex anchors and quoting as passed by the groupuser plugin 57e7c3e817SAndreas Gohr // a proper fix requires splitbrain/dokuwiki#3028 to be implemented 58fce018daSAndreas Gohr $match = ltrim($match, '^'); 59fce018daSAndreas Gohr $match = rtrim($match, '$'); 60e7c3e817SAndreas Gohr $match = stripslashes($match); 61fce018daSAndreas Gohr 62b21740b4SAndreas Gohr $filter->add(Filters::$filtermethod('cn', $match)); 63b21740b4SAndreas Gohr } 64b21740b4SAndreas Gohr 65b21740b4SAndreas Gohr $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 66b21740b4SAndreas Gohr $search = Operations::search($filter, 'cn'); 67b21740b4SAndreas Gohr $paging = $this->ldap->paging($search); 68b21740b4SAndreas Gohr 69b21740b4SAndreas Gohr $groups = []; 70b21740b4SAndreas Gohr while ($paging->hasEntries()) { 71b21740b4SAndreas Gohr try { 72b21740b4SAndreas Gohr $entries = $paging->getEntries(); 73b21740b4SAndreas Gohr } catch (ProtocolException $e) { 74b21740b4SAndreas Gohr $this->fatal($e); 75b21740b4SAndreas Gohr return $groups; // we return what we got so far 76b21740b4SAndreas Gohr } 77b21740b4SAndreas Gohr 78b21740b4SAndreas Gohr foreach ($entries as $entry) { 79b21740b4SAndreas Gohr /** @var Entry $entry */ 80204fba68SAndreas Gohr $groups[$entry->getDn()->toString()] = $this->cleanGroup($this->attr2str($entry->get('cn'))); 81b21740b4SAndreas Gohr } 82b21740b4SAndreas Gohr } 83b21740b4SAndreas Gohr 841b0eb9b3SAndreas Gohr asort($groups); 85b21740b4SAndreas Gohr return $groups; 86b21740b4SAndreas Gohr } 87b21740b4SAndreas Gohr 88b21740b4SAndreas Gohr /** 89b21740b4SAndreas Gohr * Fetch users matching the given filters 90b21740b4SAndreas Gohr * 91b21740b4SAndreas Gohr * @param array $match 92b21740b4SAndreas Gohr * @param string $filtermethod The method to use for filtering 93b21740b4SAndreas Gohr * @return array 94b21740b4SAndreas Gohr */ 95204fba68SAndreas Gohr public function getFilteredUsers($match, $filtermethod = self::FILTER_EQUAL) 96b21740b4SAndreas Gohr { 97b21740b4SAndreas Gohr if (!$this->autoAuth()) return []; 98b21740b4SAndreas Gohr 99b21740b4SAndreas Gohr $filter = Filters::and(Filters::equal('objectClass', 'user')); 100b21740b4SAndreas Gohr if (isset($match['user'])) { 101a1128cc0SAndreas Gohr $filter->add(Filters::$filtermethod('sAMAccountName', $this->simpleUser($match['user']))); 102b21740b4SAndreas Gohr } 103b21740b4SAndreas Gohr if (isset($match['name'])) { 104b21740b4SAndreas Gohr $filter->add(Filters::$filtermethod('displayName', $match['name'])); 105b21740b4SAndreas Gohr } 106b21740b4SAndreas Gohr if (isset($match['mail'])) { 107b21740b4SAndreas Gohr $filter->add(Filters::$filtermethod('mail', $match['mail'])); 108b21740b4SAndreas Gohr } 109b21740b4SAndreas Gohr if (isset($match['grps'])) { 110b21740b4SAndreas Gohr // memberOf can not be checked with a substring match, so we need to get the right groups first 111b21740b4SAndreas Gohr $groups = $this->getGroups($match['grps'], $filtermethod); 112b21740b4SAndreas Gohr $or = Filters::or(); 113b21740b4SAndreas Gohr foreach ($groups as $dn => $group) { 114204fba68SAndreas Gohr // domain users membership is in primary group 115c2500b44SAndreas Gohr if ($group === $this->config['primarygroup']) { 116204fba68SAndreas Gohr $or->add(Filters::equal('primaryGroupID', 513)); 117204fba68SAndreas Gohr continue; 118204fba68SAndreas Gohr } 119204fba68SAndreas Gohr 120*f17bb68bSAndreas Gohr $or->add(Filters::equal('memberOf', $dn)); // FIXME handle recursive groups 121b21740b4SAndreas Gohr } 122b21740b4SAndreas Gohr $filter->add($or); 123b21740b4SAndreas Gohr } 124b21740b4SAndreas Gohr $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__); 1259c590892SAndreas Gohr $attributes = $this->userAttributes(); 1269c590892SAndreas Gohr $search = Operations::search($filter, ...$attributes); 127b21740b4SAndreas Gohr $paging = $this->ldap->paging($search); 128b21740b4SAndreas Gohr 129b21740b4SAndreas Gohr $users = []; 130b21740b4SAndreas Gohr while ($paging->hasEntries()) { 131b21740b4SAndreas Gohr try { 132b21740b4SAndreas Gohr $entries = $paging->getEntries(); 133b21740b4SAndreas Gohr } catch (ProtocolException $e) { 134b21740b4SAndreas Gohr $this->fatal($e); 13580ac552fSAndreas Gohr break; // we abort and return what we have so far 136b21740b4SAndreas Gohr } 137b21740b4SAndreas Gohr 138b21740b4SAndreas Gohr foreach ($entries as $entry) { 13980ac552fSAndreas Gohr $userinfo = $this->entry2User($entry); 14080ac552fSAndreas Gohr $users[$userinfo['user']] = $this->entry2User($entry); 141b21740b4SAndreas Gohr } 142b21740b4SAndreas Gohr } 143b21740b4SAndreas Gohr 1441b0eb9b3SAndreas Gohr ksort($users); 145b21740b4SAndreas Gohr return $users; 146b21740b4SAndreas Gohr } 147b21740b4SAndreas Gohr 148a1128cc0SAndreas Gohr /** @inheritDoc */ 149a1128cc0SAndreas Gohr public function cleanUser($user) 15080ac552fSAndreas Gohr { 151a1128cc0SAndreas Gohr return $this->simpleUser($user); 15280ac552fSAndreas Gohr } 15380ac552fSAndreas Gohr 154a1128cc0SAndreas Gohr /** @inheritDoc */ 155a1128cc0SAndreas Gohr public function cleanGroup($group) 156a1128cc0SAndreas Gohr { 157a1128cc0SAndreas Gohr return PhpString::strtolower($group); 158a1128cc0SAndreas Gohr } 159a1128cc0SAndreas Gohr 160a1128cc0SAndreas Gohr /** @inheritDoc */ 161a1128cc0SAndreas Gohr public function prepareBindUser($user) 162a1128cc0SAndreas Gohr { 163a1128cc0SAndreas Gohr $user = $this->qualifiedUser($user); // add account suffix 164a1128cc0SAndreas Gohr return $user; 16580ac552fSAndreas Gohr } 16680ac552fSAndreas Gohr 16780ac552fSAndreas Gohr /** 16880ac552fSAndreas Gohr * @inheritDoc 169a1128cc0SAndreas Gohr * userPrincipalName in the form <user>@<suffix> 17080ac552fSAndreas Gohr */ 171a1128cc0SAndreas Gohr protected function qualifiedUser($user) 172a1128cc0SAndreas Gohr { 173a1128cc0SAndreas Gohr $user = $this->simpleUser($user); // strip any existing qualifiers 174a1128cc0SAndreas Gohr if (!$this->config['suffix']) { 175a1128cc0SAndreas Gohr $this->error('No account suffix set. Logins may fail.', __FILE__, __LINE__); 176a1128cc0SAndreas Gohr } 177a1128cc0SAndreas Gohr 178a1128cc0SAndreas Gohr return $user . '@' . $this->config['suffix']; 179a1128cc0SAndreas Gohr } 180a1128cc0SAndreas Gohr 181a1128cc0SAndreas Gohr /** 182a1128cc0SAndreas Gohr * @inheritDoc 183a1128cc0SAndreas Gohr * Removes the account suffix from the given user. Should match the SAMAccountName 184a1128cc0SAndreas Gohr */ 185a1128cc0SAndreas Gohr protected function simpleUser($user) 18680ac552fSAndreas Gohr { 18780ac552fSAndreas Gohr $user = PhpString::strtolower($user); 188a1128cc0SAndreas Gohr $user = preg_replace('/@.*$/', '', $user); 189a1128cc0SAndreas Gohr $user = preg_replace('/^.*\\\\/', '', $user); 19080ac552fSAndreas Gohr return $user; 19180ac552fSAndreas Gohr } 19280ac552fSAndreas Gohr 19380ac552fSAndreas Gohr /** 194b21740b4SAndreas Gohr * Transform an LDAP entry to a user info array 195b21740b4SAndreas Gohr * 196b21740b4SAndreas Gohr * @param Entry $entry 197b21740b4SAndreas Gohr * @return array 198b21740b4SAndreas Gohr */ 199b21740b4SAndreas Gohr protected function entry2User(Entry $entry) 200b21740b4SAndreas Gohr { 201b914569fSAndreas Gohr $user = [ 202a1128cc0SAndreas Gohr 'user' => $this->simpleUser($this->attr2str($entry->get('sAMAccountName'))), 2031078ec26SAndreas Gohr 'name' => $this->attr2str($entry->get('DisplayName')) ?: $this->attr2str($entry->get('Name')), 2041078ec26SAndreas Gohr 'mail' => $this->attr2str($entry->get('mail')), 2051078ec26SAndreas Gohr 'dn' => $entry->getDn()->toString(), 2061078ec26SAndreas Gohr 'grps' => $this->getUserGroups($entry), // we always return groups because its currently inexpensive 2071078ec26SAndreas Gohr ]; 208b914569fSAndreas Gohr 209b914569fSAndreas Gohr // get additional attributes 210b914569fSAndreas Gohr foreach ($this->config['attributes'] as $attr) { 211b914569fSAndreas Gohr $user[$attr] = $this->attr2str($entry->get($attr)); 212b914569fSAndreas Gohr } 213b914569fSAndreas Gohr 214b914569fSAndreas Gohr return $user; 2151078ec26SAndreas Gohr } 2161078ec26SAndreas Gohr 2171078ec26SAndreas Gohr /** 2181078ec26SAndreas Gohr * Get the list of groups the given user is member of 2191078ec26SAndreas Gohr * 2201078ec26SAndreas Gohr * This method currently does no LDAP queries and thus is inexpensive. 2211078ec26SAndreas Gohr * 2221078ec26SAndreas Gohr * @param Entry $userentry 2231078ec26SAndreas Gohr * @return array 2241078ec26SAndreas Gohr */ 2251078ec26SAndreas Gohr protected function getUserGroups(Entry $userentry) 2261078ec26SAndreas Gohr { 2271078ec26SAndreas Gohr $groups = [$this->config['defaultgroup']]; // always add default 2281078ec26SAndreas Gohr 2291078ec26SAndreas Gohr // resolving the primary group in AD is complicated but basically never needed 2301078ec26SAndreas Gohr // http://support.microsoft.com/?kbid=321360 2311078ec26SAndreas Gohr $gid = $userentry->get('primaryGroupID')->firstValue(); 2321078ec26SAndreas Gohr if ($gid == 513) { 233a1128cc0SAndreas Gohr $groups[] = $this->cleanGroup('domain users'); 2341078ec26SAndreas Gohr } 2351078ec26SAndreas Gohr 236*f17bb68bSAndreas Gohr if ($this->config['recursivegroups']) { 237*f17bb68bSAndreas Gohr // we do an additional query for the user's groups asking the AD server to resolve nested 238*f17bb68bSAndreas Gohr // groups for us 239*f17bb68bSAndreas Gohr if (!$this->autoAuth()) return $groups; 240*f17bb68bSAndreas Gohr $filter = Filters::extensible('member', (string)$userentry->getDn(), self::LDAP_MATCHING_RULE_IN_CHAIN, 241*f17bb68bSAndreas Gohr true); 242*f17bb68bSAndreas Gohr $search = Operations::search($filter, 'name'); 24351e92298SAndreas Gohr $paging = $this->ldap->paging($search); 24451e92298SAndreas Gohr while ($paging->hasEntries()) { 24551e92298SAndreas Gohr try { 24651e92298SAndreas Gohr $entries = $paging->getEntries(); 24751e92298SAndreas Gohr } catch (ProtocolException $e) { 248*f17bb68bSAndreas Gohr return $groups; // return what we have 24951e92298SAndreas Gohr } 25051e92298SAndreas Gohr /** @var Entry $entry */ 25151e92298SAndreas Gohr foreach ($entries as $entry) { 252*f17bb68bSAndreas Gohr $groups[] = $this->cleanGroup(($entry->get('name')->getValues())[0]); 25351e92298SAndreas Gohr } 25451e92298SAndreas Gohr } 25551e92298SAndreas Gohr 256*f17bb68bSAndreas Gohr } elseif ($userentry->has('memberOf')) { 257*f17bb68bSAndreas Gohr // we simply take the first CN= part of the group DN and return it as the group name 258*f17bb68bSAndreas Gohr // this should be correct for ActiveDirectory and saves us additional LDAP queries 259*f17bb68bSAndreas Gohr foreach ($userentry->get('memberOf')->getValues() as $dn) { 260*f17bb68bSAndreas Gohr list($cn) = explode(',', $dn, 2); 261*f17bb68bSAndreas Gohr $groups[] = $this->cleanGroup(substr($cn, 3)); 262*f17bb68bSAndreas Gohr } 26351e92298SAndreas Gohr } 26451e92298SAndreas Gohr 265*f17bb68bSAndreas Gohr sort($groups); 266*f17bb68bSAndreas Gohr return $groups; 26751e92298SAndreas Gohr } 26851e92298SAndreas Gohr 2699c590892SAndreas Gohr /** @inheritDoc */ 2709c590892SAndreas Gohr protected function userAttributes() 2719c590892SAndreas Gohr { 2729c590892SAndreas Gohr $attr = parent::userAttributes(); 273a1128cc0SAndreas Gohr $attr[] = new Attribute('sAMAccountName'); 2749c590892SAndreas Gohr $attr[] = new Attribute('Name'); 2759c590892SAndreas Gohr $attr[] = new Attribute('primaryGroupID'); 2769c590892SAndreas Gohr $attr[] = new Attribute('memberOf'); 2779c590892SAndreas Gohr 2789c590892SAndreas Gohr return $attr; 2799c590892SAndreas Gohr } 2801078ec26SAndreas Gohr} 281