Lines 90.18% 147 / 163
Methods 68.42% 13 / 19
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 10 / 10 100.00% 1 / 1 2
 create 100.00% 32 / 32 100.00% 1 / 1 8
 assignUserToGroups 100.00% 17 / 17 100.00% 1 / 1 6
 extractGroupNameFromDn 100.00% 4 / 4 100.00% 1 / 1 2
 update 100.00% 1 / 1 100.00% 1 / 1 1
 delete 100.00% 1 / 1 100.00% 1 / 1 1
 checkCredentials 84.44% 38 / 45 0.00% 0 / 1 20.36
 isValidLogin 42.85% 3 / 7 0.00% 0 / 1 4.68
 connect 100.00% 11 / 11 100.00% 1 / 1 2
 createUser 100.00% 5 / 5 100.00% 1 / 1 3
 createMediumPermission 80.00% 4 / 5 0.00% 0 / 1 3.07
 [phpMyFAQ\Auth] getEncryptionContainer 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Auth] getErrors 100.00% 2 / 2 100.00% 1 / 1 3
 [phpMyFAQ\Auth] addError 0.00% 0 / 1 0.00% 0 / 1 2
 [phpMyFAQ\Auth] selectAuth 80.00% 8 / 10 0.00% 0 / 1 4.13
 [phpMyFAQ\Auth] enableReadOnly 100.00% 3 / 3 100.00% 1 / 1 1
 [phpMyFAQ\Auth] disableReadOnly 100.00% 3 / 3 100.00% 1 / 1 1
 [phpMyFAQ\Auth] isReadOnly 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Auth] encrypt 66.66% 2 / 3 0.00% 0 / 1 2.15
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}

Inherited from phpMyFAQ\Auth

73    public function getEncryptionContainer(string $encType): Encryption
74    {
75        $this->encContainer = Encryption::getInstance($encType, $this->configuration);
76        return $this->encContainer;
77    }
82    public function getErrors(): string
83    {
84        $message = $this->errors !== [] ? implode(separator: PHP_EOL, array: $this->errors) . PHP_EOL : '';
85        return $message . ($this->encContainer instanceof \phpMyFAQ\Encryption ? $this->encContainer->error() : '');
86    }
91    public function addError(string $message): void
92    {
93        $this->errors[] = $message;
94    }
101    public function selectAuth(string $method): Auth&AuthDriverInterface
102    {
103        $method = ucfirst(strtolower($method));
104        $authClass = '\\phpMyFAQ\\Auth\\Auth' . $method;
105
106        if (!class_exists($authClass) || !is_subclass_of($authClass, self::class)) {
107            $this->errors[] = self::PMF_ERROR_USER_NO_AUTH_TYPE;
108            throw new Exception(message: self::PMF_ERROR_USER_NO_AUTH_TYPE);
109        }
110
111        $auth = new $authClass($this->configuration);
112        if (!$auth instanceof AuthDriverInterface) {
113            $this->errors[] = self::PMF_ERROR_USER_NO_AUTH_TYPE;
114            throw new Exception(message: self::PMF_ERROR_USER_NO_AUTH_TYPE);
115        }
116
117        return $auth;
118    }
123    public function enableReadOnly(): bool
124    {
125        $oldReadOnly = $this->readOnly;
126        $this->readOnly = true;
127
128        return $oldReadOnly;
129    }
134    public function disableReadOnly(): bool
135    {
136        $oldReadOnly = $this->readOnly;
137        $this->readOnly = false;
138
139        return $oldReadOnly;
140    }
145    public function isReadOnly(): bool
146    {
147        return $this->readOnly;
148    }
155    public function encrypt(#[SensitiveParameter] string $string): string
156    {
157        if (!$this->encContainer instanceof \phpMyFAQ\Encryption) {
158            throw new Exception(message: 'No encryption container configured. Call getEncryptionContainer() first.');
159        }
160
161        return $this->encContainer->encrypt($string);
162    }