]> BookStack Code Mirror - bookstack/blob - app/Auth/Access/Ldap/LdapService.php
502e6099a5e124c8f409ac6c1996fbf54392ee20
[bookstack] / app / Auth / Access / Ldap / LdapService.php
1 <?php
2
3 namespace BookStack\Auth\Access\Ldap;
4
5 use BookStack\Auth\Access\GroupSyncService;
6 use BookStack\Auth\User;
7 use BookStack\Exceptions\JsonDebugException;
8 use BookStack\Exceptions\LdapException;
9 use BookStack\Exceptions\LdapFailedBindException;
10 use BookStack\Uploads\UserAvatars;
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 LdapConnectionManager $ldap;
20     protected GroupSyncService $groupSyncService;
21     protected UserAvatars $userAvatars;
22
23     protected array $config;
24
25     public function __construct(LdapConnectionManager $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService)
26     {
27         $this->ldap = $ldap;
28         $this->userAvatars = $userAvatars;
29         $this->groupSyncService = $groupSyncService;
30         $this->config = config('services.ldap');
31     }
32
33     /**
34      * Search for attributes for a specific user on the ldap.
35      *
36      * @throws LdapException
37      */
38     protected function getUserWithAttributes(string $userName, array $attributes): ?array
39     {
40         $connection = $this->ldap->startSystemBind($this->config);
41
42         // Clean attributes
43         foreach ($attributes as $index => $attribute) {
44             if (strpos($attribute, 'BIN;') === 0) {
45                 $attributes[$index] = substr($attribute, strlen('BIN;'));
46             }
47         }
48
49         // Find user
50         $userFilter = $this->buildFilter($this->config['user_filter'], ['user' => $userName]);
51         $baseDn = $this->config['base_dn'];
52
53         $followReferrals = $this->config['follow_referrals'] ? 1 : 0;
54         $connection->setOption(LDAP_OPT_REFERRALS, $followReferrals);
55         $users = $connection->searchAndGetEntries($baseDn, $userFilter, $attributes);
56         if ($users['count'] === 0) {
57             return null;
58         }
59
60         return $users[0];
61     }
62
63     /**
64      * Get the details of a user from LDAP using the given username.
65      * User found via configurable user filter.
66      *
67      * @throws LdapException
68      */
69     public function getUserDetails(string $userName): ?array
70     {
71         $idAttr = $this->config['id_attribute'];
72         $emailAttr = $this->config['email_attribute'];
73         $displayNameAttr = $this->config['display_name_attribute'];
74         $thumbnailAttr = $this->config['thumbnail_attribute'];
75
76         $user = $this->getUserWithAttributes($userName, array_filter([
77             'cn', 'dn', $idAttr, $emailAttr, $displayNameAttr, $thumbnailAttr,
78         ]));
79
80         if (is_null($user)) {
81             return null;
82         }
83
84         $userCn = $this->getUserResponseProperty($user, 'cn', null);
85         $formatted = [
86             'uid'   => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
87             'name'  => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
88             'dn'    => $user['dn'],
89             'email' => $this->getUserResponseProperty($user, $emailAttr, null),
90             'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
91         ];
92
93         if ($this->config['dump_user_details']) {
94             throw new JsonDebugException([
95                 'details_from_ldap'        => $user,
96                 'details_bookstack_parsed' => $formatted,
97             ]);
98         }
99
100         return $formatted;
101     }
102
103     /**
104      * Get a property from an LDAP user response fetch.
105      * Handles properties potentially being part of an array.
106      * If the given key is prefixed with 'BIN;', that indicator will be stripped
107      * from the key and any fetched values will be converted from binary to hex.
108      */
109     protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue)
110     {
111         $isBinary = strpos($propertyKey, 'BIN;') === 0;
112         $propertyKey = strtolower($propertyKey);
113         $value = $defaultValue;
114
115         if ($isBinary) {
116             $propertyKey = substr($propertyKey, strlen('BIN;'));
117         }
118
119         if (isset($userDetails[$propertyKey])) {
120             $value = (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]);
121             if ($isBinary) {
122                 $value = bin2hex($value);
123             }
124         }
125
126         return $value;
127     }
128
129     /**
130      * Check if the given credentials are valid for the given user.
131      *
132      * @throws LdapException
133      */
134     public function validateUserCredentials(?array $ldapUserDetails, string $password): bool
135     {
136         if (is_null($ldapUserDetails)) {
137             return false;
138         }
139
140         try {
141             $this->ldap->startBind($ldapUserDetails['dn'], $password, $this->config);
142         } catch (LdapFailedBindException $e) {
143             return false;
144         } catch (LdapException $e) {
145             throw $e;
146         }
147
148         return true;
149     }
150
151     /**
152      * Build a filter string by injecting common variables.
153      */
154     protected function buildFilter(string $filterString, array $attrs): string
155     {
156         $newAttrs = [];
157         foreach ($attrs as $key => $attrText) {
158             $newKey = '${' . $key . '}';
159             $newAttrs[$newKey] = LdapConnection::escape($attrText);
160         }
161
162         return strtr($filterString, $newAttrs);
163     }
164
165     /**
166      * Get the groups a user is a part of on ldap.
167      *
168      * @throws LdapException
169      * @throws JsonDebugException
170      */
171     public function getUserGroups(string $userName): array
172     {
173         $groupsAttr = $this->config['group_attribute'];
174         $user = $this->getUserWithAttributes($userName, [$groupsAttr]);
175
176         if ($user === null) {
177             return [];
178         }
179
180         $userGroups = $this->groupFilter($user);
181         $allGroups = $this->getGroupsRecursive($userGroups, []);
182
183         if ($this->config['dump_user_groups']) {
184             throw new JsonDebugException([
185                 'details_from_ldap'             => $user,
186                 'parsed_direct_user_groups'     => $userGroups,
187                 'parsed_recursive_user_groups'  => $allGroups,
188             ]);
189         }
190
191         return $allGroups;
192     }
193
194     /**
195      * Get the parent groups of an array of groups.
196      *
197      * @throws LdapException
198      */
199     private function getGroupsRecursive(array $groupsArray, array $checked): array
200     {
201         $groupsToAdd = [];
202         foreach ($groupsArray as $groupName) {
203             if (in_array($groupName, $checked)) {
204                 continue;
205             }
206
207             $parentGroups = $this->getGroupGroups($groupName);
208             $groupsToAdd = array_merge($groupsToAdd, $parentGroups);
209             $checked[] = $groupName;
210         }
211
212         $groupsArray = array_unique(array_merge($groupsArray, $groupsToAdd), SORT_REGULAR);
213
214         if (empty($groupsToAdd)) {
215             return $groupsArray;
216         }
217
218         return $this->getGroupsRecursive($groupsArray, $checked);
219     }
220
221     /**
222      * Get the parent groups of a single group.
223      *
224      * @throws LdapException
225      */
226     private function getGroupGroups(string $groupName): array
227     {
228         $connection = $this->ldap->startSystemBind($this->config);
229
230         $followReferrals = $this->config['follow_referrals'] ? 1 : 0;
231         $connection->setOption(LDAP_OPT_REFERRALS, $followReferrals);
232
233         $baseDn = $this->config['base_dn'];
234         $groupsAttr = strtolower($this->config['group_attribute']);
235
236         $groupFilter = 'CN=' . $connection->escape($groupName);
237         $groups = $connection->searchAndGetEntries($baseDn, $groupFilter, [$groupsAttr]);
238         if ($groups['count'] === 0) {
239             return [];
240         }
241
242         return $this->groupFilter($groups[0]);
243     }
244
245     /**
246      * Filter out LDAP CN and DN language in a ldap search return.
247      * Gets the base CN (common name) of the string.
248      */
249     protected function groupFilter(array $userGroupSearchResponse): array
250     {
251         $groupsAttr = strtolower($this->config['group_attribute']);
252         $ldapGroups = [];
253         $count = 0;
254
255         if (isset($userGroupSearchResponse[$groupsAttr]['count'])) {
256             $count = (int) $userGroupSearchResponse[$groupsAttr]['count'];
257         }
258
259         for ($i = 0; $i < $count; $i++) {
260             $dnComponents = LdapConnection::explodeDn($userGroupSearchResponse[$groupsAttr][$i], 1);
261             if (!in_array($dnComponents[0], $ldapGroups)) {
262                 $ldapGroups[] = $dnComponents[0];
263             }
264         }
265
266         return $ldapGroups;
267     }
268
269     /**
270      * Sync the LDAP groups to the user roles for the current user.
271      *
272      * @throws LdapException
273      * @throws JsonDebugException
274      */
275     public function syncGroups(User $user, string $username)
276     {
277         $userLdapGroups = $this->getUserGroups($username);
278         $this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']);
279     }
280
281     /**
282      * Check if groups should be synced.
283      */
284     public function shouldSyncGroups(): bool
285     {
286         return $this->config['user_to_groups'] !== false;
287     }
288
289     /**
290      * Save and attach an avatar image, if found in the ldap details, and attach
291      * to the given user model.
292      */
293     public function saveAndAttachAvatar(User $user, array $ldapUserDetails): void
294     {
295         if (is_null($this->config['thumbnail_attribute']) || is_null($ldapUserDetails['avatar'])) {
296             return;
297         }
298
299         try {
300             $imageData = $ldapUserDetails['avatar'];
301             $this->userAvatars->assignToUserFromExistingData($user, $imageData, 'jpg');
302         } catch (\Exception $exception) {
303             Log::info("Failed to use avatar image from LDAP data for user id {$user->id}");
304         }
305     }
306 }