xref: /plugin/pureldap/classes/ADClient.php (revision 746af42c292254b2bee8add59eee910acce87636)
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