xref: /plugin/pureldap/classes/ADClient.php (revision 5dcabeda2fad4e4ee9d5e2783f1e5e830b0344f4)
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
14f17bb68bSAndreas Gohr/**
15f17bb68bSAndreas Gohr * Implement Active Directory Specifics
16f17bb68bSAndreas Gohr */
171078ec26SAndreas Gohrclass ADClient extends Client
181078ec26SAndreas Gohr{
19e7339d5aSAndreas Gohr    /**
20e7339d5aSAndreas Gohr     * @var GroupHierarchyCache
21e7339d5aSAndreas Gohr     * @see getGroupHierarchyCache
22e7339d5aSAndreas Gohr     */
23e7339d5aSAndreas Gohr    protected $gch = null;
241078ec26SAndreas Gohr
251078ec26SAndreas Gohr    /** @inheritDoc */
261078ec26SAndreas Gohr    public function getUser($username, $fetchgroups = true)
271078ec26SAndreas Gohr    {
281078ec26SAndreas Gohr        if (!$this->autoAuth()) return null;
29a1128cc0SAndreas Gohr        $username = $this->simpleUser($username);
301078ec26SAndreas Gohr
311078ec26SAndreas Gohr        $filter = Filters::and(
321078ec26SAndreas Gohr            Filters::equal('objectClass', 'user'),
33a1128cc0SAndreas Gohr            Filters::equal('sAMAccountName', $this->simpleUser($username))
341078ec26SAndreas Gohr        );
35b21740b4SAndreas Gohr        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
361078ec26SAndreas Gohr
371078ec26SAndreas Gohr        try {
381078ec26SAndreas Gohr            /** @var Entries $entries */
399c590892SAndreas Gohr            $attributes = $this->userAttributes();
409c590892SAndreas Gohr            $entries = $this->ldap->search(Operations::search($filter, ...$attributes));
411078ec26SAndreas Gohr        } catch (OperationException $e) {
42b21740b4SAndreas Gohr            $this->fatal($e);
431078ec26SAndreas Gohr            return null;
441078ec26SAndreas Gohr        }
451078ec26SAndreas Gohr        if ($entries->count() !== 1) return null;
461078ec26SAndreas Gohr        $entry = $entries->first();
47b21740b4SAndreas Gohr        return $this->entry2User($entry);
48b21740b4SAndreas Gohr    }
491078ec26SAndreas Gohr
50b21740b4SAndreas Gohr    /** @inheritDoc */
51204fba68SAndreas Gohr    public function getGroups($match = null, $filtermethod = self::FILTER_EQUAL)
52b21740b4SAndreas Gohr    {
53b21740b4SAndreas Gohr        if (!$this->autoAuth()) return [];
54b21740b4SAndreas Gohr
55b21740b4SAndreas Gohr        $filter = Filters::and(
56b21740b4SAndreas Gohr            Filters::equal('objectClass', 'group')
57b21740b4SAndreas Gohr        );
58b21740b4SAndreas Gohr        if ($match !== null) {
59e7c3e817SAndreas Gohr            // FIXME this is a workaround that removes regex anchors and quoting as passed by the groupuser plugin
60e7c3e817SAndreas Gohr            // a proper fix requires splitbrain/dokuwiki#3028 to be implemented
61fce018daSAndreas Gohr            $match = ltrim($match, '^');
62fce018daSAndreas Gohr            $match = rtrim($match, '$');
63e7c3e817SAndreas Gohr            $match = stripslashes($match);
64fce018daSAndreas Gohr
65b21740b4SAndreas Gohr            $filter->add(Filters::$filtermethod('cn', $match));
66b21740b4SAndreas Gohr        }
67b21740b4SAndreas Gohr
68b21740b4SAndreas Gohr        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
69b21740b4SAndreas Gohr        $search = Operations::search($filter, 'cn');
70b21740b4SAndreas Gohr        $paging = $this->ldap->paging($search);
71b21740b4SAndreas Gohr
72b21740b4SAndreas Gohr        $groups = [];
73b21740b4SAndreas Gohr        while ($paging->hasEntries()) {
74b21740b4SAndreas Gohr            try {
75b21740b4SAndreas Gohr                $entries = $paging->getEntries();
76b21740b4SAndreas Gohr            } catch (ProtocolException $e) {
77b21740b4SAndreas Gohr                $this->fatal($e);
78b21740b4SAndreas Gohr                return $groups; // we return what we got so far
79b21740b4SAndreas Gohr            }
80b21740b4SAndreas Gohr
81b21740b4SAndreas Gohr            foreach ($entries as $entry) {
82b21740b4SAndreas Gohr                /** @var Entry $entry */
83204fba68SAndreas Gohr                $groups[$entry->getDn()->toString()] = $this->cleanGroup($this->attr2str($entry->get('cn')));
84b21740b4SAndreas Gohr            }
85b21740b4SAndreas Gohr        }
86b21740b4SAndreas Gohr
871b0eb9b3SAndreas Gohr        asort($groups);
88b21740b4SAndreas Gohr        return $groups;
89b21740b4SAndreas Gohr    }
90b21740b4SAndreas Gohr
91b21740b4SAndreas Gohr    /**
92b21740b4SAndreas Gohr     * Fetch users matching the given filters
93b21740b4SAndreas Gohr     *
94b21740b4SAndreas Gohr     * @param array $match
95b21740b4SAndreas Gohr     * @param string $filtermethod The method to use for filtering
96b21740b4SAndreas Gohr     * @return array
97b21740b4SAndreas Gohr     */
98204fba68SAndreas Gohr    public function getFilteredUsers($match, $filtermethod = self::FILTER_EQUAL)
99b21740b4SAndreas Gohr    {
100b21740b4SAndreas Gohr        if (!$this->autoAuth()) return [];
101b21740b4SAndreas Gohr
102b21740b4SAndreas Gohr        $filter = Filters::and(Filters::equal('objectClass', 'user'));
103b21740b4SAndreas Gohr        if (isset($match['user'])) {
104a1128cc0SAndreas Gohr            $filter->add(Filters::$filtermethod('sAMAccountName', $this->simpleUser($match['user'])));
105b21740b4SAndreas Gohr        }
106b21740b4SAndreas Gohr        if (isset($match['name'])) {
107b21740b4SAndreas Gohr            $filter->add(Filters::$filtermethod('displayName', $match['name']));
108b21740b4SAndreas Gohr        }
109b21740b4SAndreas Gohr        if (isset($match['mail'])) {
110b21740b4SAndreas Gohr            $filter->add(Filters::$filtermethod('mail', $match['mail']));
111b21740b4SAndreas Gohr        }
112b21740b4SAndreas Gohr        if (isset($match['grps'])) {
113b21740b4SAndreas Gohr            // memberOf can not be checked with a substring match, so we need to get the right groups first
114b21740b4SAndreas Gohr            $groups = $this->getGroups($match['grps'], $filtermethod);
115e7339d5aSAndreas Gohr            $groupDNs = array_keys($groups);
116e7339d5aSAndreas Gohr
117e7339d5aSAndreas Gohr            if ($this->config['recursivegroups']) {
118e7339d5aSAndreas Gohr                $gch = $this->getGroupHierarchyCache();
119e7339d5aSAndreas Gohr                foreach ($groupDNs as $dn) {
120e7339d5aSAndreas Gohr                    $groupDNs = array_merge($groupDNs, $gch->getChildren($dn));
121e7339d5aSAndreas Gohr                }
122e7339d5aSAndreas Gohr                $groupDNs = array_unique($groupDNs);
123e7339d5aSAndreas Gohr            }
124e7339d5aSAndreas Gohr
125b21740b4SAndreas Gohr            $or = Filters::or();
126e7339d5aSAndreas Gohr            foreach ($groupDNs as $dn) {
127204fba68SAndreas Gohr                // domain users membership is in primary group
128e7339d5aSAndreas Gohr                if ($this->dn2group($dn) === $this->config['primarygroup']) {
129204fba68SAndreas Gohr                    $or->add(Filters::equal('primaryGroupID', 513));
130204fba68SAndreas Gohr                    continue;
131204fba68SAndreas Gohr                }
1327a36c1b4SAndreas Gohr                // find members of this exact group
1337a36c1b4SAndreas Gohr                $or->add(Filters::equal('memberOf', $dn));
134b21740b4SAndreas Gohr            }
135b21740b4SAndreas Gohr            $filter->add($or);
136b21740b4SAndreas Gohr        }
137e7339d5aSAndreas Gohr
138b21740b4SAndreas Gohr        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
1399c590892SAndreas Gohr        $attributes = $this->userAttributes();
1409c590892SAndreas Gohr        $search = Operations::search($filter, ...$attributes);
141b21740b4SAndreas Gohr        $paging = $this->ldap->paging($search);
142b21740b4SAndreas Gohr
143b21740b4SAndreas Gohr        $users = [];
144b21740b4SAndreas Gohr        while ($paging->hasEntries()) {
145b21740b4SAndreas Gohr            try {
146b21740b4SAndreas Gohr                $entries = $paging->getEntries();
147b21740b4SAndreas Gohr            } catch (ProtocolException $e) {
148b21740b4SAndreas Gohr                $this->fatal($e);
14980ac552fSAndreas Gohr                break; // we abort and return what we have so far
150b21740b4SAndreas Gohr            }
151b21740b4SAndreas Gohr
152b21740b4SAndreas Gohr            foreach ($entries as $entry) {
153746af42cSAndreas Gohr                $userinfo = $this->entry2User($entry, false);
154746af42cSAndreas Gohr                $users[$userinfo['user']] = $userinfo;
155b21740b4SAndreas Gohr            }
156b21740b4SAndreas Gohr        }
157b21740b4SAndreas Gohr
1581b0eb9b3SAndreas Gohr        ksort($users);
159b21740b4SAndreas Gohr        return $users;
160b21740b4SAndreas Gohr    }
161b21740b4SAndreas Gohr
162a1128cc0SAndreas Gohr    /** @inheritDoc */
163a1128cc0SAndreas Gohr    public function cleanUser($user)
16480ac552fSAndreas Gohr    {
165a1128cc0SAndreas Gohr        return $this->simpleUser($user);
16680ac552fSAndreas Gohr    }
16780ac552fSAndreas Gohr
168a1128cc0SAndreas Gohr    /** @inheritDoc */
169a1128cc0SAndreas Gohr    public function cleanGroup($group)
170a1128cc0SAndreas Gohr    {
171a1128cc0SAndreas Gohr        return PhpString::strtolower($group);
172a1128cc0SAndreas Gohr    }
173a1128cc0SAndreas Gohr
174a1128cc0SAndreas Gohr    /** @inheritDoc */
175a1128cc0SAndreas Gohr    public function prepareBindUser($user)
176a1128cc0SAndreas Gohr    {
177a1128cc0SAndreas Gohr        $user = $this->qualifiedUser($user); // add account suffix
178a1128cc0SAndreas Gohr        return $user;
17980ac552fSAndreas Gohr    }
18080ac552fSAndreas Gohr
18180ac552fSAndreas Gohr    /**
182e7339d5aSAndreas Gohr     * Initializes the Group Cache for nested groups
183e7339d5aSAndreas Gohr     *
184e7339d5aSAndreas Gohr     * @return GroupHierarchyCache
185e7339d5aSAndreas Gohr     */
186e7339d5aSAndreas Gohr    public function getGroupHierarchyCache()
187e7339d5aSAndreas Gohr    {
188e7339d5aSAndreas Gohr        if ($this->gch === null) {
189e7339d5aSAndreas Gohr            if (!$this->autoAuth()) return null;
190*5dcabedaSAndreas Gohr            $this->gch = new GroupHierarchyCache($this->ldap, $this->config['usefscache']);
191e7339d5aSAndreas Gohr        }
192e7339d5aSAndreas Gohr        return $this->gch;
193e7339d5aSAndreas Gohr    }
194e7339d5aSAndreas Gohr
195e7339d5aSAndreas Gohr    /**
19680ac552fSAndreas Gohr     * @inheritDoc
197a1128cc0SAndreas Gohr     * userPrincipalName in the form <user>@<suffix>
19880ac552fSAndreas Gohr     */
199a1128cc0SAndreas Gohr    protected function qualifiedUser($user)
200a1128cc0SAndreas Gohr    {
201a1128cc0SAndreas Gohr        $user = $this->simpleUser($user); // strip any existing qualifiers
202a1128cc0SAndreas Gohr        if (!$this->config['suffix']) {
203a1128cc0SAndreas Gohr            $this->error('No account suffix set. Logins may fail.', __FILE__, __LINE__);
204a1128cc0SAndreas Gohr        }
205a1128cc0SAndreas Gohr
206a1128cc0SAndreas Gohr        return $user . '@' . $this->config['suffix'];
207a1128cc0SAndreas Gohr    }
208a1128cc0SAndreas Gohr
209a1128cc0SAndreas Gohr    /**
210a1128cc0SAndreas Gohr     * @inheritDoc
211a1128cc0SAndreas Gohr     * Removes the account suffix from the given user. Should match the SAMAccountName
212a1128cc0SAndreas Gohr     */
213a1128cc0SAndreas Gohr    protected function simpleUser($user)
21480ac552fSAndreas Gohr    {
21580ac552fSAndreas Gohr        $user = PhpString::strtolower($user);
216a1128cc0SAndreas Gohr        $user = preg_replace('/@.*$/', '', $user);
217a1128cc0SAndreas Gohr        $user = preg_replace('/^.*\\\\/', '', $user);
21880ac552fSAndreas Gohr        return $user;
21980ac552fSAndreas Gohr    }
22080ac552fSAndreas Gohr
22180ac552fSAndreas Gohr    /**
222b21740b4SAndreas Gohr     * Transform an LDAP entry to a user info array
223b21740b4SAndreas Gohr     *
224b21740b4SAndreas Gohr     * @param Entry $entry
225b21740b4SAndreas Gohr     * @return array
226b21740b4SAndreas Gohr     */
227b21740b4SAndreas Gohr    protected function entry2User(Entry $entry)
228b21740b4SAndreas Gohr    {
229b914569fSAndreas Gohr        $user = [
230a1128cc0SAndreas Gohr            'user' => $this->simpleUser($this->attr2str($entry->get('sAMAccountName'))),
2311078ec26SAndreas Gohr            'name' => $this->attr2str($entry->get('DisplayName')) ?: $this->attr2str($entry->get('Name')),
2321078ec26SAndreas Gohr            'mail' => $this->attr2str($entry->get('mail')),
2331078ec26SAndreas Gohr            'dn' => $entry->getDn()->toString(),
2341078ec26SAndreas Gohr            'grps' => $this->getUserGroups($entry), // we always return groups because its currently inexpensive
2351078ec26SAndreas Gohr        ];
236b914569fSAndreas Gohr
237b914569fSAndreas Gohr        // get additional attributes
238b914569fSAndreas Gohr        foreach ($this->config['attributes'] as $attr) {
239b914569fSAndreas Gohr            $user[$attr] = $this->attr2str($entry->get($attr));
240b914569fSAndreas Gohr        }
241b914569fSAndreas Gohr
242b914569fSAndreas Gohr        return $user;
2431078ec26SAndreas Gohr    }
2441078ec26SAndreas Gohr
2451078ec26SAndreas Gohr    /**
2461078ec26SAndreas Gohr     * Get the list of groups the given user is member of
2471078ec26SAndreas Gohr     *
2481078ec26SAndreas Gohr     * This method currently does no LDAP queries and thus is inexpensive.
2491078ec26SAndreas Gohr     *
2501078ec26SAndreas Gohr     * @param Entry $userentry
2511078ec26SAndreas Gohr     * @return array
2521078ec26SAndreas Gohr     */
2531078ec26SAndreas Gohr    protected function getUserGroups(Entry $userentry)
2541078ec26SAndreas Gohr    {
255e7339d5aSAndreas Gohr        $groups = [];
256e7339d5aSAndreas Gohr
257e7339d5aSAndreas Gohr        if ($userentry->has('memberOf')) {
258e7339d5aSAndreas Gohr            $groupDNs = $userentry->get('memberOf')->getValues();
259e7339d5aSAndreas Gohr
260e7339d5aSAndreas Gohr            if ($this->config['recursivegroups']) {
261e7339d5aSAndreas Gohr                $gch = $this->getGroupHierarchyCache();
262e7339d5aSAndreas Gohr                foreach ($groupDNs as $dn) {
263e7339d5aSAndreas Gohr                    $groupDNs = array_merge($groupDNs, $gch->getParents($dn));
264e7339d5aSAndreas Gohr                }
265e7339d5aSAndreas Gohr
266e7339d5aSAndreas Gohr                $groupDNs = array_unique($groupDNs);
267e7339d5aSAndreas Gohr            }
268e7339d5aSAndreas Gohr            $groups = array_map([$this, 'dn2group'], $groupDNs);
269e7339d5aSAndreas Gohr        }
270e7339d5aSAndreas Gohr
271e7339d5aSAndreas Gohr        $groups[] = $this->config['defaultgroup']; // always add default
2721078ec26SAndreas Gohr
2731078ec26SAndreas Gohr        // resolving the primary group in AD is complicated but basically never needed
2741078ec26SAndreas Gohr        // http://support.microsoft.com/?kbid=321360
2751078ec26SAndreas Gohr        $gid = $userentry->get('primaryGroupID')->firstValue();
2761078ec26SAndreas Gohr        if ($gid == 513) {
277e7339d5aSAndreas Gohr            $groups[] = $this->cleanGroup($this->config['primarygroup']);
27851e92298SAndreas Gohr        }
27951e92298SAndreas Gohr
280f17bb68bSAndreas Gohr        sort($groups);
281f17bb68bSAndreas Gohr        return $groups;
28251e92298SAndreas Gohr    }
28351e92298SAndreas Gohr
2849c590892SAndreas Gohr    /** @inheritDoc */
2859c590892SAndreas Gohr    protected function userAttributes()
2869c590892SAndreas Gohr    {
2879c590892SAndreas Gohr        $attr = parent::userAttributes();
288a1128cc0SAndreas Gohr        $attr[] = new Attribute('sAMAccountName');
2899c590892SAndreas Gohr        $attr[] = new Attribute('Name');
2909c590892SAndreas Gohr        $attr[] = new Attribute('primaryGroupID');
2919c590892SAndreas Gohr        $attr[] = new Attribute('memberOf');
2929c590892SAndreas Gohr
2939c590892SAndreas Gohr        return $attr;
2949c590892SAndreas Gohr    }
295e7339d5aSAndreas Gohr
296e7339d5aSAndreas Gohr    /**
297e7339d5aSAndreas Gohr     * Extract the group name from the DN
298e7339d5aSAndreas Gohr     *
299e7339d5aSAndreas Gohr     * @param string $dn
300e7339d5aSAndreas Gohr     * @return string
301e7339d5aSAndreas Gohr     */
302e7339d5aSAndreas Gohr    protected function dn2group($dn)
303e7339d5aSAndreas Gohr    {
304e7339d5aSAndreas Gohr        list($cn) = explode(',', $dn, 2);
305e7339d5aSAndreas Gohr        return $this->cleanGroup(substr($cn, 3));
306e7339d5aSAndreas Gohr    }
3071078ec26SAndreas Gohr}
308