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