xref: /plugin/pureldap/classes/ADClient.php (revision 746af42c292254b2bee8add59eee910acce87636)
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\Exception\ProtocolException;
11use FreeDSx\Ldap\Operations;
12use FreeDSx\Ldap\Search\Filters;
13use FreeDSx\Ldap\Search\Paging;
14use PHP_CodeSniffer\Filters\Filter;
15
16/**
17 * Implement Active Directory Specifics
18 */
19class ADClient extends Client
20{
21    // see https://docs.microsoft.com/en-us/windows/win32/adsi/search-filter-syntax
22    const LDAP_MATCHING_RULE_IN_CHAIN = '1.2.840.113556.1.4.1941';
23
24    /** @inheritDoc */
25    public function getUser($username, $fetchgroups = true)
26    {
27        if (!$this->autoAuth()) return null;
28        $username = $this->simpleUser($username);
29
30        $filter = Filters::and(
31            Filters::equal('objectClass', 'user'),
32            Filters::equal('sAMAccountName', $this->simpleUser($username))
33        );
34        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
35
36        try {
37            /** @var Entries $entries */
38            $attributes = $this->userAttributes();
39            $entries = $this->ldap->search(Operations::search($filter, ...$attributes));
40        } catch (OperationException $e) {
41            $this->fatal($e);
42            return null;
43        }
44        if ($entries->count() !== 1) return null;
45        $entry = $entries->first();
46        return $this->entry2User($entry);
47    }
48
49    /** @inheritDoc */
50    public function getGroups($match = null, $filtermethod = self::FILTER_EQUAL)
51    {
52        if (!$this->autoAuth()) return [];
53
54        $filter = Filters::and(
55            Filters::equal('objectClass', 'group')
56        );
57        if ($match !== null) {
58            // FIXME this is a workaround that removes regex anchors and quoting as passed by the groupuser plugin
59            // a proper fix requires splitbrain/dokuwiki#3028 to be implemented
60            $match = ltrim($match, '^');
61            $match = rtrim($match, '$');
62            $match = stripslashes($match);
63
64            $filter->add(Filters::$filtermethod('cn', $match));
65        }
66
67        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
68        $search = Operations::search($filter, 'cn');
69        $paging = $this->ldap->paging($search);
70
71        $groups = [];
72        while ($paging->hasEntries()) {
73            try {
74                $entries = $paging->getEntries();
75            } catch (ProtocolException $e) {
76                $this->fatal($e);
77                return $groups; // we return what we got so far
78            }
79
80            foreach ($entries as $entry) {
81                /** @var Entry $entry */
82                $groups[$entry->getDn()->toString()] = $this->cleanGroup($this->attr2str($entry->get('cn')));
83            }
84        }
85
86        asort($groups);
87        return $groups;
88    }
89
90    /**
91     * Fetch users matching the given filters
92     *
93     * @param array $match
94     * @param string $filtermethod The method to use for filtering
95     * @return array
96     */
97    public function getFilteredUsers($match, $filtermethod = self::FILTER_EQUAL)
98    {
99        if (!$this->autoAuth()) return [];
100
101        $filter = Filters::and(Filters::equal('objectClass', 'user'));
102        if (isset($match['user'])) {
103            $filter->add(Filters::$filtermethod('sAMAccountName', $this->simpleUser($match['user'])));
104        }
105        if (isset($match['name'])) {
106            $filter->add(Filters::$filtermethod('displayName', $match['name']));
107        }
108        if (isset($match['mail'])) {
109            $filter->add(Filters::$filtermethod('mail', $match['mail']));
110        }
111        if (isset($match['grps'])) {
112            // memberOf can not be checked with a substring match, so we need to get the right groups first
113            $groups = $this->getGroups($match['grps'], $filtermethod);
114            $or = Filters::or();
115            foreach ($groups as $dn => $group) {
116                // domain users membership is in primary group
117                if ($group === $this->config['primarygroup']) {
118                    $or->add(Filters::equal('primaryGroupID', 513));
119                    continue;
120                }
121                // find members of this exact group
122                $or->add(Filters::equal('memberOf', $dn));
123            }
124
125            // find members of the nested groups
126            // we resolve the nested groups first, before we're running the user query as this is usually
127            // faster than doing a full recursive user query. Unfortunately it is still pretty slow
128            if ($this->config['recursivegroups']) {
129                $paging = $this->resolveRecursiveMembership(array_keys($groups), 'memberOf');
130                while ($paging->hasEntries()) {
131                    try {
132                        $entries = $paging->getEntries();
133                    } catch (ProtocolException $e) {
134                        continue;
135                    }
136                    /** @var Entry $entry */
137                    foreach ($entries as $entry) {
138                        $or->add(Filters::equal('memberOf', (string)$entry->getDn()));
139                    }
140                }
141            }
142
143            $filter->add($or);
144        }
145        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
146        $attributes = $this->userAttributes();
147        $search = Operations::search($filter, ...$attributes);
148        $paging = $this->ldap->paging($search);
149
150        $users = [];
151        while ($paging->hasEntries()) {
152            try {
153                $entries = $paging->getEntries();
154            } catch (ProtocolException $e) {
155                $this->fatal($e);
156                break; // we abort and return what we have so far
157            }
158
159            foreach ($entries as $entry) {
160                $userinfo = $this->entry2User($entry, false);
161                $users[$userinfo['user']] = $userinfo;
162            }
163        }
164
165        ksort($users);
166        return $users;
167    }
168
169    /** @inheritDoc */
170    public function cleanUser($user)
171    {
172        return $this->simpleUser($user);
173    }
174
175    /** @inheritDoc */
176    public function cleanGroup($group)
177    {
178        return PhpString::strtolower($group);
179    }
180
181    /** @inheritDoc */
182    public function prepareBindUser($user)
183    {
184        $user = $this->qualifiedUser($user); // add account suffix
185        return $user;
186    }
187
188    /**
189     * @inheritDoc
190     * userPrincipalName in the form <user>@<suffix>
191     */
192    protected function qualifiedUser($user)
193    {
194        $user = $this->simpleUser($user); // strip any existing qualifiers
195        if (!$this->config['suffix']) {
196            $this->error('No account suffix set. Logins may fail.', __FILE__, __LINE__);
197        }
198
199        return $user . '@' . $this->config['suffix'];
200    }
201
202    /**
203     * @inheritDoc
204     * Removes the account suffix from the given user. Should match the SAMAccountName
205     */
206    protected function simpleUser($user)
207    {
208        $user = PhpString::strtolower($user);
209        $user = preg_replace('/@.*$/', '', $user);
210        $user = preg_replace('/^.*\\\\/', '', $user);
211        return $user;
212    }
213
214    /**
215     * Transform an LDAP entry to a user info array
216     *
217     * @param Entry $entry
218     * @return array
219     */
220    protected function entry2User(Entry $entry)
221    {
222        $user = [
223            'user' => $this->simpleUser($this->attr2str($entry->get('sAMAccountName'))),
224            'name' => $this->attr2str($entry->get('DisplayName')) ?: $this->attr2str($entry->get('Name')),
225            'mail' => $this->attr2str($entry->get('mail')),
226            'dn' => $entry->getDn()->toString(),
227            'grps' => $this->getUserGroups($entry), // we always return groups because its currently inexpensive
228        ];
229
230        // get additional attributes
231        foreach ($this->config['attributes'] as $attr) {
232            $user[$attr] = $this->attr2str($entry->get($attr));
233        }
234
235        return $user;
236    }
237
238    /**
239     * Get the list of groups the given user is member of
240     *
241     * This method currently does no LDAP queries and thus is inexpensive.
242     *
243     * @param Entry $userentry
244     * @return array
245     */
246    protected function getUserGroups(Entry $userentry)
247    {
248        $groups = [$this->config['defaultgroup']]; // always add default
249
250        // resolving the primary group in AD is complicated but basically never needed
251        // http://support.microsoft.com/?kbid=321360
252        $gid = $userentry->get('primaryGroupID')->firstValue();
253        if ($gid == 513) {
254            $groups[] = $this->cleanGroup('domain users');
255        }
256
257        if ($this->config['recursivegroups']) {
258            // we do an additional query for the user's groups asking the AD server to resolve nested
259            // groups for us
260            $paging = $this->resolveRecursiveMembership([(string)$userentry->getDn()]);
261            while ($paging->hasEntries()) {
262                try {
263                    $entries = $paging->getEntries();
264                } catch (ProtocolException $e) {
265                    return $groups; // return what we have
266                }
267                /** @var Entry $entry */
268                foreach ($entries as $entry) {
269                    $groups[] = $this->cleanGroup(($entry->get('name')->getValues())[0]);
270                }
271            }
272
273        } elseif ($userentry->has('memberOf')) {
274            // we simply take the first CN= part of the group DN and return it as the group name
275            // this should be correct for ActiveDirectory and saves us additional LDAP queries
276            foreach ($userentry->get('memberOf')->getValues() as $dn) {
277                list($cn) = explode(',', $dn, 2);
278                $groups[] = $this->cleanGroup(substr($cn, 3));
279            }
280        }
281
282        sort($groups);
283        return $groups;
284    }
285
286    /**
287     * Get nested groups for the given DN
288     *
289     * @todo this is slow, doing many recursive calls might actually be faster
290     * @see https://stackoverflow.com/q/40024425
291     * @param string[] $DNs this can either be a user or group dn
292     * @param string $attribute Are we looking down (member) or up (memberOf)?
293     * @return Paging|null
294     */
295    protected function resolveRecursiveMembership($DNs, $attribute='member')
296    {
297        if (!$this->autoAuth()) return null;
298
299        $filter = Filters::or();
300        foreach ($DNs as $dn) {
301            $filter->add(
302                Filters::extensible($attribute, $dn, self::LDAP_MATCHING_RULE_IN_CHAIN, true)
303            );
304        }
305        $filter = Filters::and(
306            Filters::equal('objectCategory', 'group'),
307            $filter
308        );
309
310        $search = Operations::search($filter, 'name');
311        $paging = $this->ldap->paging($search);
312        return $paging;
313    }
314
315    /** @inheritDoc */
316    protected function userAttributes()
317    {
318        $attr = parent::userAttributes();
319        $attr[] = new Attribute('sAMAccountName');
320        $attr[] = new Attribute('Name');
321        $attr[] = new Attribute('primaryGroupID');
322        $attr[] = new Attribute('memberOf');
323
324        return $attr;
325    }
326}
327