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