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\Exception\ProtocolException;
11use FreeDSx\Ldap\Operations;
12use FreeDSx\Ldap\Search\Filters;
13
14/**
15 * Implement Active Directory Specifics
16 */
17class ADClient extends Client
18{
19    /**
20     * @var GroupHierarchyCache
21     * @see getGroupHierarchyCache
22     */
23    protected $gch = null;
24
25    /** @inheritDoc */
26    public function getUser($username, $fetchgroups = true)
27    {
28        if (!$this->autoAuth()) return null;
29        $username = $this->simpleUser($username);
30
31        $filter = Filters::and(
32            Filters::equal('objectClass', 'user'),
33            Filters::equal('sAMAccountName', $this->simpleUser($username))
34        );
35        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
36
37        try {
38            /** @var Entries $entries */
39            $attributes = $this->userAttributes();
40            $entries = $this->ldap->search(Operations::search($filter, ...$attributes));
41        } catch (OperationException $e) {
42            $this->fatal($e);
43            return null;
44        }
45        if ($entries->count() !== 1) return null;
46        $entry = $entries->first();
47        return $this->entry2User($entry);
48    }
49
50    /** @inheritDoc */
51    public function getGroups($match = null, $filtermethod = self::FILTER_EQUAL)
52    {
53        if (!$this->autoAuth()) return [];
54
55        $filter = Filters::and(
56            Filters::equal('objectClass', 'group')
57        );
58        if ($match !== null) {
59            // FIXME this is a workaround that removes regex anchors and quoting as passed by the groupuser plugin
60            // a proper fix requires splitbrain/dokuwiki#3028 to be implemented
61            $match = ltrim($match, '^');
62            $match = rtrim($match, '$');
63            $match = stripslashes($match);
64
65            $filter->add(Filters::$filtermethod('cn', $match));
66        }
67
68        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
69        $search = Operations::search($filter, 'cn');
70        $paging = $this->ldap->paging($search);
71
72        $groups = [];
73        while ($paging->hasEntries()) {
74            try {
75                $entries = $paging->getEntries();
76            } catch (ProtocolException $e) {
77                $this->fatal($e);
78                return $groups; // we return what we got so far
79            }
80
81            foreach ($entries as $entry) {
82                /** @var Entry $entry */
83                $groups[$entry->getDn()->toString()] = $this->cleanGroup($this->attr2str($entry->get('cn')));
84            }
85        }
86
87        asort($groups);
88        return $groups;
89    }
90
91    /**
92     * Fetch users matching the given filters
93     *
94     * @param array $match
95     * @param string $filtermethod The method to use for filtering
96     * @return array
97     */
98    public function getFilteredUsers($match, $filtermethod = self::FILTER_EQUAL)
99    {
100        if (!$this->autoAuth()) return [];
101
102        $filter = Filters::and(Filters::equal('objectClass', 'user'));
103        if (isset($match['user'])) {
104            $filter->add(Filters::$filtermethod('sAMAccountName', $this->simpleUser($match['user'])));
105        }
106        if (isset($match['name'])) {
107            $filter->add(Filters::$filtermethod('displayName', $match['name']));
108        }
109        if (isset($match['mail'])) {
110            $filter->add(Filters::$filtermethod('mail', $match['mail']));
111        }
112        if (isset($match['grps'])) {
113            // memberOf can not be checked with a substring match, so we need to get the right groups first
114            $groups = $this->getGroups($match['grps'], $filtermethod);
115            $groupDNs = array_keys($groups);
116
117            if ($this->config['recursivegroups']) {
118                $gch = $this->getGroupHierarchyCache();
119                foreach ($groupDNs as $dn) {
120                    $groupDNs = array_merge($groupDNs, $gch->getChildren($dn));
121                }
122                $groupDNs = array_unique($groupDNs);
123            }
124
125            $or = Filters::or();
126            foreach ($groupDNs as $dn) {
127                // domain users membership is in primary group
128                if ($this->dn2group($dn) === $this->config['primarygroup']) {
129                    $or->add(Filters::equal('primaryGroupID', 513));
130                    continue;
131                }
132                // find members of this exact group
133                $or->add(Filters::equal('memberOf', $dn));
134            }
135            $filter->add($or);
136        }
137
138        $this->debug('Searching ' . $filter->toString(), __FILE__, __LINE__);
139        $attributes = $this->userAttributes();
140        $search = Operations::search($filter, ...$attributes);
141        $paging = $this->ldap->paging($search);
142
143        $users = [];
144        while ($paging->hasEntries()) {
145            try {
146                $entries = $paging->getEntries();
147            } catch (ProtocolException $e) {
148                $this->fatal($e);
149                break; // we abort and return what we have so far
150            }
151
152            foreach ($entries as $entry) {
153                $userinfo = $this->entry2User($entry, false);
154                $users[$userinfo['user']] = $userinfo;
155            }
156        }
157
158        ksort($users);
159        return $users;
160    }
161
162    /** @inheritDoc */
163    public function cleanUser($user)
164    {
165        return $this->simpleUser($user);
166    }
167
168    /** @inheritDoc */
169    public function cleanGroup($group)
170    {
171        return PhpString::strtolower($group);
172    }
173
174    /** @inheritDoc */
175    public function prepareBindUser($user)
176    {
177        $user = $this->qualifiedUser($user); // add account suffix
178        return $user;
179    }
180
181    /**
182     * Initializes the Group Cache for nested groups
183     *
184     * @return GroupHierarchyCache
185     */
186    public function getGroupHierarchyCache()
187    {
188        if ($this->gch === null) {
189            if (!$this->autoAuth()) return null;
190            $this->gch = new GroupHierarchyCache($this->ldap, $this->config['usefscache']);
191        }
192        return $this->gch;
193    }
194
195    /**
196     * @inheritDoc
197     * userPrincipalName in the form <user>@<suffix>
198     */
199    protected function qualifiedUser($user)
200    {
201        $user = $this->simpleUser($user); // strip any existing qualifiers
202        if (!$this->config['suffix']) {
203            $this->error('No account suffix set. Logins may fail.', __FILE__, __LINE__);
204        }
205
206        return $user . '@' . $this->config['suffix'];
207    }
208
209    /**
210     * @inheritDoc
211     * Removes the account suffix from the given user. Should match the SAMAccountName
212     */
213    protected function simpleUser($user)
214    {
215        $user = PhpString::strtolower($user);
216        $user = preg_replace('/@.*$/', '', $user);
217        $user = preg_replace('/^.*\\\\/', '', $user);
218        return $user;
219    }
220
221    /**
222     * Transform an LDAP entry to a user info array
223     *
224     * @param Entry $entry
225     * @return array
226     */
227    protected function entry2User(Entry $entry)
228    {
229        $user = [
230            'user' => $this->simpleUser($this->attr2str($entry->get('sAMAccountName'))),
231            'name' => $this->attr2str($entry->get('DisplayName')) ?: $this->attr2str($entry->get('Name')),
232            'mail' => $this->attr2str($entry->get('mail')),
233            'dn' => $entry->getDn()->toString(),
234            'grps' => $this->getUserGroups($entry), // we always return groups because its currently inexpensive
235        ];
236
237        // get additional attributes
238        foreach ($this->config['attributes'] as $attr) {
239            $user[$attr] = $this->attr2str($entry->get($attr));
240        }
241
242        return $user;
243    }
244
245    /**
246     * Get the list of groups the given user is member of
247     *
248     * This method currently does no LDAP queries and thus is inexpensive.
249     *
250     * @param Entry $userentry
251     * @return array
252     */
253    protected function getUserGroups(Entry $userentry)
254    {
255        $groups = [];
256
257        if ($userentry->has('memberOf')) {
258            $groupDNs = $userentry->get('memberOf')->getValues();
259
260            if ($this->config['recursivegroups']) {
261                $gch = $this->getGroupHierarchyCache();
262                foreach ($groupDNs as $dn) {
263                    $groupDNs = array_merge($groupDNs, $gch->getParents($dn));
264                }
265
266                $groupDNs = array_unique($groupDNs);
267            }
268            $groups = array_map([$this, 'dn2group'], $groupDNs);
269        }
270
271        $groups[] = $this->config['defaultgroup']; // always add default
272
273        // resolving the primary group in AD is complicated but basically never needed
274        // http://support.microsoft.com/?kbid=321360
275        $gid = $userentry->get('primaryGroupID')->firstValue();
276        if ($gid == 513) {
277            $groups[] = $this->cleanGroup($this->config['primarygroup']);
278        }
279
280        sort($groups);
281        return $groups;
282    }
283
284    /** @inheritDoc */
285    protected function userAttributes()
286    {
287        $attr = parent::userAttributes();
288        $attr[] = new Attribute('sAMAccountName');
289        $attr[] = new Attribute('Name');
290        $attr[] = new Attribute('primaryGroupID');
291        $attr[] = new Attribute('memberOf');
292
293        return $attr;
294    }
295
296    /**
297     * Extract the group name from the DN
298     *
299     * @param string $dn
300     * @return string
301     */
302    protected function dn2group($dn)
303    {
304        list($cn) = explode(',', $dn, 2);
305        return $this->cleanGroup(substr($cn, 3));
306    }
307}
308