xref: /plugin/pureldap/classes/ADClient.php (revision 0da90260076cd89dd000463a99febc0cd0f225ac)
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\Operations;
11use FreeDSx\Ldap\Search\Filters;
12
13/**
14 * Implement Active Directory Specifics
15 */
16class ADClient extends Client
17{
18    /**
19     * @var GroupHierarchyCache
20     * @see getGroupHierarchyCache
21     */
22    protected $gch = null;
23
24    /** @inheritDoc */
25    public function getUser($username, $fetchgroups = true)
26    {
27        $entry = $this->getUserEntry($username);
28        if ($entry === null) return null;
29        return $this->entry2User($entry);
30    }
31
32    /**
33     * Get the LDAP entry for the given user
34     *
35     * @param string $username
36     * @return Entry|null
37     */
38    protected function getUserEntry($username)
39    {
40        if (!$this->autoAuth()) return null;
41        $username = $this->simpleUser($username);
42
43        $filter = Filters::and(
44            Filters::equal('objectClass', 'user'),
45            Filters::equal('sAMAccountName', $this->simpleUser($username))
46        );
47        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
48
49        try {
50            /** @var Entries $entries */
51            $attributes = $this->userAttributes();
52            $entries = $this->ldap->search(Operations::search($filter, ...$attributes));
53        } catch (OperationException $e) {
54            $this->fatal($e);
55            return null;
56        }
57        if ($entries->count() !== 1) return null;
58        return $entries->first();
59    }
60
61    /** @inheritDoc */
62    public function setPassword($username, $newpass, $oldpass = null)
63    {
64        if (!$this->autoAuth()) return false;
65
66        $entry = $this->getUserEntry($username);
67        if ($entry === null) {
68            $this->error("User '$username' not found", __FILE__, __LINE__);
69            return false;
70        }
71
72        if ($oldpass) {
73            // if an old password is given, this is a self-service password change
74            // this has to be executed as the user themselves, not as the admin
75            if ($this->isAuthenticated !== $this->prepareBindUser($username)) {
76                if (!$this->authenticate($username, $oldpass)) {
77                    $this->error("Old password for '$username' is wrong", __FILE__, __LINE__);
78                    return false;
79                }
80            }
81
82            $entry->remove('unicodePwd', $this->encodePassword($oldpass));
83            $entry->add('unicodePwd', $this->encodePassword($newpass));
84        } else {
85            // run as admin user
86            $entry->set('unicodePwd', $this->encodePassword($newpass));
87        }
88
89        try {
90            $this->ldap->update($entry);
91        } catch (OperationException $e) {
92            $this->fatal($e);
93            return false;
94        }
95        return true;
96    }
97
98    /** @inheritDoc */
99    public function getGroups($match = null, $filtermethod = self::FILTER_EQUAL)
100    {
101        if (!$this->autoAuth()) return [];
102
103        $filter = Filters::and(
104            Filters::equal('objectClass', 'group')
105        );
106        if ($match !== null) {
107            // FIXME this is a workaround that removes regex anchors and quoting as passed by the groupuser plugin
108            // a proper fix requires splitbrain/dokuwiki#3028 to be implemented
109            $match = ltrim($match, '^');
110            $match = rtrim($match, '$');
111            $match = stripslashes($match);
112
113            $filter->add(Filters::$filtermethod('cn', $match));
114        }
115
116        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
117        $search = Operations::search($filter, 'cn');
118        $paging = $this->ldap->paging($search);
119
120        $groups = [];
121        while ($paging->hasEntries()) {
122            try {
123                $entries = $paging->getEntries();
124            } catch (OperationException $e) {
125                $this->fatal($e);
126                return $groups; // we return what we got so far
127            }
128
129            foreach ($entries as $entry) {
130                /** @var Entry $entry */
131                $groups[$entry->getDn()->toString()] = $this->cleanGroup($this->attr2str($entry->get('cn')));
132            }
133        }
134
135        asort($groups);
136        return $groups;
137    }
138
139    /**
140     * Fetch users matching the given filters
141     *
142     * @param array $match
143     * @param string $filtermethod The method to use for filtering
144     * @return array
145     */
146    public function getFilteredUsers($match, $filtermethod = self::FILTER_EQUAL)
147    {
148        if (!$this->autoAuth()) return [];
149
150        $filter = Filters::and(Filters::equal('objectClass', 'user'));
151        if (isset($match['user'])) {
152            $filter->add(Filters::$filtermethod('sAMAccountName', $this->simpleUser($match['user'])));
153        }
154        if (isset($match['name'])) {
155            $filter->add(Filters::$filtermethod('displayName', $match['name']));
156        }
157        if (isset($match['mail'])) {
158            $filter->add(Filters::$filtermethod('mail', $match['mail']));
159        }
160        if (isset($match['grps'])) {
161            // memberOf can not be checked with a substring match, so we need to get the right groups first
162            $groups = $this->getGroups($match['grps'], $filtermethod);
163            $groupDNs = array_keys($groups);
164
165            if ($this->config['recursivegroups']) {
166                $gch = $this->getGroupHierarchyCache();
167                foreach ($groupDNs as $dn) {
168                    $groupDNs = array_merge($groupDNs, $gch->getChildren($dn));
169                }
170                $groupDNs = array_unique($groupDNs);
171            }
172
173            $or = Filters::or();
174            foreach ($groupDNs as $dn) {
175                // domain users membership is in primary group
176                if ($this->dn2group($dn) === $this->config['primarygroup']) {
177                    $or->add(Filters::equal('primaryGroupID', 513));
178                    continue;
179                }
180                // find members of this exact group
181                $or->add(Filters::equal('memberOf', $dn));
182            }
183            $filter->add($or);
184        }
185
186        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
187        $attributes = $this->userAttributes();
188        $search = Operations::search($filter, ...$attributes);
189        $paging = $this->ldap->paging($search);
190
191        $users = [];
192        while ($paging->hasEntries()) {
193            try {
194                $entries = $paging->getEntries();
195            } catch (OperationException $e) {
196                $this->fatal($e);
197                break; // we abort and return what we have so far
198            }
199
200            foreach ($entries as $entry) {
201                $userinfo = $this->entry2User($entry);
202                $users[$userinfo['user']] = $userinfo;
203            }
204        }
205
206        ksort($users);
207        return $users;
208    }
209
210    /** @inheritDoc */
211    public function cleanUser($user)
212    {
213        return $this->simpleUser($user);
214    }
215
216    /** @inheritDoc */
217    public function cleanGroup($group)
218    {
219        return PhpString::strtolower($group);
220    }
221
222    /** @inheritDoc */
223    public function prepareBindUser($user)
224    {
225        // add account suffix
226        return $this->qualifiedUser($user);
227    }
228
229    /**
230     * Initializes the Group Cache for nested groups
231     *
232     * @return GroupHierarchyCache
233     */
234    public function getGroupHierarchyCache()
235    {
236        if ($this->gch === null) {
237            if (!$this->autoAuth()) return null;
238            $this->gch = new GroupHierarchyCache($this->ldap, $this->config['usefscache']);
239        }
240        return $this->gch;
241    }
242
243    /**
244     * userPrincipalName in the form <user>@<suffix>
245     *
246     * @param string $user
247     * @return string
248     */
249    protected function qualifiedUser($user)
250    {
251        $user = $this->simpleUser($user); // strip any existing qualifiers
252        if (!$this->config['suffix']) {
253            $this->error('No account suffix set. Logins may fail.', __FILE__, __LINE__);
254        }
255
256        return $user . '@' . $this->config['suffix'];
257    }
258
259    /**
260     * Removes the account suffix from the given user. Should match the SAMAccountName
261     *
262     * @param string $user
263     * @return string
264     */
265    protected function simpleUser($user)
266    {
267        $user = PhpString::strtolower($user);
268        $user = preg_replace('/@.*$/', '', $user);
269        $user = preg_replace('/^.*\\\\/', '', $user);
270        return $user;
271    }
272
273    /**
274     * Transform an LDAP entry to a user info array
275     *
276     * @param Entry $entry
277     * @return array
278     */
279    protected function entry2User(Entry $entry)
280    {
281        $user = [
282            'user' => $this->simpleUser($this->attr2str($entry->get('sAMAccountName'))),
283            'name' => $this->attr2str($entry->get('DisplayName')) ?: $this->attr2str($entry->get('Name')),
284            'mail' => $this->attr2str($entry->get('mail')),
285            'dn' => $entry->getDn()->toString(),
286            'grps' => $this->getUserGroups($entry), // we always return groups because its currently inexpensive
287        ];
288
289        // get additional attributes
290        foreach ($this->config['attributes'] as $attr) {
291            $user[$attr] = $this->attr2str($entry->get($attr));
292        }
293
294        return $user;
295    }
296
297    /**
298     * Get the list of groups the given user is member of
299     *
300     * This method currently does no LDAP queries and thus is inexpensive.
301     *
302     * @param Entry $userentry
303     * @return array
304     */
305    protected function getUserGroups(Entry $userentry)
306    {
307        $groups = [];
308
309        if ($userentry->has('memberOf')) {
310            $groupDNs = $userentry->get('memberOf')->getValues();
311
312            if ($this->config['recursivegroups']) {
313                $gch = $this->getGroupHierarchyCache();
314                foreach ($groupDNs as $dn) {
315                    $groupDNs = array_merge($groupDNs, $gch->getParents($dn));
316                }
317
318                $groupDNs = array_unique($groupDNs);
319            }
320            $groups = array_map([$this, 'dn2group'], $groupDNs);
321        }
322
323        $groups[] = $this->config['defaultgroup']; // always add default
324
325        // resolving the primary group in AD is complicated but basically never needed
326        // http://support.microsoft.com/?kbid=321360
327        $gid = $userentry->get('primaryGroupID')->firstValue();
328        if ($gid == 513) {
329            $groups[] = $this->cleanGroup($this->config['primarygroup']);
330        }
331
332        sort($groups);
333        return $groups;
334    }
335
336    /** @inheritDoc */
337    protected function userAttributes()
338    {
339        $attr = parent::userAttributes();
340        $attr[] = new Attribute('sAMAccountName');
341        $attr[] = new Attribute('Name');
342        $attr[] = new Attribute('primaryGroupID');
343        $attr[] = new Attribute('memberOf');
344
345        return $attr;
346    }
347
348    /**
349     * Extract the group name from the DN
350     *
351     * @param string $dn
352     * @return string
353     */
354    protected function dn2group($dn)
355    {
356        list($cn) = explode(',', $dn, 2);
357        return $this->cleanGroup(substr($cn, 3));
358    }
359
360    /**
361     * Encode a password for transmission over LDAP
362     *
363     * Passwords are encoded as UTF-16LE strings encapsulated in quotes.
364     *
365     * @param string $password The password to encode
366     * @return string
367     */
368    protected function encodePassword($password)
369    {
370        $password = "\"" . $password . "\"";
371
372        if (function_exists('iconv')) {
373            $adpassword = iconv('UTF-8', 'UTF-16LE', $password);
374        } elseif (function_exists('mb_convert_encoding')) {
375            $adpassword = mb_convert_encoding($password, "UTF-16LE", "UTF-8");
376        } else {
377            // this will only work for ASCII7 passwords
378            $adpassword = '';
379            for ($i = 0; $i < strlen($password); $i++) {
380                $adpassword .= "$password[$i]\000";
381            }
382        }
383        return $adpassword;
384    }
385}
386