Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.55% covered (success)
93.55%
145 / 155
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Ldap
93.55% covered (success)
93.55%
145 / 155
72.73% covered (warning)
72.73%
8 / 11
56.84
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 connect
75.00% covered (warning)
75.00%
24 / 32
0.00% covered (danger)
0.00%
0 / 1
11.56
 bind
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getMail
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLdapData
97.96% covered (success)
97.96%
48 / 49
0.00% covered (danger)
0.00%
0 / 1
17
 quote
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLdapDn
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
7
 getCompleteName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGroupMemberships
97.14% covered (success)
97.14%
34 / 35
0.00% covered (danger)
0.00%
0 / 1
11
 error
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * The Ldap class provides methods and functions for LDAP and/or Active Directory.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Adam Greene <phpmyfaq@skippy.fastmail.fm>
12 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
13 * @author    Alberto Cabello Sanchez <alberto@unex.es>
14 * @author    Lars Scheithauer <larsscheithauer@googlemail.com>
15 * @copyright 2004-2026 phpMyFAQ Team
16 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
17 * @link      https://www.phpmyfaq.de
18 * @since     2004-12-16
19 */
20
21declare(strict_types=1);
22
23namespace phpMyFAQ;
24
25use LDAP\Connection;
26use SensitiveParameter;
27
28/**
29 * Class Ldap
30 *
31 * @package phpMyFAQ
32 */
33class Ldap
34{
35    /**
36     * Error.
37     */
38    public ?string $error = null;
39
40    /**
41     * LDAP error number.
42     */
43    public ?int $errno = null;
44
45    private readonly array $ldapConfig;
46
47    /**
48     * The LDAP connection, present after a successful connect().
49     */
50    private ?Connection $ds = null;
51
52    /**
53     * The LDAP base.
54     */
55    private ?string $base = null;
56
57    /**
58     * Constructor.
59     */
60    public function __construct(
61        private readonly Configuration $configuration,
62    ) {
63        $this->ldapConfig = $this->configuration->getLdapConfig();
64    }
65
66    /**
67     * Connects to a given LDAP server with given credentials.
68     */
69    public function connect(
70        string $ldapServer,
71        int $ldapPort,
72        string $ldapBase,
73        string $ldapUser = '',
74        #[SensitiveParameter]
75        string $ldapPassword = '',
76    ): bool {
77        // Sanity checks
78        if ('' === $ldapServer || '' === $ldapBase) {
79            return false;
80        }
81
82        $this->base = $ldapBase;
83        $connection = ldap_connect($ldapServer . ':' . $ldapPort);
84
85        if (!$connection instanceof Connection) {
86            $this->error = 'Unable to connect to LDAP server';
87            $this->ds = null;
88
89            return false;
90        }
91
92        $this->ds = $connection;
93
94        // Set LDAP options
95        foreach ($this->configuration->getLdapOptions() as $key => $ldapOption) {
96            if (ldap_set_option($connection, (int) constant($key), $ldapOption)) {
97                continue;
98            }
99
100            $this->errno = ldap_errno($connection);
101            $errorMessage = 'Unable to set LDAP option "%s" to "%s" (Error: %s).';
102            $this->error = sprintf($errorMessage, $key, print_r($ldapOption, return: true), ldap_error($connection));
103
104            return false;
105        }
106
107        $ldapBind = match (true) {
108            true === $this->configuration->get(item: 'ldap.ldap_use_dynamic_login') => $this->bind(
109                (string) $this->configuration->get(item: 'ldap.ldap_dynamic_login_attribute')
110                . '='
111                . $ldapUser
112                . ','
113                . $ldapBase,
114                $ldapPassword,
115            ),
116            true === $this->configuration->get(item: 'ldap.ldap_use_anonymous_login') => $this->bind(),
117            default => $this->bind($ldapUser, $ldapPassword),
118        };
119
120        if (false === $ldapBind) {
121            $this->errno = ldap_errno($connection);
122            $this->error = sprintf('Unable to bind to LDAP server (Error: %s).', ldap_error($connection));
123            $this->ds = null;
124
125            return false;
126        }
127
128        return true;
129    }
130
131    /**
132     * Binds to the LDAP directory with specified RDN and password.
133     */
134    /* @mago-expect lint:no-error-control-operator - ldap_bind raises warnings instead of exceptions; the boolean result is the error signal */
135    public function bind(string $rdn = '', #[SensitiveParameter] string $password = ''): bool
136    {
137        if (!$this->ds instanceof Connection) {
138            $this->error = 'The LDAP connection handler is not a valid resource.';
139
140            return false;
141        }
142
143        if ('' === $rdn && '' === $password) {
144            return @ldap_bind($this->ds, dn: null, password: null);
145        }
146
147        return @ldap_bind($this->ds, $rdn, $password);
148    }
149
150    /**
151     * Returns the user's email address from LDAP.
152     *
153     * @param string $username Username
154     */
155    public function getMail(string $username): bool|string
156    {
157        return $this->getLdapData($username, data: 'mail');
158    }
159
160    /**
161     * Returns specific data from LDAP.
162     *
163     * @param string $username Username
164     * @param string $data     MapKey
165     */
166    /* @mago-expect lint:no-error-control-operator - ldap_search raises warnings instead of exceptions; failures are reported via $this->error */
167    private function getLdapData(string $username, string $data): bool|string
168    {
169        $connection = $this->ds;
170        if (!$connection instanceof Connection) {
171            $this->error = 'The LDAP connection handler is not a valid resource.';
172
173            return false;
174        }
175
176        $base = $this->base;
177        if ($base === null || $base === '') {
178            $this->error = 'LDAP base DN is not configured.';
179
180            return false;
181        }
182
183        $mapping = $this->ldapConfig['ldap_mapping'] ?? null;
184        if (!is_array($mapping) || !array_key_exists($data, $mapping)) {
185            $errorMessage = 'The requested data field "%s" does not exist in LDAP mapping configuration.';
186            $this->error = sprintf($errorMessage, $data);
187
188            return false;
189        }
190
191        $comparison = '(%s=%s)';
192        $filter = sprintf(
193            $comparison,
194            (string) $this->configuration->get(item: 'ldap.ldap_mapping.username'),
195            $this->quote($username),
196        );
197
198        if ($this->configuration->get(item: 'ldap.ldap_use_memberOf')) {
199            $comparison = '(&%s(memberOf:1.2.840.113556.1.4.1941:=%s))';
200            $filter = sprintf(
201                $comparison,
202                $filter,
203                (string) $this->configuration->get(item: 'ldap.ldap_mapping.memberOf'),
204            );
205        }
206
207        $field = (string) $mapping[$data];
208
209        $searchResult = @ldap_search($connection, $base, $filter, [$field]);
210
211        if (!$searchResult || is_array($searchResult)) {
212            $errorMessage = 'Unable to search for "%s" (Error: %s)';
213            $this->error = sprintf($errorMessage, $username, ldap_error($connection));
214
215            return false;
216        }
217
218        $entryId = ldap_first_entry($connection, $searchResult);
219
220        if (!$entryId) {
221            $this->errno = ldap_errno($connection);
222            $this->error = sprintf('Cannot get the value(s). Error: %s', ldap_error($connection));
223
224            return false;
225        }
226
227        $entries = ldap_get_entries($connection, $searchResult);
228        if ($entries === false) {
229            return false;
230        }
231
232        for ($i = 0; $i < (int) $entries['count']; ++$i) {
233            if (
234                !array_key_exists($i, $entries)
235                || !is_array($entries[$i])
236                || !array_key_exists($field, $entries[$i])
237                || !is_array($entries[$i][$field])
238                || !array_key_exists(0, $entries[$i][$field])
239            ) {
240                continue;
241            }
242
243            return (string) $entries[$i][$field][0];
244        }
245
246        return false;
247    }
248
249    /**
250     * Quotes LDAP strings in accordance with the RFC 2254.
251     */
252    public function quote(string $string): string
253    {
254        return str_replace(['\\', ' ', '*', '(', ')'], ['\\5c', '\\20', '\\2a', '\\28', '\\29'], $string);
255    }
256
257    /**
258     * Returns the user's DN.
259     *
260     * @param string $username Username
261     */
262    public function getDn(string $username): bool|string
263    {
264        return $this->getLdapDn($username);
265    }
266
267    /**
268     * Returns the DN from LDAP.
269     *
270     * @param string $username Username
271     */
272    /* @mago-expect lint:no-error-control-operator - ldap_search raises warnings instead of exceptions; failures are reported via $this->error */
273    private function getLdapDn(string $username): string|false
274    {
275        $connection = $this->ds;
276        if (!$connection instanceof Connection) {
277            $this->error = 'The LDAP connection handler is not a valid resource.';
278
279            return false;
280        }
281
282        $base = $this->base;
283        if ($base === null || $base === '') {
284            $this->error = 'LDAP base DN is not configured.';
285
286            return false;
287        }
288
289        $comparison = '(%s=%s)';
290        $filter = sprintf(
291            $comparison,
292            (string) $this->configuration->get(item: 'ldap.ldap_mapping.username'),
293            $this->quote($username),
294        );
295        $sr = @ldap_search($connection, $base, $filter);
296
297        if (false === $sr || is_array($sr)) {
298            $errorMessage = 'Unable to search for "%s" (Error: %s)';
299            $this->error = sprintf($errorMessage, $username, ldap_error($connection));
300
301            return false;
302        }
303
304        $entryId = ldap_first_entry($connection, $sr);
305
306        if (false === $entryId) {
307            $this->error = sprintf('Cannot get the value(s). Error: %s', ldap_error($connection));
308
309            return false;
310        }
311
312        return ldap_get_dn($connection, $entryId);
313    }
314
315    /**
316     * Returns the user's full name from LDAP.
317     *
318     * @param string $username Username
319     */
320    public function getCompleteName(string $username): bool|string
321    {
322        return $this->getLdapData($username, data: 'name');
323    }
324
325    /**
326     * Returns the user's AD group memberships.
327     *
328     * @param string $username Username
329     * @return array<string>|false Array of group DNs or false on error
330     */
331    /* @mago-expect lint:no-error-control-operator - ldap_search raises warnings instead of exceptions; failures are reported via $this->error */
332    public function getGroupMemberships(string $username): array|false
333    {
334        $connection = $this->ds;
335        if (!$connection instanceof Connection) {
336            $this->error = 'The LDAP connection handler is not a valid resource.';
337            return false;
338        }
339
340        $base = $this->base;
341        if ($base === null || $base === '') {
342            $this->error = 'LDAP base DN is not configured.';
343            return false;
344        }
345
346        $comparison = '(%s=%s)';
347        $filter = sprintf(
348            $comparison,
349            (string) $this->configuration->get(item: 'ldap.ldap_mapping.username'),
350            $this->quote($username),
351        );
352
353        $fields = ['memberOf'];
354
355        $searchResult = @ldap_search($connection, $base, $filter, $fields);
356
357        if (!$searchResult || is_array($searchResult)) {
358            $errorMessage = 'Unable to search for "%s" (Error: %s)';
359            $this->error = sprintf($errorMessage, $username, ldap_error($connection));
360
361            return false;
362        }
363
364        $entryId = ldap_first_entry($connection, $searchResult);
365
366        if (!$entryId) {
367            $this->errno = ldap_errno($connection);
368            $this->error = sprintf('Cannot get the value(s). Error: %s', ldap_error($connection));
369
370            return false;
371        }
372
373        $entries = ldap_get_entries($connection, $searchResult);
374        $groups = [];
375        if ($entries === false) {
376            return $groups;
377        }
378
379        $memberOf = $entries[0]['memberof'] ?? null;
380        if ((int) $entries['count'] > 0 && is_array($memberOf)) {
381            $memberOfCount = (int) ($memberOf['count'] ?? 0);
382            for ($i = 0; $i < $memberOfCount; $i++) {
383                $groups[] = (string) $memberOf[$i];
384            }
385        }
386
387        return $groups;
388    }
389
390    /**
391     * Returns the LDAP error message of the last LDAP command.
392     */
393    public function error(?Connection $ds = null): string
394    {
395        $connection = $ds ?? $this->ds;
396        if (!$connection instanceof Connection) {
397            return 'The LDAP connection handler is not a valid resource.';
398        }
399
400        return ldap_error($connection);
401    }
402}