]> BookStack Code Mirror - bookstack/blob - app/Auth/Access/LdapService.php
Combined LDAP connect and bind for failover handling
[bookstack] / app / Auth / Access / LdapService.php
1 <?php
2
3 namespace BookStack\Auth\Access;
4
5 use BookStack\Auth\User;
6 use BookStack\Exceptions\JsonDebugException;
7 use BookStack\Exceptions\LdapException;
8 use BookStack\Exceptions\LdapFailedBindException;
9 use BookStack\Uploads\UserAvatars;
10 use ErrorException;
11 use Illuminate\Support\Facades\Log;
12
13 /**
14  * Class LdapService
15  * Handles any app-specific LDAP tasks.
16  */
17 class LdapService
18 {
19     protected Ldap $ldap;
20     protected GroupSyncService $groupSyncService;
21     protected UserAvatars $userAvatars;
22
23     protected array $config;
24     protected bool $enabled;
25
26     /**
27      * LdapService constructor.
28      */
29     public function __construct(Ldap $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService)
30     {
31         $this->ldap = $ldap;
32         $this->userAvatars = $userAvatars;
33         $this->groupSyncService = $groupSyncService;
34         $this->config = config('services.ldap');
35         $this->enabled = config('auth.method') === 'ldap';
36     }
37
38     /**
39      * Check if groups should be synced.
40      */
41     public function shouldSyncGroups(): bool
42     {
43         return $this->enabled && $this->config['user_to_groups'] !== false;
44     }
45
46     /**
47      * Search for attributes for a specific user on the ldap.
48      *
49      * @throws LdapException
50      */
51     protected function getUserWithAttributes(string $userName, array $attributes): ?array
52     {
53         $ldapConnection = $this->bindConnection();
54
55         // Clean attributes
56         foreach ($attributes as $index => $attribute) {
57             if (strpos($attribute, 'BIN;') === 0) {
58                 $attributes[$index] = substr($attribute, strlen('BIN;'));
59             }
60         }
61
62         // Find user
63         $userFilter = $this->buildFilter($this->config['user_filter'], ['user' => $userName]);
64         $baseDn = $this->config['base_dn'];
65
66         $followReferrals = $this->config['follow_referrals'] ? 1 : 0;
67         $this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
68         $users = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $userFilter, $attributes);
69         if ($users['count'] === 0) {
70             return null;
71         }
72
73         return $users[0];
74     }
75
76     /**
77      * Get the details of a user from LDAP using the given username.
78      * User found via configurable user filter.
79      *
80      * @throws LdapException
81      */
82     public function getUserDetails(string $userName): ?array
83     {
84         $idAttr = $this->config['id_attribute'];
85         $emailAttr = $this->config['email_attribute'];
86         $displayNameAttr = $this->config['display_name_attribute'];
87         $thumbnailAttr = $this->config['thumbnail_attribute'];
88
89         $user = $this->getUserWithAttributes($userName, array_filter([
90             'cn', 'dn', $idAttr, $emailAttr, $displayNameAttr, $thumbnailAttr,
91         ]));
92
93         if (is_null($user)) {
94             return null;
95         }
96
97         $userCn = $this->getUserResponseProperty($user, 'cn', null);
98         $formatted = [
99             'uid'   => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
100             'name'  => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
101             'dn'    => $user['dn'],
102             'email' => $this->getUserResponseProperty($user, $emailAttr, null),
103             'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
104         ];
105
106         if ($this->config['dump_user_details']) {
107             throw new JsonDebugException([
108                 'details_from_ldap'        => $user,
109                 'details_bookstack_parsed' => $formatted,
110             ]);
111         }
112
113         return $formatted;
114     }
115
116     /**
117      * Get a property from an LDAP user response fetch.
118      * Handles properties potentially being part of an array.
119      * If the given key is prefixed with 'BIN;', that indicator will be stripped
120      * from the key and any fetched values will be converted from binary to hex.
121      */
122     protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue)
123     {
124         $isBinary = strpos($propertyKey, 'BIN;') === 0;
125         $propertyKey = strtolower($propertyKey);
126         $value = $defaultValue;
127
128         if ($isBinary) {
129             $propertyKey = substr($propertyKey, strlen('BIN;'));
130         }
131
132         if (isset($userDetails[$propertyKey])) {
133             $value = (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]);
134             if ($isBinary) {
135                 $value = bin2hex($value);
136             }
137         }
138
139         return $value;
140     }
141
142     /**
143      * Check if the given credentials are valid for the given user.
144      *
145      * @throws LdapException
146      */
147     public function validateUserCredentials(?array $ldapUserDetails, string $password): bool
148     {
149         if (is_null($ldapUserDetails)) {
150             return false;
151         }
152
153         try {
154             $this->bindConnection($ldapUserDetails['dn'], $password);
155         } catch (LdapFailedBindException $e) {
156             return false;
157         } catch (LdapException $e) {
158             throw $e;
159         }
160
161         return true;
162     }
163
164
165     /**
166      * Attempted to start and bind to a new LDAP connection.
167      * Will attempt against multiple defined fail-over hosts if set.
168      *
169      * Throws a LdapFailedBindException error if the bind connected but failed.
170      * Otherwise, generic LdapException errors would be thrown.
171      *
172      * @return resource
173      * @throws LdapException
174      */
175     protected function bindConnection(string $dn = null, string $password = null)
176     {
177         $systemBind = ($dn === null && $password === null);
178
179         // Check LDAP extension in installed
180         if (!function_exists('ldap_connect') && config('app.env') !== 'testing') {
181             throw new LdapException(trans('errors.ldap_extension_not_installed'));
182         }
183
184         // Disable certificate verification.
185         // This option works globally and must be set before a connection is created.
186         if ($this->config['tls_insecure']) {
187             $this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
188         }
189
190         $serverDetails = $this->parseMultiServerString($this->config['server']);
191         $lastException = null;
192
193         foreach ($serverDetails as $server) {
194             try {
195                 $connection = $this->startServerConnection($server);
196             } catch (LdapException $exception) {
197                 $lastException = $exception;
198                 continue;
199             }
200
201             try {
202                 if ($systemBind) {
203                     $this->bindSystemUser($connection);
204                 } else {
205                     $this->bindGivenUser($connection, $dn, $password);
206                 }
207             } catch (LdapFailedBindException $exception) {
208                 // Rethrow simply to indicate the importance of handling this exception case
209                 // to indicate auth status. We skip past attempting fail-over hosts in this case since it's
210                 // likely the connection worked here but the bind was unauthorised.
211                 throw $exception;
212             } catch (ErrorException $exception) {
213                 Log::error('LDAP bind error: ' . $exception->getMessage());
214                 $lastException = new LdapException('Encountered error during LDAP bind');
215                 continue;
216             }
217
218             return $connection;
219         }
220
221         throw $lastException;
222     }
223
224     /**
225      * Bind to the given LDAP connection using the given credentials.
226      * MUST throw an exception on failure.
227      *
228      * @param resource $connection
229      *
230      * @throws LdapFailedBindException
231      */
232     protected function bindGivenUser($connection, string $dn = null, string $password = null): void
233     {
234         $ldapBind = $this->ldap->bind($connection, $dn, $password);
235
236         if (!$ldapBind) {
237             throw new LdapFailedBindException('Failed to bind with given user details');
238         }
239     }
240
241     /**
242      * Bind the system user to the LDAP connection using the configured credentials  otherwise anonymous
243      * access is attempted. MUST throw an exception on failure.
244      *
245      * @param resource $connection
246      *
247      * @throws LdapFailedBindException
248      */
249     protected function bindSystemUser($connection): void
250     {
251         $ldapDn = $this->config['dn'];
252         $ldapPass = $this->config['pass'];
253
254         $isAnonymous = ($ldapDn === false || $ldapPass === false);
255         if ($isAnonymous) {
256             $ldapBind = $this->ldap->bind($connection);
257         } else {
258             $ldapBind = $this->ldap->bind($connection, $ldapDn, $ldapPass);
259         }
260
261         if (!$ldapBind) {
262             throw new LdapFailedBindException(($isAnonymous ? trans('errors.ldap_fail_anonymous') : trans('errors.ldap_fail_authed')));
263         }
264     }
265
266     /**
267      * Attempt to start a server connection from the provided details.
268      *
269      * @param array{host: string, port: int} $serverDetail
270      * @return resource
271      * @throws LdapException
272      */
273     protected function startServerConnection(array $serverDetail)
274     {
275         $ldapConnection = $this->ldap->connect($serverDetail['host'], $serverDetail['port']);
276
277         if (!$ldapConnection) {
278             throw new LdapException(trans('errors.ldap_cannot_connect'));
279         }
280
281         // Set any required options
282         if ($this->config['version']) {
283             $this->ldap->setVersion($ldapConnection, $this->config['version']);
284         }
285
286         // Start and verify TLS if it's enabled
287         if ($this->config['start_tls']) {
288             try {
289                 $tlsStarted = $this->ldap->startTls($ldapConnection);
290             } catch (ErrorException $exception) {
291                 $tlsStarted = false;
292             }
293
294             if (!$tlsStarted) {
295                 throw new LdapException('Could not start TLS connection');
296             }
297         }
298
299         return $ldapConnection;
300     }
301
302     /**
303      * Parse a potentially multi-value LDAP server host string and return an array of host/port detail pairs.
304      * Multiple hosts are separated with a semicolon, for example: 'ldap.example.com:8069;ldaps://ldap.example.com'
305      *
306      * @return array<array{host: string, port: int}>
307      */
308     protected function parseMultiServerString(string $serversString): array
309     {
310         $serverStringList = explode(';', $serversString);
311
312         return array_map(fn ($serverStr) => $this->parseSingleServerString($serverStr), $serverStringList);
313     }
314
315     /**
316      * Parse an LDAP server string and return the host and port for a connection.
317      * Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
318      *
319      * @return array{host: string, port: int}
320      */
321     protected function parseSingleServerString(string $serverString): array
322     {
323         $serverNameParts = explode(':', $serverString);
324
325         // If we have a protocol just return the full string since PHP will ignore a separate port.
326         if ($serverNameParts[0] === 'ldaps' || $serverNameParts[0] === 'ldap') {
327             return ['host' => $serverString, 'port' => 389];
328         }
329
330         // Otherwise, extract the port out
331         $hostName = $serverNameParts[0];
332         $ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389;
333
334         return ['host' => $hostName, 'port' => $ldapPort];
335     }
336
337     /**
338      * Build a filter string by injecting common variables.
339      */
340     protected function buildFilter(string $filterString, array $attrs): string
341     {
342         $newAttrs = [];
343         foreach ($attrs as $key => $attrText) {
344             $newKey = '${' . $key . '}';
345             $newAttrs[$newKey] = $this->ldap->escape($attrText);
346         }
347
348         return strtr($filterString, $newAttrs);
349     }
350
351     /**
352      * Get the groups a user is a part of on ldap.
353      *
354      * @throws LdapException
355      * @throws JsonDebugException
356      */
357     public function getUserGroups(string $userName): array
358     {
359         $groupsAttr = $this->config['group_attribute'];
360         $user = $this->getUserWithAttributes($userName, [$groupsAttr]);
361
362         if ($user === null) {
363             return [];
364         }
365
366         $userGroups = $this->groupFilter($user);
367         $allGroups = $this->getGroupsRecursive($userGroups, []);
368
369         if ($this->config['dump_user_groups']) {
370             throw new JsonDebugException([
371                 'details_from_ldap'             => $user,
372                 'parsed_direct_user_groups'     => $userGroups,
373                 'parsed_recursive_user_groups'  => $allGroups,
374             ]);
375         }
376
377         return $allGroups;
378     }
379
380     /**
381      * Get the parent groups of an array of groups.
382      *
383      * @throws LdapException
384      */
385     private function getGroupsRecursive(array $groupsArray, array $checked): array
386     {
387         $groupsToAdd = [];
388         foreach ($groupsArray as $groupName) {
389             if (in_array($groupName, $checked)) {
390                 continue;
391             }
392
393             $parentGroups = $this->getGroupGroups($groupName);
394             $groupsToAdd = array_merge($groupsToAdd, $parentGroups);
395             $checked[] = $groupName;
396         }
397
398         $groupsArray = array_unique(array_merge($groupsArray, $groupsToAdd), SORT_REGULAR);
399
400         if (empty($groupsToAdd)) {
401             return $groupsArray;
402         }
403
404         return $this->getGroupsRecursive($groupsArray, $checked);
405     }
406
407     /**
408      * Get the parent groups of a single group.
409      *
410      * @throws LdapException
411      */
412     private function getGroupGroups(string $groupName): array
413     {
414         $ldapConnection = $this->bindConnection();
415
416         $followReferrals = $this->config['follow_referrals'] ? 1 : 0;
417         $this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
418
419         $baseDn = $this->config['base_dn'];
420         $groupsAttr = strtolower($this->config['group_attribute']);
421
422         $groupFilter = 'CN=' . $this->ldap->escape($groupName);
423         $groups = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $groupFilter, [$groupsAttr]);
424         if ($groups['count'] === 0) {
425             return [];
426         }
427
428         return $this->groupFilter($groups[0]);
429     }
430
431     /**
432      * Filter out LDAP CN and DN language in a ldap search return.
433      * Gets the base CN (common name) of the string.
434      */
435     protected function groupFilter(array $userGroupSearchResponse): array
436     {
437         $groupsAttr = strtolower($this->config['group_attribute']);
438         $ldapGroups = [];
439         $count = 0;
440
441         if (isset($userGroupSearchResponse[$groupsAttr]['count'])) {
442             $count = (int) $userGroupSearchResponse[$groupsAttr]['count'];
443         }
444
445         for ($i = 0; $i < $count; $i++) {
446             $dnComponents = $this->ldap->explodeDn($userGroupSearchResponse[$groupsAttr][$i], 1);
447             if (!in_array($dnComponents[0], $ldapGroups)) {
448                 $ldapGroups[] = $dnComponents[0];
449             }
450         }
451
452         return $ldapGroups;
453     }
454
455     /**
456      * Sync the LDAP groups to the user roles for the current user.
457      *
458      * @throws LdapException
459      * @throws JsonDebugException
460      */
461     public function syncGroups(User $user, string $username)
462     {
463         $userLdapGroups = $this->getUserGroups($username);
464         $this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']);
465     }
466
467     /**
468      * Save and attach an avatar image, if found in the ldap details, and attach
469      * to the given user model.
470      */
471     public function saveAndAttachAvatar(User $user, array $ldapUserDetails): void
472     {
473         if (is_null(config('services.ldap.thumbnail_attribute')) || is_null($ldapUserDetails['avatar'])) {
474             return;
475         }
476
477         try {
478             $imageData = $ldapUserDetails['avatar'];
479             $this->userAvatars->assignToUserFromExistingData($user, $imageData, 'jpg');
480         } catch (\Exception $exception) {
481             Log::info("Failed to use avatar image from LDAP data for user id {$user->id}");
482         }
483     }
484 }