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