]> BookStack Code Mirror - bookstack/blob - app/Services/LdapService.php
Added ability to set custom ldap group -> role mapping
[bookstack] / app / Services / LdapService.php
1 <?php namespace BookStack\Services;
2
3 use BookStack\Exceptions\LdapException;
4 use BookStack\Repos\UserRepo;
5 use BookStack\Role;
6 use BookStack\User;
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
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 UserRepo $userRepo
28      */
29     public function __construct(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         $user = $this->getUserWithAttributes($userName, ['cn', 'uid', 'dn', $emailAttr]);
83
84         if ($user === null) {
85             return null;
86         }
87
88         return [
89             'uid'   => (isset($user['uid'])) ? $user['uid'][0] : $user['dn'],
90             'name'  => $user['cn'][0],
91             'dn'    => $user['dn'],
92             'email' => (isset($user[$emailAttr])) ? (is_array($user[$emailAttr]) ? $user[$emailAttr][0] : $user[$emailAttr]) : null
93         ];
94     }
95
96     /**
97      * @param Authenticatable $user
98      * @param string          $username
99      * @param string          $password
100      * @return bool
101      * @throws LdapException
102      */
103     public function validateUserCredentials(Authenticatable $user, $username, $password)
104     {
105         $ldapUser = $this->getUserDetails($username);
106         if ($ldapUser === null) {
107             return false;
108         }
109         if ($ldapUser['uid'] !== $user->external_auth_id) {
110             return false;
111         }
112
113         $ldapConnection = $this->getConnection();
114         try {
115             $ldapBind = $this->ldap->bind($ldapConnection, $ldapUser['dn'], $password);
116         } catch (\ErrorException $e) {
117             $ldapBind = false;
118         }
119
120         return $ldapBind;
121     }
122
123     /**
124      * Bind the system user to the LDAP connection using the given credentials
125      * otherwise anonymous access is attempted.
126      * @param $connection
127      * @throws LdapException
128      */
129     protected function bindSystemUser($connection)
130     {
131         $ldapDn = $this->config['dn'];
132         $ldapPass = $this->config['pass'];
133
134         $isAnonymous = ($ldapDn === false || $ldapPass === false);
135         if ($isAnonymous) {
136             $ldapBind = $this->ldap->bind($connection);
137         } else {
138             $ldapBind = $this->ldap->bind($connection, $ldapDn, $ldapPass);
139         }
140
141         if (!$ldapBind) {
142             throw new LdapException(($isAnonymous ? trans('errors.ldap_fail_anonymous') : trans('errors.ldap_fail_authed')));
143         }
144     }
145
146     /**
147      * Get the connection to the LDAP server.
148      * Creates a new connection if one does not exist.
149      * @return resource
150      * @throws LdapException
151      */
152     protected function getConnection()
153     {
154         if ($this->ldapConnection !== null) {
155             return $this->ldapConnection;
156         }
157
158         // Check LDAP extension in installed
159         if (!function_exists('ldap_connect') && config('app.env') !== 'testing') {
160             throw new LdapException(trans('errors.ldap_extension_not_installed'));
161         }
162
163         // Get port from server string and protocol if specified.
164         $ldapServer = explode(':', $this->config['server']);
165         $hasProtocol = preg_match('/^ldaps{0,1}\:\/\//', $this->config['server']) === 1;
166         if (!$hasProtocol) {
167             array_unshift($ldapServer, '');
168         }
169         $hostName = $ldapServer[0] . ($hasProtocol?':':'') . $ldapServer[1];
170         $defaultPort = $ldapServer[0] === 'ldaps' ? 636 : 389;
171         $ldapConnection = $this->ldap->connect($hostName, count($ldapServer) > 2 ? intval($ldapServer[2]) : $defaultPort);
172
173         if ($ldapConnection === false) {
174             throw new LdapException(trans('errors.ldap_cannot_connect'));
175         }
176
177         // Set any required options
178         if ($this->config['version']) {
179             $this->ldap->setVersion($ldapConnection, $this->config['version']);
180         }
181
182         $this->ldapConnection = $ldapConnection;
183         return $this->ldapConnection;
184     }
185
186     /**
187      * Build a filter string by injecting common variables.
188      * @param string $filterString
189      * @param array $attrs
190      * @return string
191      */
192     protected function buildFilter($filterString, array $attrs)
193     {
194         $newAttrs = [];
195         foreach ($attrs as $key => $attrText) {
196             $newKey = '${' . $key . '}';
197             $newAttrs[$newKey] = $attrText;
198         }
199         return strtr($filterString, $newAttrs);
200     }
201
202     /**
203      * Get the groups a user is a part of on ldap
204      * @param string $userName
205      * @return array|null
206      * @throws LdapException
207      */
208     public function getUserGroups($userName)
209     {
210         $groupsAttr = $this->config['group_attribute'];
211         $user = $this->getUserWithAttributes($userName, [$groupsAttr]);
212
213         if ($user === null) {
214             return null;
215         }
216
217         $userGroups = $this->groupFilter($user);
218         $userGroups = $this->getGroupsRecursive($userGroups, []);
219         return $userGroups;
220     }
221
222     /**
223      * Get the parent groups of an array of groups
224      * @param array $groupsArray
225      * @param array $checked
226      * @return array
227      * @throws LdapException
228      */
229     private function getGroupsRecursive($groupsArray, $checked)
230     {
231         $groups_to_add = [];
232         foreach ($groupsArray as $groupName) {
233             if (in_array($groupName, $checked)) {
234                 continue;
235             }
236
237             $groupsToAdd = $this->getGroupGroups($groupName);
238             $groups_to_add = array_merge($groups_to_add, $groupsToAdd);
239             $checked[] = $groupName;
240         }
241         $groupsArray = array_unique(array_merge($groupsArray, $groups_to_add), SORT_REGULAR);
242
243         if (!empty($groups_to_add)) {
244             return $this->getGroupsRecursive($groupsArray, $checked);
245         } else {
246             return $groupsArray;
247         }
248     }
249
250     /**
251      * Get the parent groups of a single group
252      * @param string $groupName
253      * @return array
254      * @throws LdapException
255      */
256     private function getGroupGroups($groupName)
257     {
258         $ldapConnection = $this->getConnection();
259         $this->bindSystemUser($ldapConnection);
260
261         $followReferrals = $this->config['follow_referrals'] ? 1 : 0;
262         $this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
263
264         $baseDn = $this->config['base_dn'];
265         $groupsAttr = strtolower($this->config['group_attribute']);
266
267         $groups = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, 'CN='.$groupName, [$groupsAttr]);
268         if ($groups['count'] === 0) {
269             return [];
270         }
271
272         $groupGroups = $this->groupFilter($groups[0]);
273         return $groupGroups;
274     }
275
276     /**
277      * Filter out LDAP CN and DN language in a ldap search return
278      * Gets the base CN (common name) of the string
279      * @param string $ldapSearchReturn
280      * @return array
281      */
282     protected function groupFilter($ldapSearchReturn)
283     {
284         $groupsAttr = strtolower($this->config['group_attribute']);
285         $ldapGroups = [];
286         $count = 0;
287         if (isset($ldapSearchReturn[$groupsAttr]['count'])) {
288             $count = (int) $ldapSearchReturn[$groupsAttr]['count'];
289         }
290         for ($i=0; $i<$count; $i++) {
291             $dnComponents = ldap_explode_dn($ldapSearchReturn[$groupsAttr][$i], 1);
292             if (!in_array($dnComponents[0], $ldapGroups)) {
293                 $ldapGroups[] = $dnComponents[0];
294             }
295         }
296         return $ldapGroups;
297     }
298
299     /**
300      * Sync the LDAP groups to the user roles for the current user
301      * @param \BookStack\User $user
302      * @throws LdapException
303      */
304     public function syncGroups(User $user)
305     {
306         $userLdapGroups = $this->getUserGroups($user->external_auth_id);
307
308         // Get the ids for the roles from the names
309         $ldapGroupsAsRoles = $this->matchLdapGroupsToSystemsRoles($userLdapGroups);
310
311         // Sync groups
312         if ($this->config['remove_from_groups']) {
313             $user->roles()->sync($ldapGroupsAsRoles);
314             $this->userRepo->attachDefaultRole($user);
315         } else {
316             $user->roles()->syncWithoutDetaching($ldapGroupsAsRoles);
317         }
318     }
319
320     /**
321      * Match an array of group names from LDAP to BookStack system roles.
322      * Formats LDAP group names to be lower-case and hyphenated.
323      * @param array $groupNames
324      * @return \Illuminate\Support\Collection
325      */
326     protected function matchLdapGroupsToSystemsRoles(array $groupNames)
327     {
328         foreach ($groupNames as $i => $groupName) {
329             $groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
330         }
331
332         $roles = Role::query()->where(function(Builder $query) use ($groupNames) {
333             $query->whereIn('name', $groupNames);
334             foreach ($groupNames as $groupName) {
335                 $query->orWhere('external_auth_id', 'LIKE', '%' . $groupName . '%');
336             }
337         })->get();
338
339         $matchedRoles = $roles->filter(function(Role $role) use ($groupNames) {
340             return $this->roleMatchesGroupNames($role, $groupNames);
341         });
342
343         return $matchedRoles->pluck('id');
344     }
345
346     /**
347      * Check a role against an array of group names to see if it matches.
348      * Checked against role 'external_auth_id' if set otherwise the name of the role.
349      * @param Role $role
350      * @param array $groupNames
351      * @return bool
352      */
353     protected function roleMatchesGroupNames(Role $role, array $groupNames)
354     {
355         if ($role->external_auth_id) {
356             $externalAuthIds = explode(',', strtolower($role->external_auth_id));
357             foreach ($externalAuthIds as $externalAuthId) {
358                 if (in_array(trim($externalAuthId), $groupNames)) {
359                     return true;
360                 }
361             }
362             return false;
363         }
364
365         $roleName = str_replace(' ', '-', trim(strtolower($role->display_name)));
366         return in_array($roleName, $groupNames);
367     }
368
369 }