]> BookStack Code Mirror - bookstack/blob - app/Auth/Access/LdapService.php
Fix "Declaration of Middleware\TrustProxies::handle should be compatible with Fidelop...
[bookstack] / app / Auth / Access / LdapService.php
1 <?php namespace BookStack\Auth\Access;
2
3 use BookStack\Auth\Access;
4 use BookStack\Auth\User;
5 use BookStack\Auth\UserRepo;
6 use BookStack\Exceptions\LdapException;
7 use Illuminate\Contracts\Auth\Authenticatable;
8
9 /**
10  * Class LdapService
11  * Handles any app-specific LDAP tasks.
12  * @package BookStack\Services
13  */
14 class LdapService extends Access\ExternalAuthService
15 {
16
17     protected $ldap;
18     protected $ldapConnection;
19     protected $config;
20     protected $userRepo;
21     protected $enabled;
22
23     /**
24      * LdapService constructor.
25      * @param Ldap $ldap
26      * @param \BookStack\Auth\UserRepo $userRepo
27      */
28     public function __construct(Access\Ldap $ldap, UserRepo $userRepo)
29     {
30         $this->ldap = $ldap;
31         $this->config = config('services.ldap');
32         $this->userRepo = $userRepo;
33         $this->enabled = config('auth.method') === 'ldap';
34     }
35
36     /**
37      * Check if groups should be synced.
38      * @return bool
39      */
40     public function shouldSyncGroups()
41     {
42         return $this->enabled && $this->config['user_to_groups'] !== false;
43     }
44
45     /**
46      * Search for attributes for a specific user on the ldap
47      * @param string $userName
48      * @param array $attributes
49      * @return null|array
50      * @throws LdapException
51      */
52     private function getUserWithAttributes($userName, $attributes)
53     {
54         $ldapConnection = $this->getConnection();
55         $this->bindSystemUser($ldapConnection);
56
57         // Find user
58         $userFilter = $this->buildFilter($this->config['user_filter'], ['user' => $userName]);
59         $baseDn = $this->config['base_dn'];
60
61         $followReferrals = $this->config['follow_referrals'] ? 1 : 0;
62         $this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
63         $users = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $userFilter, $attributes);
64         if ($users['count'] === 0) {
65             return null;
66         }
67
68         return $users[0];
69     }
70
71     /**
72      * Get the details of a user from LDAP using the given username.
73      * User found via configurable user filter.
74      * @param $userName
75      * @return array|null
76      * @throws LdapException
77      */
78     public function getUserDetails($userName)
79     {
80         $emailAttr = $this->config['email_attribute'];
81         $displayNameAttr = $this->config['display_name_attribute'];
82
83         $user = $this->getUserWithAttributes($userName, ['cn', 'uid', 'dn', $emailAttr, $displayNameAttr]);
84
85         if ($user === null) {
86             return null;
87         }
88
89         $userCn = $this->getUserResponseProperty($user, 'cn', null);
90         return [
91             'uid'   => $this->getUserResponseProperty($user, 'uid', $user['dn']),
92             'name'  => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
93             'dn'    => $user['dn'],
94             'email' => $this->getUserResponseProperty($user, $emailAttr, null),
95         ];
96     }
97
98     /**
99      * Get a property from an LDAP user response fetch.
100      * Handles properties potentially being part of an array.
101      * @param array $userDetails
102      * @param string $propertyKey
103      * @param $defaultValue
104      * @return mixed
105      */
106     protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue)
107     {
108         if (isset($userDetails[$propertyKey])) {
109             return (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]);
110         }
111
112         return $defaultValue;
113     }
114
115     /**
116      * @param Authenticatable $user
117      * @param string $username
118      * @param string $password
119      * @return bool
120      * @throws LdapException
121      */
122     public function validateUserCredentials(Authenticatable $user, $username, $password)
123     {
124         $ldapUser = $this->getUserDetails($username);
125         if ($ldapUser === null) {
126             return false;
127         }
128
129         if ($ldapUser['uid'] !== $user->external_auth_id) {
130             return false;
131         }
132
133         $ldapConnection = $this->getConnection();
134         try {
135             $ldapBind = $this->ldap->bind($ldapConnection, $ldapUser['dn'], $password);
136         } catch (\ErrorException $e) {
137             $ldapBind = false;
138         }
139
140         return $ldapBind;
141     }
142
143     /**
144      * Bind the system user to the LDAP connection using the given credentials
145      * otherwise anonymous access is attempted.
146      * @param $connection
147      * @throws LdapException
148      */
149     protected function bindSystemUser($connection)
150     {
151         $ldapDn = $this->config['dn'];
152         $ldapPass = $this->config['pass'];
153
154         $isAnonymous = ($ldapDn === false || $ldapPass === false);
155         if ($isAnonymous) {
156             $ldapBind = $this->ldap->bind($connection);
157         } else {
158             $ldapBind = $this->ldap->bind($connection, $ldapDn, $ldapPass);
159         }
160
161         if (!$ldapBind) {
162             throw new LdapException(($isAnonymous ? trans('errors.ldap_fail_anonymous') : trans('errors.ldap_fail_authed')));
163         }
164     }
165
166     /**
167      * Get the connection to the LDAP server.
168      * Creates a new connection if one does not exist.
169      * @return resource
170      * @throws LdapException
171      */
172     protected function getConnection()
173     {
174         if ($this->ldapConnection !== null) {
175             return $this->ldapConnection;
176         }
177
178         // Check LDAP extension in installed
179         if (!function_exists('ldap_connect') && config('app.env') !== 'testing') {
180             throw new LdapException(trans('errors.ldap_extension_not_installed'));
181         }
182
183          // Check if TLS_INSECURE is set. The handle is set to NULL due to the nature of
184          // the LDAP_OPT_X_TLS_REQUIRE_CERT option. It can only be set globally and not per handle.
185         if ($this->config['tls_insecure']) {
186             $this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
187         }
188
189         $serverDetails = $this->parseServerString($this->config['server']);
190         $ldapConnection = $this->ldap->connect($serverDetails['host'], $serverDetails['port']);
191
192         if ($ldapConnection === false) {
193             throw new LdapException(trans('errors.ldap_cannot_connect'));
194         }
195
196         // Set any required options
197         if ($this->config['version']) {
198             $this->ldap->setVersion($ldapConnection, $this->config['version']);
199         }
200
201         $this->ldapConnection = $ldapConnection;
202         return $this->ldapConnection;
203     }
204
205     /**
206      * Parse a LDAP server string and return the host and port for
207      * a connection. Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'
208      * @param $serverString
209      * @return array
210      */
211     protected function parseServerString($serverString)
212     {
213         $serverNameParts = explode(':', $serverString);
214
215         // If we have a protocol just return the full string since PHP will ignore a separate port.
216         if ($serverNameParts[0] === 'ldaps' || $serverNameParts[0] === 'ldap') {
217             return ['host' => $serverString, 'port' => 389];
218         }
219
220         // Otherwise, extract the port out
221         $hostName = $serverNameParts[0];
222         $ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389;
223         return ['host' => $hostName, 'port' => $ldapPort];
224     }
225
226     /**
227      * Build a filter string by injecting common variables.
228      * @param string $filterString
229      * @param array $attrs
230      * @return string
231      */
232     protected function buildFilter($filterString, array $attrs)
233     {
234         $newAttrs = [];
235         foreach ($attrs as $key => $attrText) {
236             $newKey = '${' . $key . '}';
237             $newAttrs[$newKey] = $this->ldap->escape($attrText);
238         }
239         return strtr($filterString, $newAttrs);
240     }
241
242     /**
243      * Get the groups a user is a part of on ldap
244      * @param string $userName
245      * @return array
246      * @throws LdapException
247      */
248     public function getUserGroups($userName)
249     {
250         $groupsAttr = $this->config['group_attribute'];
251         $user = $this->getUserWithAttributes($userName, [$groupsAttr]);
252
253         if ($user === null) {
254             return [];
255         }
256
257         $userGroups = $this->groupFilter($user);
258         $userGroups = $this->getGroupsRecursive($userGroups, []);
259         return $userGroups;
260     }
261
262     /**
263      * Get the parent groups of an array of groups
264      * @param array $groupsArray
265      * @param array $checked
266      * @return array
267      * @throws LdapException
268      */
269     private function getGroupsRecursive($groupsArray, $checked)
270     {
271         $groups_to_add = [];
272         foreach ($groupsArray as $groupName) {
273             if (in_array($groupName, $checked)) {
274                 continue;
275             }
276
277             $groupsToAdd = $this->getGroupGroups($groupName);
278             $groups_to_add = array_merge($groups_to_add, $groupsToAdd);
279             $checked[] = $groupName;
280         }
281         $groupsArray = array_unique(array_merge($groupsArray, $groups_to_add), SORT_REGULAR);
282
283         if (!empty($groups_to_add)) {
284             return $this->getGroupsRecursive($groupsArray, $checked);
285         } else {
286             return $groupsArray;
287         }
288     }
289
290     /**
291      * Get the parent groups of a single group
292      * @param string $groupName
293      * @return array
294      * @throws LdapException
295      */
296     private function getGroupGroups($groupName)
297     {
298         $ldapConnection = $this->getConnection();
299         $this->bindSystemUser($ldapConnection);
300
301         $followReferrals = $this->config['follow_referrals'] ? 1 : 0;
302         $this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
303
304         $baseDn = $this->config['base_dn'];
305         $groupsAttr = strtolower($this->config['group_attribute']);
306
307         $groupFilter = 'CN=' . $this->ldap->escape($groupName);
308         $groups = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $groupFilter, [$groupsAttr]);
309         if ($groups['count'] === 0) {
310             return [];
311         }
312
313         $groupGroups = $this->groupFilter($groups[0]);
314         return $groupGroups;
315     }
316
317     /**
318      * Filter out LDAP CN and DN language in a ldap search return
319      * Gets the base CN (common name) of the string
320      * @param array $userGroupSearchResponse
321      * @return array
322      */
323     protected function groupFilter(array $userGroupSearchResponse)
324     {
325         $groupsAttr = strtolower($this->config['group_attribute']);
326         $ldapGroups = [];
327         $count = 0;
328
329         if (isset($userGroupSearchResponse[$groupsAttr]['count'])) {
330             $count = (int)$userGroupSearchResponse[$groupsAttr]['count'];
331         }
332
333         for ($i = 0; $i < $count; $i++) {
334             $dnComponents = $this->ldap->explodeDn($userGroupSearchResponse[$groupsAttr][$i], 1);
335             if (!in_array($dnComponents[0], $ldapGroups)) {
336                 $ldapGroups[] = $dnComponents[0];
337             }
338         }
339
340         return $ldapGroups;
341     }
342
343     /**
344      * Sync the LDAP groups to the user roles for the current user
345      * @param \BookStack\Auth\User $user
346      * @param string $username
347      * @throws LdapException
348      */
349     public function syncGroups(User $user, string $username)
350     {
351         $userLdapGroups = $this->getUserGroups($username);
352         $this->syncWithGroups($user, $userLdapGroups);
353     }
354 }