xref: /plugin/pureldap/classes/ADClient.php (revision f17bb68b5a2d095b69b9e951aa10c6b366b7a7ce)
11078ec26SAndreas Gohr<?php
21078ec26SAndreas Gohr
31078ec26SAndreas Gohrnamespace dokuwiki\plugin\pureldap\classes;
41078ec26SAndreas Gohr
580ac552fSAndreas Gohruse dokuwiki\Utf8\PhpString;
69c590892SAndreas Gohruse FreeDSx\Ldap\Entry\Attribute;
71078ec26SAndreas Gohruse FreeDSx\Ldap\Entry\Entries;
81078ec26SAndreas Gohruse FreeDSx\Ldap\Entry\Entry;
91078ec26SAndreas Gohruse FreeDSx\Ldap\Exception\OperationException;
105a3b9122SAndreas Gohruse FreeDSx\Ldap\Exception\ProtocolException;
111078ec26SAndreas Gohruse FreeDSx\Ldap\Operations;
121078ec26SAndreas Gohruse FreeDSx\Ldap\Search\Filters;
131078ec26SAndreas Gohr
14*f17bb68bSAndreas Gohr/**
15*f17bb68bSAndreas Gohr * Implement Active Directory Specifics
16*f17bb68bSAndreas Gohr */
171078ec26SAndreas Gohrclass ADClient extends Client
181078ec26SAndreas Gohr{
19*f17bb68bSAndreas Gohr    // see https://docs.microsoft.com/en-us/windows/win32/adsi/search-filter-syntax
20*f17bb68bSAndreas Gohr    const LDAP_MATCHING_RULE_IN_CHAIN = '1.2.840.113556.1.4.1941';
211078ec26SAndreas Gohr
221078ec26SAndreas Gohr    /** @inheritDoc */
231078ec26SAndreas Gohr    public function getUser($username, $fetchgroups = true)
241078ec26SAndreas Gohr    {
251078ec26SAndreas Gohr        if (!$this->autoAuth()) return null;
26a1128cc0SAndreas Gohr        $username = $this->simpleUser($username);
271078ec26SAndreas Gohr
281078ec26SAndreas Gohr        $filter = Filters::and(
291078ec26SAndreas Gohr            Filters::equal('objectClass', 'user'),
30a1128cc0SAndreas Gohr            Filters::equal('sAMAccountName', $this->simpleUser($username))
311078ec26SAndreas Gohr        );
32b21740b4SAndreas Gohr        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
331078ec26SAndreas Gohr
341078ec26SAndreas Gohr        try {
351078ec26SAndreas Gohr            /** @var Entries $entries */
369c590892SAndreas Gohr            $attributes = $this->userAttributes();
379c590892SAndreas Gohr            $entries = $this->ldap->search(Operations::search($filter, ...$attributes));
381078ec26SAndreas Gohr        } catch (OperationException $e) {
39b21740b4SAndreas Gohr            $this->fatal($e);
401078ec26SAndreas Gohr            return null;
411078ec26SAndreas Gohr        }
421078ec26SAndreas Gohr        if ($entries->count() !== 1) return null;
431078ec26SAndreas Gohr        $entry = $entries->first();
44b21740b4SAndreas Gohr        return $this->entry2User($entry);
45b21740b4SAndreas Gohr    }
461078ec26SAndreas Gohr
47b21740b4SAndreas Gohr    /** @inheritDoc */
48204fba68SAndreas Gohr    public function getGroups($match = null, $filtermethod = self::FILTER_EQUAL)
49b21740b4SAndreas Gohr    {
50b21740b4SAndreas Gohr        if (!$this->autoAuth()) return [];
51b21740b4SAndreas Gohr
52b21740b4SAndreas Gohr        $filter = Filters::and(
53b21740b4SAndreas Gohr            Filters::equal('objectClass', 'group')
54b21740b4SAndreas Gohr        );
55b21740b4SAndreas Gohr        if ($match !== null) {
56e7c3e817SAndreas Gohr            // FIXME this is a workaround that removes regex anchors and quoting as passed by the groupuser plugin
57e7c3e817SAndreas Gohr            // a proper fix requires splitbrain/dokuwiki#3028 to be implemented
58fce018daSAndreas Gohr            $match = ltrim($match, '^');
59fce018daSAndreas Gohr            $match = rtrim($match, '$');
60e7c3e817SAndreas Gohr            $match = stripslashes($match);
61fce018daSAndreas Gohr
62b21740b4SAndreas Gohr            $filter->add(Filters::$filtermethod('cn', $match));
63b21740b4SAndreas Gohr        }
64b21740b4SAndreas Gohr
65b21740b4SAndreas Gohr        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
66b21740b4SAndreas Gohr        $search = Operations::search($filter, 'cn');
67b21740b4SAndreas Gohr        $paging = $this->ldap->paging($search);
68b21740b4SAndreas Gohr
69b21740b4SAndreas Gohr        $groups = [];
70b21740b4SAndreas Gohr        while ($paging->hasEntries()) {
71b21740b4SAndreas Gohr            try {
72b21740b4SAndreas Gohr                $entries = $paging->getEntries();
73b21740b4SAndreas Gohr            } catch (ProtocolException $e) {
74b21740b4SAndreas Gohr                $this->fatal($e);
75b21740b4SAndreas Gohr                return $groups; // we return what we got so far
76b21740b4SAndreas Gohr            }
77b21740b4SAndreas Gohr
78b21740b4SAndreas Gohr            foreach ($entries as $entry) {
79b21740b4SAndreas Gohr                /** @var Entry $entry */
80204fba68SAndreas Gohr                $groups[$entry->getDn()->toString()] = $this->cleanGroup($this->attr2str($entry->get('cn')));
81b21740b4SAndreas Gohr            }
82b21740b4SAndreas Gohr        }
83b21740b4SAndreas Gohr
841b0eb9b3SAndreas Gohr        asort($groups);
85b21740b4SAndreas Gohr        return $groups;
86b21740b4SAndreas Gohr    }
87b21740b4SAndreas Gohr
88b21740b4SAndreas Gohr    /**
89b21740b4SAndreas Gohr     * Fetch users matching the given filters
90b21740b4SAndreas Gohr     *
91b21740b4SAndreas Gohr     * @param array $match
92b21740b4SAndreas Gohr     * @param string $filtermethod The method to use for filtering
93b21740b4SAndreas Gohr     * @return array
94b21740b4SAndreas Gohr     */
95204fba68SAndreas Gohr    public function getFilteredUsers($match, $filtermethod = self::FILTER_EQUAL)
96b21740b4SAndreas Gohr    {
97b21740b4SAndreas Gohr        if (!$this->autoAuth()) return [];
98b21740b4SAndreas Gohr
99b21740b4SAndreas Gohr        $filter = Filters::and(Filters::equal('objectClass', 'user'));
100b21740b4SAndreas Gohr        if (isset($match['user'])) {
101a1128cc0SAndreas Gohr            $filter->add(Filters::$filtermethod('sAMAccountName', $this->simpleUser($match['user'])));
102b21740b4SAndreas Gohr        }
103b21740b4SAndreas Gohr        if (isset($match['name'])) {
104b21740b4SAndreas Gohr            $filter->add(Filters::$filtermethod('displayName', $match['name']));
105b21740b4SAndreas Gohr        }
106b21740b4SAndreas Gohr        if (isset($match['mail'])) {
107b21740b4SAndreas Gohr            $filter->add(Filters::$filtermethod('mail', $match['mail']));
108b21740b4SAndreas Gohr        }
109b21740b4SAndreas Gohr        if (isset($match['grps'])) {
110b21740b4SAndreas Gohr            // memberOf can not be checked with a substring match, so we need to get the right groups first
111b21740b4SAndreas Gohr            $groups = $this->getGroups($match['grps'], $filtermethod);
112b21740b4SAndreas Gohr            $or = Filters::or();
113b21740b4SAndreas Gohr            foreach ($groups as $dn => $group) {
114204fba68SAndreas Gohr                // domain users membership is in primary group
115c2500b44SAndreas Gohr                if ($group === $this->config['primarygroup']) {
116204fba68SAndreas Gohr                    $or->add(Filters::equal('primaryGroupID', 513));
117204fba68SAndreas Gohr                    continue;
118204fba68SAndreas Gohr                }
119204fba68SAndreas Gohr
120*f17bb68bSAndreas Gohr                $or->add(Filters::equal('memberOf', $dn)); // FIXME handle recursive groups
121b21740b4SAndreas Gohr            }
122b21740b4SAndreas Gohr            $filter->add($or);
123b21740b4SAndreas Gohr        }
124b21740b4SAndreas Gohr        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
1259c590892SAndreas Gohr        $attributes = $this->userAttributes();
1269c590892SAndreas Gohr        $search = Operations::search($filter, ...$attributes);
127b21740b4SAndreas Gohr        $paging = $this->ldap->paging($search);
128b21740b4SAndreas Gohr
129b21740b4SAndreas Gohr        $users = [];
130b21740b4SAndreas Gohr        while ($paging->hasEntries()) {
131b21740b4SAndreas Gohr            try {
132b21740b4SAndreas Gohr                $entries = $paging->getEntries();
133b21740b4SAndreas Gohr            } catch (ProtocolException $e) {
134b21740b4SAndreas Gohr                $this->fatal($e);
13580ac552fSAndreas Gohr                break; // we abort and return what we have so far
136b21740b4SAndreas Gohr            }
137b21740b4SAndreas Gohr
138b21740b4SAndreas Gohr            foreach ($entries as $entry) {
13980ac552fSAndreas Gohr                $userinfo = $this->entry2User($entry);
14080ac552fSAndreas Gohr                $users[$userinfo['user']] = $this->entry2User($entry);
141b21740b4SAndreas Gohr            }
142b21740b4SAndreas Gohr        }
143b21740b4SAndreas Gohr
1441b0eb9b3SAndreas Gohr        ksort($users);
145b21740b4SAndreas Gohr        return $users;
146b21740b4SAndreas Gohr    }
147b21740b4SAndreas Gohr
148a1128cc0SAndreas Gohr    /** @inheritDoc */
149a1128cc0SAndreas Gohr    public function cleanUser($user)
15080ac552fSAndreas Gohr    {
151a1128cc0SAndreas Gohr        return $this->simpleUser($user);
15280ac552fSAndreas Gohr    }
15380ac552fSAndreas Gohr
154a1128cc0SAndreas Gohr    /** @inheritDoc */
155a1128cc0SAndreas Gohr    public function cleanGroup($group)
156a1128cc0SAndreas Gohr    {
157a1128cc0SAndreas Gohr        return PhpString::strtolower($group);
158a1128cc0SAndreas Gohr    }
159a1128cc0SAndreas Gohr
160a1128cc0SAndreas Gohr    /** @inheritDoc */
161a1128cc0SAndreas Gohr    public function prepareBindUser($user)
162a1128cc0SAndreas Gohr    {
163a1128cc0SAndreas Gohr        $user = $this->qualifiedUser($user); // add account suffix
164a1128cc0SAndreas Gohr        return $user;
16580ac552fSAndreas Gohr    }
16680ac552fSAndreas Gohr
16780ac552fSAndreas Gohr    /**
16880ac552fSAndreas Gohr     * @inheritDoc
169a1128cc0SAndreas Gohr     * userPrincipalName in the form <user>@<suffix>
17080ac552fSAndreas Gohr     */
171a1128cc0SAndreas Gohr    protected function qualifiedUser($user)
172a1128cc0SAndreas Gohr    {
173a1128cc0SAndreas Gohr        $user = $this->simpleUser($user); // strip any existing qualifiers
174a1128cc0SAndreas Gohr        if (!$this->config['suffix']) {
175a1128cc0SAndreas Gohr            $this->error('No account suffix set. Logins may fail.', __FILE__, __LINE__);
176a1128cc0SAndreas Gohr        }
177a1128cc0SAndreas Gohr
178a1128cc0SAndreas Gohr        return $user . '@' . $this->config['suffix'];
179a1128cc0SAndreas Gohr    }
180a1128cc0SAndreas Gohr
181a1128cc0SAndreas Gohr    /**
182a1128cc0SAndreas Gohr     * @inheritDoc
183a1128cc0SAndreas Gohr     * Removes the account suffix from the given user. Should match the SAMAccountName
184a1128cc0SAndreas Gohr     */
185a1128cc0SAndreas Gohr    protected function simpleUser($user)
18680ac552fSAndreas Gohr    {
18780ac552fSAndreas Gohr        $user = PhpString::strtolower($user);
188a1128cc0SAndreas Gohr        $user = preg_replace('/@.*$/', '', $user);
189a1128cc0SAndreas Gohr        $user = preg_replace('/^.*\\\\/', '', $user);
19080ac552fSAndreas Gohr        return $user;
19180ac552fSAndreas Gohr    }
19280ac552fSAndreas Gohr
19380ac552fSAndreas Gohr    /**
194b21740b4SAndreas Gohr     * Transform an LDAP entry to a user info array
195b21740b4SAndreas Gohr     *
196b21740b4SAndreas Gohr     * @param Entry $entry
197b21740b4SAndreas Gohr     * @return array
198b21740b4SAndreas Gohr     */
199b21740b4SAndreas Gohr    protected function entry2User(Entry $entry)
200b21740b4SAndreas Gohr    {
201b914569fSAndreas Gohr        $user = [
202a1128cc0SAndreas Gohr            'user' => $this->simpleUser($this->attr2str($entry->get('sAMAccountName'))),
2031078ec26SAndreas Gohr            'name' => $this->attr2str($entry->get('DisplayName')) ?: $this->attr2str($entry->get('Name')),
2041078ec26SAndreas Gohr            'mail' => $this->attr2str($entry->get('mail')),
2051078ec26SAndreas Gohr            'dn' => $entry->getDn()->toString(),
2061078ec26SAndreas Gohr            'grps' => $this->getUserGroups($entry), // we always return groups because its currently inexpensive
2071078ec26SAndreas Gohr        ];
208b914569fSAndreas Gohr
209b914569fSAndreas Gohr        // get additional attributes
210b914569fSAndreas Gohr        foreach ($this->config['attributes'] as $attr) {
211b914569fSAndreas Gohr            $user[$attr] = $this->attr2str($entry->get($attr));
212b914569fSAndreas Gohr        }
213b914569fSAndreas Gohr
214b914569fSAndreas Gohr        return $user;
2151078ec26SAndreas Gohr    }
2161078ec26SAndreas Gohr
2171078ec26SAndreas Gohr    /**
2181078ec26SAndreas Gohr     * Get the list of groups the given user is member of
2191078ec26SAndreas Gohr     *
2201078ec26SAndreas Gohr     * This method currently does no LDAP queries and thus is inexpensive.
2211078ec26SAndreas Gohr     *
2221078ec26SAndreas Gohr     * @param Entry $userentry
2231078ec26SAndreas Gohr     * @return array
2241078ec26SAndreas Gohr     */
2251078ec26SAndreas Gohr    protected function getUserGroups(Entry $userentry)
2261078ec26SAndreas Gohr    {
2271078ec26SAndreas Gohr        $groups = [$this->config['defaultgroup']]; // always add default
2281078ec26SAndreas Gohr
2291078ec26SAndreas Gohr        // resolving the primary group in AD is complicated but basically never needed
2301078ec26SAndreas Gohr        // http://support.microsoft.com/?kbid=321360
2311078ec26SAndreas Gohr        $gid = $userentry->get('primaryGroupID')->firstValue();
2321078ec26SAndreas Gohr        if ($gid == 513) {
233a1128cc0SAndreas Gohr            $groups[] = $this->cleanGroup('domain users');
2341078ec26SAndreas Gohr        }
2351078ec26SAndreas Gohr
236*f17bb68bSAndreas Gohr        if ($this->config['recursivegroups']) {
237*f17bb68bSAndreas Gohr            // we do an additional query for the user's groups asking the AD server to resolve nested
238*f17bb68bSAndreas Gohr            // groups for us
239*f17bb68bSAndreas Gohr            if (!$this->autoAuth()) return $groups;
240*f17bb68bSAndreas Gohr            $filter = Filters::extensible('member', (string)$userentry->getDn(), self::LDAP_MATCHING_RULE_IN_CHAIN,
241*f17bb68bSAndreas Gohr                true);
242*f17bb68bSAndreas Gohr            $search = Operations::search($filter, 'name');
24351e92298SAndreas Gohr            $paging = $this->ldap->paging($search);
24451e92298SAndreas Gohr            while ($paging->hasEntries()) {
24551e92298SAndreas Gohr                try {
24651e92298SAndreas Gohr                    $entries = $paging->getEntries();
24751e92298SAndreas Gohr                } catch (ProtocolException $e) {
248*f17bb68bSAndreas Gohr                    return $groups; // return what we have
24951e92298SAndreas Gohr                }
25051e92298SAndreas Gohr                /** @var Entry $entry */
25151e92298SAndreas Gohr                foreach ($entries as $entry) {
252*f17bb68bSAndreas Gohr                    $groups[] = $this->cleanGroup(($entry->get('name')->getValues())[0]);
25351e92298SAndreas Gohr                }
25451e92298SAndreas Gohr            }
25551e92298SAndreas Gohr
256*f17bb68bSAndreas Gohr        } elseif ($userentry->has('memberOf')) {
257*f17bb68bSAndreas Gohr            // we simply take the first CN= part of the group DN and return it as the group name
258*f17bb68bSAndreas Gohr            // this should be correct for ActiveDirectory and saves us additional LDAP queries
259*f17bb68bSAndreas Gohr            foreach ($userentry->get('memberOf')->getValues() as $dn) {
260*f17bb68bSAndreas Gohr                list($cn) = explode(',', $dn, 2);
261*f17bb68bSAndreas Gohr                $groups[] = $this->cleanGroup(substr($cn, 3));
262*f17bb68bSAndreas Gohr            }
26351e92298SAndreas Gohr        }
26451e92298SAndreas Gohr
265*f17bb68bSAndreas Gohr        sort($groups);
266*f17bb68bSAndreas Gohr        return $groups;
26751e92298SAndreas Gohr    }
26851e92298SAndreas Gohr
2699c590892SAndreas Gohr    /** @inheritDoc */
2709c590892SAndreas Gohr    protected function userAttributes()
2719c590892SAndreas Gohr    {
2729c590892SAndreas Gohr        $attr = parent::userAttributes();
273a1128cc0SAndreas Gohr        $attr[] = new Attribute('sAMAccountName');
2749c590892SAndreas Gohr        $attr[] = new Attribute('Name');
2759c590892SAndreas Gohr        $attr[] = new Attribute('primaryGroupID');
2769c590892SAndreas Gohr        $attr[] = new Attribute('memberOf');
2779c590892SAndreas Gohr
2789c590892SAndreas Gohr        return $attr;
2799c590892SAndreas Gohr    }
2801078ec26SAndreas Gohr}
281