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