]> BookStack Code Mirror - bookstack/blob - app/Access/LdapService.php
Adapt tests with displayName array
[bookstack] / app / Access / LdapService.php
1 <?php
2
3 namespace BookStack\Access;
4
5 use BookStack\Exceptions\JsonDebugException;
6 use BookStack\Exceptions\LdapException;
7 use BookStack\Uploads\UserAvatars;
8 use BookStack\Users\Models\User;
9 use ErrorException;
10 use Illuminate\Support\Facades\Log;
11
12 /**
13  * Class LdapService
14  * Handles any app-specific LDAP tasks.
15  */
16 class LdapService
17 {
18     /**
19      * @var resource|\LDAP\Connection
20      */
21     protected $ldapConnection;
22
23     protected array $config;
24     protected bool $enabled;
25
26     public function __construct(
27         protected Ldap $ldap,
28         protected UserAvatars $userAvatars,
29         protected GroupSyncService $groupSyncService
30     ) {
31         $this->config = config('services.ldap');
32         $this->enabled = config('auth.method') === 'ldap';
33     }
34
35     /**
36      * Check if groups should be synced.
37      */
38     public function shouldSyncGroups(): bool
39     {
40         return $this->enabled && $this->config['user_to_groups'] !== false;
41     }
42
43     /**
44      * Search for attributes for a specific user on the ldap.
45      *
46      * @throws LdapException
47      */
48     private function getUserWithAttributes(string $userName, array $attributes): ?array
49     {
50         $ldapConnection = $this->getConnection();
51         $this->bindSystemUser($ldapConnection);
52
53         // Clean attributes
54         foreach ($attributes as $index => $attribute) {
55             if (str_starts_with($attribute, 'BIN;')) {
56                 $attributes[$index] = substr($attribute, strlen('BIN;'));
57             }
58         }
59
60         // Find user
61         $userFilter = $this->buildFilter($this->config['user_filter'], ['user' => $userName]);
62         $baseDn = $this->config['base_dn'];
63
64         $followReferrals = $this->config['follow_referrals'] ? 1 : 0;
65         $this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
66         $users = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $userFilter, $attributes);
67         if ($users['count'] === 0) {
68             return null;
69         }
70
71         return $users[0];
72     }
73
74     /**
75      * Calculate the display name.
76      */
77     protected function getUserDisplayName(array $displayNameAttr, array $userDetails, string $defaultValue): string
78     {
79         $displayName = [];
80         foreach ($displayNameAttr as $dnAttr) {
81             $dnComponent = $this->getUserResponseProperty($userDetails, $dnAttr, null);
82             if ($dnComponent !== null) {
83                 $displayName[] = $dnComponent;
84             }
85         }
86
87         if (count($displayName) == 0) {
88             $displayName = $defaultValue;
89         } else {
90             $displayName = implode(' ', $displayName);
91         }
92
93         return $displayName;
94     }
95
96     /**
97      * Get the details of a user from LDAP using the given username.
98      * User found via configurable user filter.
99      *
100      * @throws LdapException|JsonDebugException
101      */
102     public function getUserDetails(string $userName): ?array
103     {
104         $idAttr = $this->config['id_attribute'];
105         $emailAttr = $this->config['email_attribute'];
106         $displayNameAttr = $this->config['display_name_attribute'];
107         $thumbnailAttr = $this->config['thumbnail_attribute'];
108
109         $user = $this->getUserWithAttributes($userName, array_filter(array_merge($displayNameAttr, [
110             'cn', 'dn', $idAttr, $emailAttr, $thumbnailAttr,
111         ])));
112
113         if (is_null($user)) {
114             return null;
115         }
116
117         $userCn = $this->getUserResponseProperty($user, 'cn', null);
118         $formatted = [
119             'uid'   => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
120             'name'  => $this->getUserDisplayName($displayNameAttr, $user, $userCn),
121             'dn'    => $user['dn'],
122             'email' => $this->getUserResponseProperty($user, $emailAttr, null),
123             'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
124         ];
125
126         if ($this->config['dump_user_details']) {
127             throw new JsonDebugException([
128                 'details_from_ldap'        => $user,
129                 'details_bookstack_parsed' => $formatted,
130             ]);
131         }
132
133         return $formatted;
134     }
135
136     /**
137      * Get a property from an LDAP user response fetch.
138      * Handles properties potentially being part of an array.
139      * If the given key is prefixed with 'BIN;', that indicator will be stripped
140      * from the key and any fetched values will be converted from binary to hex.
141      */
142     protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue)
143     {
144         $isBinary = str_starts_with($propertyKey, 'BIN;');
145         $propertyKey = strtolower($propertyKey);
146         $value = $defaultValue;
147
148         if ($isBinary) {
149             $propertyKey = substr($propertyKey, strlen('BIN;'));
150         }
151
152         if (isset($userDetails[$propertyKey])) {
153             $value = (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]);
154             if ($isBinary) {
155                 $value = bin2hex($value);
156             }
157         }
158
159         return $value;
160     }
161
162     /**
163      * Check if the given credentials are valid for the given user.
164      *
165      * @throws LdapException
166      */
167     public function validateUserCredentials(?array $ldapUserDetails, string $password): bool
168     {
169         if (is_null($ldapUserDetails)) {
170             return false;
171         }
172
173         $ldapConnection = $this->getConnection();
174
175         try {
176             $ldapBind = $this->ldap->bind($ldapConnection, $ldapUserDetails['dn'], $password);
177         } catch (ErrorException $e) {
178             $ldapBind = false;
179         }
180
181         return $ldapBind;
182     }
183
184     /**
185      * Bind the system user to the LDAP connection using the given credentials
186      * otherwise anonymous access is attempted.
187      *
188      * @param resource|\LDAP\Connection $connection
189      *
190      * @throws LdapException
191      */
192     protected function bindSystemUser($connection): void
193     {
194         $ldapDn = $this->config['dn'];
195         $ldapPass = $this->config['pass'];
196
197         $isAnonymous = ($ldapDn === false || $ldapPass === false);
198         if ($isAnonymous) {
199             $ldapBind = $this->ldap->bind($connection);
200         } else {
201             $ldapBind = $this->ldap->bind($connection, $ldapDn, $ldapPass);
202         }
203
204         if (!$ldapBind) {
205             throw new LdapException(($isAnonymous ? trans('errors.ldap_fail_anonymous') : trans('errors.ldap_fail_authed')));
206         }
207     }
208
209     /**
210      * Get the connection to the LDAP server.
211      * Creates a new connection if one does not exist.
212      *
213      * @throws LdapException
214      *
215      * @return resource|\LDAP\Connection
216      */
217     protected function getConnection()
218     {
219         if ($this->ldapConnection !== null) {
220             return $this->ldapConnection;
221         }
222
223         // Check LDAP extension in installed
224         if (!function_exists('ldap_connect') && config('app.env') !== 'testing') {
225             throw new LdapException(trans('errors.ldap_extension_not_installed'));
226         }
227
228         // Disable certificate verification.
229         // This option works globally and must be set before a connection is created.
230         if ($this->config['tls_insecure']) {
231             $this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
232         }
233
234         // Configure any user-provided CA cert files for LDAP.
235         // This option works globally and must be set before a connection is created.
236         if ($this->config['tls_ca_cert']) {
237             $this->configureTlsCaCerts($this->config['tls_ca_cert']);
238         }
239
240         $ldapHost = $this->parseServerString($this->config['server']);
241         $ldapConnection = $this->ldap->connect($ldapHost);
242
243         if ($ldapConnection === false) {
244             throw new LdapException(trans('errors.ldap_cannot_connect'));
245         }
246
247         // Set any required options
248         if ($this->config['version']) {
249             $this->ldap->setVersion($ldapConnection, $this->config['version']);
250         }
251
252         // Start and verify TLS if it's enabled
253         if ($this->config['start_tls']) {
254             try {
255                 $started = $this->ldap->startTls($ldapConnection);
256             } catch (\Exception $exception) {
257                 $error = $exception->getMessage() . ' :: ' . ldap_error($ldapConnection);
258                 ldap_get_option($ldapConnection, LDAP_OPT_DIAGNOSTIC_MESSAGE, $detail);
259                 Log::info("LDAP STARTTLS failure: {$error} {$detail}");
260                 throw new LdapException('Could not start TLS connection. Further details in the application log.');
261             }
262             if (!$started) {
263                 throw new LdapException('Could not start TLS connection');
264             }
265         }
266
267         $this->ldapConnection = $ldapConnection;
268
269         return $this->ldapConnection;
270     }
271
272     /**
273      * Configure TLS CA certs globally for ldap use.
274      * This will detect if the given path is a directory or file, and set the relevant
275      * LDAP TLS options appropriately otherwise throw an exception if no file/folder found.
276      *
277      * Note: When using a folder, certificates are expected to be correctly named by hash
278      * which can be done via the c_rehash utility.
279      *
280      * @throws LdapException
281      */
282     protected function configureTlsCaCerts(string $caCertPath): void
283     {
284         $errMessage = "Provided path [{$caCertPath}] for LDAP TLS CA certs could not be resolved to an existing location";
285         $path = realpath($caCertPath);
286         if ($path === false) {
287             throw new LdapException($errMessage);
288         }
289
290         if (is_dir($path)) {
291             $this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTDIR, $path);
292         } else if (is_file($path)) {
293             $this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTFILE, $path);
294         } else {
295             throw new LdapException($errMessage);
296         }
297     }
298
299     /**
300      * Parse an LDAP server string and return the host suitable for a connection.
301      * Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
302      */
303     protected function parseServerString(string $serverString): string
304     {
305         if (str_starts_with($serverString, 'ldaps://') || str_starts_with($serverString, 'ldap://')) {
306             return $serverString;
307         }
308
309         return "ldap://{$serverString}";
310     }
311
312     /**
313      * Build a filter string by injecting common variables.
314      * Both "${var}" and "{var}" style placeholders are supported.
315      * Dollar based are old format but supported for compatibility.
316      */
317     protected function buildFilter(string $filterString, array $attrs): string
318     {
319         $newAttrs = [];
320         foreach ($attrs as $key => $attrText) {
321             $escapedText = $this->ldap->escape($attrText);
322             $oldVarKey = '${' . $key . '}';
323             $newVarKey = '{' . $key . '}';
324             $newAttrs[$oldVarKey] = $escapedText;
325             $newAttrs[$newVarKey] = $escapedText;
326         }
327
328         return strtr($filterString, $newAttrs);
329     }
330
331     /**
332      * Get the groups a user is a part of on ldap.
333      *
334      * @throws LdapException
335      * @throws JsonDebugException
336      */
337     public function getUserGroups(string $userName): array
338     {
339         $groupsAttr = $this->config['group_attribute'];
340         $user = $this->getUserWithAttributes($userName, [$groupsAttr]);
341
342         if ($user === null) {
343             return [];
344         }
345
346         $userGroups = $this->extractGroupsFromSearchResponseEntry($user);
347         $allGroups = $this->getGroupsRecursive($userGroups, []);
348         $formattedGroups = $this->extractGroupNamesFromLdapGroupDns($allGroups);
349
350         if ($this->config['dump_user_groups']) {
351             throw new JsonDebugException([
352                 'details_from_ldap'            => $user,
353                 'parsed_direct_user_groups'    => $userGroups,
354                 'parsed_recursive_user_groups' => $allGroups,
355                 'parsed_resulting_group_names' => $formattedGroups,
356             ]);
357         }
358
359         return $formattedGroups;
360     }
361
362     protected function extractGroupNamesFromLdapGroupDns(array $groupDNs): array
363     {
364         $names = [];
365
366         foreach ($groupDNs as $groupDN) {
367             $exploded = $this->ldap->explodeDn($groupDN, 1);
368             if ($exploded !== false && count($exploded) > 0) {
369                 $names[] = $exploded[0];
370             }
371         }
372
373         return array_unique($names);
374     }
375
376     /**
377      * Build an array of all relevant groups DNs after recursively scanning
378      * across parents of the groups given.
379      *
380      * @throws LdapException
381      */
382     protected function getGroupsRecursive(array $groupDNs, array $checked): array
383     {
384         $groupsToAdd = [];
385         foreach ($groupDNs as $groupDN) {
386             if (in_array($groupDN, $checked)) {
387                 continue;
388             }
389
390             $parentGroups = $this->getParentsOfGroup($groupDN);
391             $groupsToAdd = array_merge($groupsToAdd, $parentGroups);
392             $checked[] = $groupDN;
393         }
394
395         $uniqueDNs = array_unique(array_merge($groupDNs, $groupsToAdd), SORT_REGULAR);
396
397         if (empty($groupsToAdd)) {
398             return $uniqueDNs;
399         }
400
401         return $this->getGroupsRecursive($uniqueDNs, $checked);
402     }
403
404     /**
405      * @throws LdapException
406      */
407     protected function getParentsOfGroup(string $groupDN): array
408     {
409         $groupsAttr = strtolower($this->config['group_attribute']);
410         $ldapConnection = $this->getConnection();
411         $this->bindSystemUser($ldapConnection);
412
413         $followReferrals = $this->config['follow_referrals'] ? 1 : 0;
414         $this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
415         $read = $this->ldap->read($ldapConnection, $groupDN, '(objectClass=*)', [$groupsAttr]);
416         $results = $this->ldap->getEntries($ldapConnection, $read);
417         if ($results['count'] === 0) {
418             return [];
419         }
420
421         return $this->extractGroupsFromSearchResponseEntry($results[0]);
422     }
423
424     /**
425      * Extract an array of group DN values from the given LDAP search response entry
426      */
427     protected function extractGroupsFromSearchResponseEntry(array $ldapEntry): array
428     {
429         $groupsAttr = strtolower($this->config['group_attribute']);
430         $groupDNs = [];
431         $count = 0;
432
433         if (isset($ldapEntry[$groupsAttr]['count'])) {
434             $count = (int) $ldapEntry[$groupsAttr]['count'];
435         }
436
437         for ($i = 0; $i < $count; $i++) {
438             $dn = $ldapEntry[$groupsAttr][$i];
439             if (!in_array($dn, $groupDNs)) {
440                 $groupDNs[] = $dn;
441             }
442         }
443
444         return $groupDNs;
445     }
446
447     /**
448      * Sync the LDAP groups to the user roles for the current user.
449      *
450      * @throws LdapException
451      * @throws JsonDebugException
452      */
453     public function syncGroups(User $user, string $username): void
454     {
455         $userLdapGroups = $this->getUserGroups($username);
456         $this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']);
457     }
458
459     /**
460      * Save and attach an avatar image, if found in the ldap details, and attach
461      * to the given user model.
462      */
463     public function saveAndAttachAvatar(User $user, array $ldapUserDetails): void
464     {
465         if (is_null(config('services.ldap.thumbnail_attribute')) || is_null($ldapUserDetails['avatar'])) {
466             return;
467         }
468
469         try {
470             $imageData = $ldapUserDetails['avatar'];
471             $this->userAvatars->assignToUserFromExistingData($user, $imageData, 'jpg');
472         } catch (\Exception $exception) {
473             Log::info("Failed to use avatar image from LDAP data for user id {$user->id}");
474         }
475     }
476 }