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