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