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