xref: /plugin/pureldap/classes/ADClient.php (revision 208fe81a1d46425114eb6a90e8eceeed153c5f2d)
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    public const ADS_UF_DONT_EXPIRE_PASSWD = 0x10000;
19
20    /**
21     * @var GroupHierarchyCache
22     * @see getGroupHierarchyCache
23     */
24    protected $gch;
25
26    /** @inheritDoc */
27    public function getUser($username, $fetchgroups = true)
28    {
29        $entry = $this->getUserEntry($username);
30        if ($entry === null) return null;
31        return $this->entry2User($entry);
32    }
33
34    /**
35     * Get the LDAP entry for the given user
36     *
37     * @param string $username
38     * @return Entry|null
39     */
40    protected function getUserEntry($username)
41    {
42        if (!$this->autoAuth()) return null;
43        $username = $this->simpleUser($username);
44
45        $filter = Filters::and(
46            Filters::equal('objectClass', 'user'),
47            Filters::equal('sAMAccountName', $this->simpleUser($username))
48        );
49        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
50
51        try {
52            $attributes = $this->userAttributes();
53            $entries = $this->ldap->search(Operations::search($filter, ...$attributes));
54        } catch (OperationException $e) {
55            $this->fatal($e);
56            return null;
57        }
58        if ($entries->count() !== 1) return null;
59        return $entries->first();
60    }
61
62    /** @inheritDoc */
63    public function setPassword($username, $newpass, $oldpass = null)
64    {
65        if (!$this->autoAuth()) return false;
66
67        $entry = $this->getUserEntry($username);
68        if ($entry === null) {
69            $this->error("User '$username' not found", __FILE__, __LINE__);
70            return false;
71        }
72
73        if ($oldpass) {
74            // if an old password is given, this is a self-service password change
75            // this has to be executed as the user themselves, not as the admin
76            if ($this->isAuthenticated !== $this->prepareBindUser($username)) {
77                if (!$this->authenticate($username, $oldpass)) {
78                    $this->error("Old password for '$username' is wrong", __FILE__, __LINE__);
79                    return false;
80                }
81            }
82
83            $entry->remove('unicodePwd', $this->encodePassword($oldpass));
84            $entry->add('unicodePwd', $this->encodePassword($newpass));
85        } else {
86            // run as admin user
87            $entry->set('unicodePwd', $this->encodePassword($newpass));
88        }
89
90        try {
91            $this->ldap->update($entry);
92        } catch (OperationException $e) {
93            $this->fatal($e);
94            return false;
95        }
96        return true;
97    }
98
99    /** @inheritDoc */
100    public function getGroups($match = null, $filtermethod = self::FILTER_EQUAL)
101    {
102        if (!$this->autoAuth()) return [];
103
104        $filter = Filters::and(
105            Filters::equal('objectClass', 'group')
106        );
107        if ($match !== null) {
108            // FIXME this is a workaround that removes regex anchors and quoting as passed by the groupuser plugin
109            // a proper fix requires splitbrain/dokuwiki#3028 to be implemented
110            $match = ltrim($match, '^');
111            $match = rtrim($match, '$');
112            $match = stripslashes($match);
113
114            $filter->add(Filters::$filtermethod('cn', $match));
115        }
116
117        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
118        $search = Operations::search($filter, 'cn');
119        $paging = $this->ldap->paging($search);
120
121        $groups = [];
122        while ($paging->hasEntries()) {
123            try {
124                $entries = $paging->getEntries();
125            } catch (OperationException $e) {
126                $this->fatal($e);
127                return $groups; // we return what we got so far
128            }
129
130            foreach ($entries as $entry) {
131                /** @var Entry $entry */
132                $groups[$entry->getDn()->toString()] = $this->cleanGroup($this->attr2str($entry->get('cn')));
133            }
134        }
135
136        asort($groups);
137        return $groups;
138    }
139
140    /**
141     * Fetch users matching the given filters
142     *
143     * @param array $match
144     * @param string $filtermethod The method to use for filtering
145     * @return array
146     */
147    public function getFilteredUsers($match, $filtermethod = self::FILTER_EQUAL)
148    {
149        if (!$this->autoAuth()) return [];
150
151        $filter = Filters::and(Filters::equal('objectClass', 'user'));
152        if (isset($match['user'])) {
153            $filter->add(Filters::$filtermethod('sAMAccountName', $this->simpleUser($match['user'])));
154        }
155        if (isset($match['name'])) {
156            $filter->add(Filters::$filtermethod('displayName', $match['name']));
157        }
158        if (isset($match['mail'])) {
159            $filter->add(Filters::$filtermethod('mail', $match['mail']));
160        }
161        if (isset($match['grps'])) {
162            // memberOf can not be checked with a substring match, so we need to get the right groups first
163            $groups = $this->getGroups($match['grps'], $filtermethod);
164            $groupDNs = array_keys($groups);
165
166            if ($this->config['recursivegroups']) {
167                $gch = $this->getGroupHierarchyCache();
168                foreach ($groupDNs as $dn) {
169                    $groupDNs = array_merge($groupDNs, $gch->getChildren($dn));
170                }
171                $groupDNs = array_unique($groupDNs);
172            }
173
174            $or = Filters::or();
175            foreach ($groupDNs as $dn) {
176                // domain users membership is in primary group
177                if ($this->dn2group($dn) === $this->config['primarygroup']) {
178                    $or->add(Filters::equal('primaryGroupID', 513));
179                    continue;
180                }
181                // find members of this exact group
182                $or->add(Filters::equal('memberOf', $dn));
183            }
184            $filter->add($or);
185        }
186
187        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
188        $attributes = $this->userAttributes();
189        $search = Operations::search($filter, ...$attributes);
190        $paging = $this->ldap->paging($search);
191
192        $users = [];
193        while ($paging->hasEntries()) {
194            try {
195                $entries = $paging->getEntries();
196            } catch (OperationException $e) {
197                $this->fatal($e);
198                break; // we abort and return what we have so far
199            }
200
201            foreach ($entries as $entry) {
202                $userinfo = $this->entry2User($entry);
203                $users[$userinfo['user']] = $userinfo;
204            }
205        }
206
207        ksort($users);
208        return $users;
209    }
210
211    /** @inheritDoc */
212    public function cleanUser($user)
213    {
214        return $this->simpleUser($user);
215    }
216
217    /** @inheritDoc */
218    public function cleanGroup($group)
219    {
220        return PhpString::strtolower($group);
221    }
222
223    /** @inheritDoc */
224    protected function prepareBindUser($user)
225    {
226        // add account suffix
227        return $this->qualifiedUser($user);
228    }
229
230    /**
231     * Initializes the Group Cache for nested groups
232     *
233     * @return GroupHierarchyCache
234     */
235    public function getGroupHierarchyCache()
236    {
237        if ($this->gch === null) {
238            if (!$this->autoAuth()) return null;
239            $this->gch = new GroupHierarchyCache($this->ldap, $this->config['usefscache']);
240        }
241        return $this->gch;
242    }
243
244    /**
245     * userPrincipalName in the form <user>@<suffix>
246     *
247     * @param string $user
248     * @return string
249     */
250    protected function qualifiedUser($user)
251    {
252        $user = $this->simpleUser($user); // strip any existing qualifiers
253        if (!$this->config['suffix']) {
254            $this->error('No account suffix set. Logins may fail.', __FILE__, __LINE__);
255        }
256
257        return $user . '@' . $this->config['suffix'];
258    }
259
260    /**
261     * Removes the account suffix from the given user. Should match the SAMAccountName
262     *
263     * @param string $user
264     * @return string
265     */
266    protected function simpleUser($user)
267    {
268        $user = PhpString::strtolower($user);
269        $user = preg_replace('/@.*$/', '', $user);
270        $user = preg_replace('/^.*\\\\/', '', $user);
271        return $user;
272    }
273
274    /**
275     * Transform an LDAP entry to a user info array
276     *
277     * @param Entry $entry
278     * @return array
279     */
280    protected function entry2User(Entry $entry)
281    {
282        $user = [
283            'user' => $this->simpleUser($this->attr2str($entry->get('sAMAccountName'))),
284            'name' => $this->attr2str($entry->get('DisplayName')) ?: $this->attr2str($entry->get('Name')),
285            'mail' => $this->attr2str($entry->get('mail')),
286            'dn' => $entry->getDn()->toString(),
287            'grps' => $this->getUserGroups($entry), // we always return groups because its currently inexpensive
288        ];
289
290        // handle password expiry info
291        $lastChange = $this->attr2str($entry->get('pwdlastset'));
292        if ($lastChange) {
293            $lastChange = (int)substr($lastChange, 0, -7); // remove last 7 digits (100ns intervals to seconds)
294            $lastChange -= 11_644_473_600; // convert from 1601 to 1970 epoch
295        }
296        $user['lastpwd'] = (int)$lastChange;
297        $user['expires'] = !($this->attr2str($entry->get('useraccountcontrol')) & self::ADS_UF_DONT_EXPIRE_PASSWD);
298
299        // get additional attributes
300        foreach ($this->config['attributes'] as $attr) {
301            $user[$attr] = $this->attr2str($entry->get($attr));
302        }
303
304        return $user;
305    }
306
307    /**
308     * Get the list of groups the given user is member of
309     *
310     * This method currently does no LDAP queries and thus is inexpensive.
311     *
312     * @param Entry $userentry
313     * @return array
314     */
315    protected function getUserGroups(Entry $userentry)
316    {
317        $groups = [];
318
319        if ($userentry->has('memberOf')) {
320            $groupDNs = $userentry->get('memberOf')->getValues();
321
322            if ($this->config['recursivegroups']) {
323                $gch = $this->getGroupHierarchyCache();
324                foreach ($groupDNs as $dn) {
325                    $groupDNs = array_merge($groupDNs, $gch->getParents($dn));
326                }
327
328                $groupDNs = array_unique($groupDNs);
329            }
330            $groups = array_map([$this, 'dn2group'], $groupDNs);
331        }
332
333        $groups[] = $this->config['defaultgroup']; // always add default
334
335        // resolving the primary group in AD is complicated but basically never needed
336        // http://support.microsoft.com/?kbid=321360
337        $gid = $userentry->get('primaryGroupID')->firstValue();
338        if ($gid == 513) {
339            $groups[] = $this->cleanGroup($this->config['primarygroup']);
340        }
341
342        sort($groups);
343        return $groups;
344    }
345
346    /** @inheritDoc */
347    protected function userAttributes()
348    {
349        $attr = parent::userAttributes();
350        $attr[] = new Attribute('sAMAccountName');
351        $attr[] = new Attribute('Name');
352        $attr[] = new Attribute('primaryGroupID');
353        $attr[] = new Attribute('memberOf');
354        $attr[] = new Attribute('pwdlastset');
355        $attr[] = new Attribute('useraccountcontrol');
356
357        return $attr;
358    }
359
360    /**
361     * Queries the maximum password age from the AD server
362     *
363     * Note: we do not check if passwords actually are set to expire here. This is encoded in the lower 32bit
364     * of the returned 64bit integer (see link below). We do not check this because it would require us to
365     * actually do large integer math and we can simply assume it's enabled when the age check was requested in
366     * DokuWiki configuration.
367     *
368     * @link http://msdn.microsoft.com/en-us/library/ms974598.aspx
369     * @param bool $useCache should a filesystem cache be used if available?
370     * @return int The maximum password age in seconds
371     */
372    public function getMaxPasswordAge($useCache = true)
373    {
374        global $conf;
375        $cachename = getCacheName('maxPwdAge', '.pureldap-maxPwdAge');
376        $cachetime = @filemtime($cachename);
377
378        // valid file system cache? use it
379        if ($useCache && $cachetime && (time() - $cachetime) < $conf['auth_security_timeout']) {
380            return (int)file_get_contents($cachename);
381        }
382
383        if (!$this->autoAuth()) return 0;
384
385        $attr = new Attribute('maxPwdAge');
386        try {
387            $entry = $this->ldap->read(
388                $this->getConf('base_dn'),
389                [$attr]
390            );
391        } catch (OperationException $e) {
392            $this->fatal($e);
393            return 0;
394        }
395        if (!$entry) return 0;
396        $maxPwdAge = $entry->get($attr)->firstValue();
397
398        // MS returns 100 nanosecond intervals, we want seconds
399        // we operate on strings to avoid integer overflow
400        // we also want a positive value, so we trim off the leading minus sign
401        // only then we convert to int
402        $maxPwdAge = (int)ltrim(substr($maxPwdAge, 0, -7), '-');
403
404        file_put_contents($cachename, $maxPwdAge);
405        return $maxPwdAge;
406    }
407
408    /**
409     * Extract the group name from the DN
410     *
411     * @param string $dn
412     * @return string
413     */
414    protected function dn2group($dn)
415    {
416        [$cn] = explode(',', $dn, 2);
417        return $this->cleanGroup(substr($cn, 3));
418    }
419
420    /**
421     * Encode a password for transmission over LDAP
422     *
423     * Passwords are encoded as UTF-16LE strings encapsulated in quotes.
424     *
425     * @param string $password The password to encode
426     * @return string
427     */
428    protected function encodePassword($password)
429    {
430        $password = "\"" . $password . "\"";
431
432        if (function_exists('iconv')) {
433            $adpassword = iconv('UTF-8', 'UTF-16LE', $password);
434        } elseif (function_exists('mb_convert_encoding')) {
435            $adpassword = mb_convert_encoding($password, "UTF-16LE", "UTF-8");
436        } else {
437            // this will only work for ASCII7 passwords
438            $adpassword = '';
439            for ($i = 0; $i < strlen($password); $i++) {
440                $adpassword .= "$password[$i]\000";
441            }
442        }
443        return $adpassword;
444    }
445}
446