Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.30% covered (success)
91.30%
126 / 138
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthLdap
91.30% covered (success)
91.30%
126 / 138
72.73% covered (warning)
72.73%
8 / 11
51.64
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 create
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
8
 assignUserToGroups
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
 extractGroupNameFromDn
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 update
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkCredentials
84.44% covered (success)
84.44%
38 / 45
0.00% covered (danger)
0.00%
0 / 1
20.36
 isValidLogin
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
4.68
 connect
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 createUser
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 createMediumPermission
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
1<?php
2
3/**
4 * Manages user authentication with the LDAP server.
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    Alberto Cabello <alberto@unex.es>
12 * @author    Lars Scheithauer <larsscheithauer@googlemail.com>
13 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
14 * @copyright 2009-2026 phpMyFAQ Team
15 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
16 * @link      https://www.phpmyfaq.de
17 * @since     2009-03-01
18 */
19
20declare(strict_types=1);
21
22namespace phpMyFAQ\Auth;
23
24use Closure;
25use phpMyFAQ\Auth;
26use phpMyFAQ\Configuration;
27use phpMyFAQ\Core\Exception;
28use phpMyFAQ\Enums\AuthenticationSourceType;
29use phpMyFAQ\Ldap as LdapCore;
30use phpMyFAQ\Permission\MediumPermission;
31use phpMyFAQ\User;
32use SensitiveParameter;
33
34/**
35 * Class AuthLdap
36 *
37 * @package phpMyFAQ\Auth
38 */
39class AuthLdap extends Auth implements AuthDriverInterface
40{
41    private LdapCore $ldapCore;
42    private readonly ?Closure $userFactory;
43    private readonly ?Closure $mediumPermissionFactory;
44
45    /** @var array<int, array<string, mixed>> Array of LDAP servers */
46    private readonly array $ldapServer;
47
48    /** @var int Active LDAP server */
49    private int $activeServer = 0;
50
51    private readonly bool $multipleServers;
52
53    /**
54     * @inheritDoc
55     * @throws Exception
56     */
57    public function __construct(
58        Configuration $configuration,
59        ?LdapCore $ldapCore = null,
60        ?Closure $userFactory = null,
61        ?Closure $mediumPermissionFactory = null,
62    ) {
63        $this->configuration = $configuration;
64        $this->ldapServer = $this->configuration->getLdapServer();
65        $this->multipleServers = true === $this->configuration->get(item: 'ldap.ldap_use_multiple_servers');
66        $this->ldapCore = $ldapCore ?? new LdapCore($configuration);
67        $this->userFactory = $userFactory;
68        $this->mediumPermissionFactory = $mediumPermissionFactory;
69
70        parent::__construct($this->configuration);
71
72        if ([] === $this->ldapServer) {
73            throw new AuthException('An error occurred while contacting LDAP: No configuration found.');
74        }
75
76        $this->connect($this->activeServer);
77    }
78
79    /**
80     * @inheritDoc
81     * @throws Exception
82     */
83    public function create(string $login, #[SensitiveParameter] string $password, string $domain = ''): bool
84    {
85        $result = false;
86        $user = $this->createUser();
87
88        try {
89            $result = $user->createUser($login, '', $domain);
90        } catch (\Exception $exception) {
91            $this->configuration->getLogger()->info($exception->getMessage());
92        }
93
94        $this->connect($this->activeServer);
95
96        if (!$result && in_array($user->getStatus(), ['blocked', 'protected'], strict: true)) {
97            $this->configuration
98                ->getLogger()
99                ->warning(sprintf(
100                    'LDAP login denied: local %s account "%s" cannot be activated via LDAP.',
101                    $user->getStatus(),
102                    $login,
103                ));
104            throw new AuthException(sprintf(
105                'Local account "%s" is %s and cannot be activated via LDAP.',
106                $login,
107                $user->getStatus(),
108            ));
109        }
110
111        $user->setStatus('active');
112        $user->setAuthSource(AuthenticationSourceType::AUTH_LDAP->value);
113
114        // Set user information from LDAP
115        $completeName = $this->ldapCore->getCompleteName($login);
116        $mail = $this->ldapCore->getMail($login);
117        $user->setUserData([
118            'display_name' => is_string($completeName) ? $completeName : '',
119            'email' => is_string($mail) ? $mail : '',
120        ]);
121
122        // Handle group assignments if enabled
123        $ldapGroupConfig = $this->configuration->getLdapGroupConfig();
124        if (
125            true === ($ldapGroupConfig['auto_assign'] ?? false)
126            && $this->configuration->get(item: 'security.permLevel') === 'medium'
127        ) {
128            $this->assignUserToGroups($login, $user->getUserId());
129        }
130
131        return $result;
132    }
133
134    /**
135     * Assigns user to phpMyFAQ groups based on AD group membership
136     *
137     * @param string $login Username
138     * @param int $userId User ID
139     */
140    private function assignUserToGroups(string $login, int $userId): void
141    {
142        $ldapGroupConfig = $this->configuration->getLdapGroupConfig();
143        $userGroups = $this->ldapCore->getGroupMemberships($login);
144
145        if ($userGroups === false) {
146            $this->configuration->getLogger()->warning('Unable to retrieve group memberships for user: ' . $login);
147            return;
148        }
149
150        $mediumPermission = $this->createMediumPermission();
151        $groupMappingRaw = $ldapGroupConfig['group_mapping'] ?? [];
152        $groupMapping = is_array($groupMappingRaw) ? $groupMappingRaw : [];
153
154        foreach ($userGroups as $userGroup) {
155            $groupName = $this->extractGroupNameFromDn($userGroup);
156
157            // Check if there's a specific mapping for this AD group
158            $faqGroupName = $groupName;
159            if (array_key_exists($groupName, $groupMapping)) {
160                $faqGroupName = (string) $groupMapping[$groupName];
161            }
162
163            // Find or create the group
164            $groupId = $mediumPermission->findOrCreateGroupByName($faqGroupName);
165
166            if ($groupId > 0) {
167                $mediumPermission->addToGroup($userId, $groupId);
168                $this->configuration->getLogger()->info(sprintf('Added user %s to group %s', $login, $faqGroupName));
169            }
170        }
171    }
172
173    /**
174     * Extract group name from DN
175     *
176     * @param string $dn Group DN
177     * @return string Group name
178     */
179    private function extractGroupNameFromDn(string $dn): string
180    {
181        // Extract CN from DN, e.g., "CN=Domain Users,CN=Users,DC=example,DC=com" -> "Domain Users"
182        $matches = [];
183        if (preg_match('/CN=([^,]+)/i', $dn, $matches)) {
184            return $matches[1];
185        }
186
187        return $dn;
188    }
189
190    /**
191     * @inheritDoc
192     */
193    public function update(string $login, #[SensitiveParameter] string $password): bool
194    {
195        return true;
196    }
197
198    /**
199     * @inheritDoc
200     */
201    public function delete(string $login): bool
202    {
203        return true;
204    }
205
206    /**
207     * @inheritDoc
208     * @throws AuthException|Exception
209     */
210    public function checkCredentials(
211        string $login,
212        #[SensitiveParameter]
213        string $password,
214        ?array $optionalData = null,
215    ): bool {
216        if ('' === trim($password)) {
217            throw new AuthException(User::ERROR_USER_INCORRECT_PASSWORD);
218        }
219
220        // Get active LDAP server for current user
221        if ($this->multipleServers) {
222            $key = array_key_first($this->ldapServer);
223            if ($key !== null) {
224                $this->activeServer = (int) $key;
225                $this->connect($this->activeServer);
226            }
227        }
228
229        $bindLogin = $login;
230        $usesDomainPrefix = (bool) $this->configuration->get(item: 'ldap.ldap_use_domain_prefix');
231        if ($usesDomainPrefix) {
232            if (is_array($optionalData) && array_key_exists('domain', $optionalData)) {
233                $bindLogin = $optionalData['domain'] . '\\' . $login;
234            }
235        }
236
237        if (!$usesDomainPrefix) {
238            $this->connect($this->activeServer);
239            $userDn = $this->ldapCore->getDn($login);
240            $bindLogin = is_string($userDn) && $userDn !== '' ? $userDn : $login;
241        }
242
243        // Check user in LDAP
244        $server = $this->ldapServer[$this->activeServer] ?? [];
245        $this->ldapCore->connect(
246            (string) ($server['ldap_server'] ?? ''),
247            (int) ($server['ldap_port'] ?? 389),
248            (string) ($server['ldap_base'] ?? ''),
249            $bindLogin,
250            htmlspecialchars_decode($password),
251        );
252
253        if (!$this->ldapCore->bind($bindLogin, htmlspecialchars_decode($password))) {
254            throw new AuthException($this->ldapCore->error ?? 'LDAP bind failed.');
255        }
256
257        // Check AD group membership restrictions if enabled
258        $ldapGroupConfig = $this->configuration->getLdapGroupConfig();
259        if ($ldapGroupConfig['use_group_restriction'] === 'true') {
260            $userGroups = $this->ldapCore->getGroupMemberships($login);
261            if ($userGroups === false) {
262                throw new AuthException('Unable to retrieve user group memberships');
263            }
264
265            $allowedGroupsRaw = $ldapGroupConfig['allowed_groups'] ?? [];
266            $allowedGroups = is_array($allowedGroupsRaw) ? $allowedGroupsRaw : [];
267            if ($allowedGroups !== []) {
268                $hasAllowedGroup = false;
269                foreach ($userGroups as $userGroup) {
270                    foreach ($allowedGroups as $allowedGroup) {
271                        if (!str_contains($userGroup, trim((string) $allowedGroup))) {
272                            continue;
273                        }
274
275                        $hasAllowedGroup = true;
276                        break 2;
277                    }
278                }
279
280                if (!$hasAllowedGroup) {
281                    throw new AuthException('User is not a member of any allowed LDAP/Active Directory groups');
282                }
283            }
284        }
285
286        $this->create($login, htmlspecialchars_decode($password));
287
288        return true;
289    }
290
291    /**
292     * @inheritDoc
293     */
294    public function isValidLogin(string $login, ?array $optionalData = null): int
295    {
296        // Get active LDAP server for current user
297        if ($this->multipleServers) {
298            $key = array_key_first($this->ldapServer);
299            if ($key !== null) {
300                $this->activeServer = (int) $key;
301                $this->connect($this->activeServer);
302            }
303        }
304
305        $this->connect($this->activeServer);
306
307        return strlen((string) $this->ldapCore->getCompleteName($login));
308    }
309
310    private function connect(int $activeServer = 0): void
311    {
312        $server = $this->ldapServer[$activeServer] ?? [];
313        $this->ldapCore->connect(
314            (string) ($server['ldap_server'] ?? ''),
315            (int) ($server['ldap_port'] ?? 389),
316            (string) ($server['ldap_base'] ?? ''),
317            (string) ($server['ldap_user'] ?? ''),
318            (string) ($server['ldap_password'] ?? ''),
319        );
320
321        if ($this->ldapCore->error) {
322            $this->configuration->getLogger()->error($this->ldapCore->error);
323            $this->errors[] = $this->ldapCore->error;
324        }
325    }
326
327    private function createUser(): User
328    {
329        if ($this->userFactory instanceof Closure) {
330            $user = ($this->userFactory)();
331            if ($user instanceof User) {
332                return $user;
333            }
334        }
335
336        return new User($this->configuration);
337    }
338
339    private function createMediumPermission(): MediumPermission
340    {
341        if ($this->mediumPermissionFactory instanceof Closure) {
342            $mediumPermission = ($this->mediumPermissionFactory)();
343            if ($mediumPermission instanceof MediumPermission) {
344                return $mediumPermission;
345            }
346        }
347
348        return new MediumPermission($this->configuration);
349    }
350}