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