]> BookStack Code Mirror - bookstack/blob - app/Auth/Access/Saml2Service.php
Reviewed and refactored additional editor draft save warnings
[bookstack] / app / Auth / Access / Saml2Service.php
1 <?php
2
3 namespace BookStack\Auth\Access;
4
5 use BookStack\Auth\User;
6 use BookStack\Exceptions\JsonDebugException;
7 use BookStack\Exceptions\SamlException;
8 use BookStack\Exceptions\StoppedAuthenticationException;
9 use BookStack\Exceptions\UserRegistrationException;
10 use Exception;
11 use Illuminate\Support\Str;
12 use OneLogin\Saml2\Auth;
13 use OneLogin\Saml2\Error;
14 use OneLogin\Saml2\IdPMetadataParser;
15 use OneLogin\Saml2\ValidationError;
16
17 /**
18  * Class Saml2Service
19  * Handles any app-specific SAML tasks.
20  */
21 class Saml2Service extends ExternalAuthService
22 {
23     protected $config;
24     protected $registrationService;
25     protected $loginService;
26
27     /**
28      * Saml2Service constructor.
29      */
30     public function __construct(RegistrationService $registrationService, LoginService $loginService)
31     {
32         $this->config = config('saml2');
33         $this->registrationService = $registrationService;
34         $this->loginService = $loginService;
35     }
36
37     /**
38      * Initiate a login flow.
39      *
40      * @throws Error
41      */
42     public function login(): array
43     {
44         $toolKit = $this->getToolkit();
45         $returnRoute = url('/saml2/acs');
46
47         return [
48             'url' => $toolKit->login($returnRoute, [], false, false, true),
49             'id'  => $toolKit->getLastRequestID(),
50         ];
51     }
52
53     /**
54      * Initiate a logout flow.
55      *
56      * @throws Error
57      */
58     public function logout(): array
59     {
60         $toolKit = $this->getToolkit();
61         $returnRoute = url('/');
62
63         try {
64             $url = $toolKit->logout($returnRoute, [], null, null, true);
65             $id = $toolKit->getLastRequestID();
66         } catch (Error $error) {
67             if ($error->getCode() !== Error::SAML_SINGLE_LOGOUT_NOT_SUPPORTED) {
68                 throw $error;
69             }
70
71             $this->actionLogout();
72             $url = '/';
73             $id = null;
74         }
75
76         return ['url' => $url, 'id' => $id];
77     }
78
79     /**
80      * Process the ACS response from the idp and return the
81      * matching, or new if registration active, user matched to the idp.
82      * Returns null if not authenticated.
83      *
84      * @throws Error
85      * @throws SamlException
86      * @throws ValidationError
87      * @throws JsonDebugException
88      * @throws UserRegistrationException
89      */
90     public function processAcsResponse(?string $requestId): ?User
91     {
92         $toolkit = $this->getToolkit();
93         $toolkit->processResponse($requestId);
94         $errors = $toolkit->getErrors();
95
96         if (!empty($errors)) {
97             throw new Error(
98                 'Invalid ACS Response: ' . implode(', ', $errors)
99             );
100         }
101
102         if (!$toolkit->isAuthenticated()) {
103             return null;
104         }
105
106         $attrs = $toolkit->getAttributes();
107         $id = $toolkit->getNameId();
108
109         return $this->processLoginCallback($id, $attrs);
110     }
111
112     /**
113      * Process a response for the single logout service.
114      *
115      * @throws Error
116      */
117     public function processSlsResponse(?string $requestId): ?string
118     {
119         $toolkit = $this->getToolkit();
120         $redirect = $toolkit->processSLO(true, $requestId, false, null, true);
121
122         $errors = $toolkit->getErrors();
123
124         if (!empty($errors)) {
125             throw new Error(
126                 'Invalid SLS Response: ' . implode(', ', $errors)
127             );
128         }
129
130         $this->actionLogout();
131
132         return $redirect;
133     }
134
135     /**
136      * Do the required actions to log a user out.
137      */
138     protected function actionLogout()
139     {
140         auth()->logout();
141         session()->invalidate();
142     }
143
144     /**
145      * Get the metadata for this service provider.
146      *
147      * @throws Error
148      */
149     public function metadata(): string
150     {
151         $toolKit = $this->getToolkit();
152         $settings = $toolKit->getSettings();
153         $metadata = $settings->getSPMetadata();
154         $errors = $settings->validateMetadata($metadata);
155
156         if (!empty($errors)) {
157             throw new Error(
158                 'Invalid SP metadata: ' . implode(', ', $errors),
159                 Error::METADATA_SP_INVALID
160             );
161         }
162
163         return $metadata;
164     }
165
166     /**
167      * Load the underlying Onelogin SAML2 toolkit.
168      *
169      * @throws Error
170      * @throws Exception
171      */
172     protected function getToolkit(): Auth
173     {
174         $settings = $this->config['onelogin'];
175         $overrides = $this->config['onelogin_overrides'] ?? [];
176
177         if ($overrides && is_string($overrides)) {
178             $overrides = json_decode($overrides, true);
179         }
180
181         $metaDataSettings = [];
182         if ($this->config['autoload_from_metadata']) {
183             $metaDataSettings = IdPMetadataParser::parseRemoteXML($settings['idp']['entityId']);
184         }
185
186         $spSettings = $this->loadOneloginServiceProviderDetails();
187         $settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides);
188
189         return new Auth($settings);
190     }
191
192     /**
193      * Load dynamic service provider options required by the onelogin toolkit.
194      */
195     protected function loadOneloginServiceProviderDetails(): array
196     {
197         $spDetails = [
198             'entityId'                 => url('/saml2/metadata'),
199             'assertionConsumerService' => [
200                 'url' => url('/saml2/acs'),
201             ],
202             'singleLogoutService' => [
203                 'url' => url('/saml2/sls'),
204             ],
205         ];
206
207         return [
208             'baseurl' => url('/saml2'),
209             'sp'      => $spDetails,
210         ];
211     }
212
213     /**
214      * Check if groups should be synced.
215      */
216     protected function shouldSyncGroups(): bool
217     {
218         return $this->config['user_to_groups'] !== false;
219     }
220
221     /**
222      * Calculate the display name.
223      */
224     protected function getUserDisplayName(array $samlAttributes, string $defaultValue): string
225     {
226         $displayNameAttr = $this->config['display_name_attributes'];
227
228         $displayName = [];
229         foreach ($displayNameAttr as $dnAttr) {
230             $dnComponent = $this->getSamlResponseAttribute($samlAttributes, $dnAttr, null);
231             if ($dnComponent !== null) {
232                 $displayName[] = $dnComponent;
233             }
234         }
235
236         if (count($displayName) == 0) {
237             $displayName = $defaultValue;
238         } else {
239             $displayName = implode(' ', $displayName);
240         }
241
242         return $displayName;
243     }
244
245     /**
246      * Get the value to use as the external id saved in BookStack
247      * used to link the user to an existing BookStack DB user.
248      */
249     protected function getExternalId(array $samlAttributes, string $defaultValue)
250     {
251         $userNameAttr = $this->config['external_id_attribute'];
252         if ($userNameAttr === null) {
253             return $defaultValue;
254         }
255
256         return $this->getSamlResponseAttribute($samlAttributes, $userNameAttr, $defaultValue);
257     }
258
259     /**
260      * Extract the details of a user from a SAML response.
261      */
262     protected function getUserDetails(string $samlID, $samlAttributes): array
263     {
264         $emailAttr = $this->config['email_attribute'];
265         $externalId = $this->getExternalId($samlAttributes, $samlID);
266
267         $defaultEmail = filter_var($samlID, FILTER_VALIDATE_EMAIL) ? $samlID : null;
268         $email = $this->getSamlResponseAttribute($samlAttributes, $emailAttr, $defaultEmail);
269
270         return [
271             'external_id' => $externalId,
272             'name'        => $this->getUserDisplayName($samlAttributes, $externalId),
273             'email'       => $email,
274             'saml_id'     => $samlID,
275         ];
276     }
277
278     /**
279      * Get the groups a user is a part of from the SAML response.
280      */
281     public function getUserGroups(array $samlAttributes): array
282     {
283         $groupsAttr = $this->config['group_attribute'];
284         $userGroups = $samlAttributes[$groupsAttr] ?? null;
285
286         if (!is_array($userGroups)) {
287             $userGroups = [];
288         }
289
290         return $userGroups;
291     }
292
293     /**
294      *  For an array of strings, return a default for an empty array,
295      *  a string for an array with one element and the full array for
296      *  more than one element.
297      */
298     protected function simplifyValue(array $data, $defaultValue)
299     {
300         switch (count($data)) {
301             case 0:
302                 $data = $defaultValue;
303                 break;
304             case 1:
305                 $data = $data[0];
306                 break;
307         }
308
309         return $data;
310     }
311
312     /**
313      * Get a property from an SAML response.
314      * Handles properties potentially being an array.
315      */
316     protected function getSamlResponseAttribute(array $samlAttributes, string $propertyKey, $defaultValue)
317     {
318         if (isset($samlAttributes[$propertyKey])) {
319             return $this->simplifyValue($samlAttributes[$propertyKey], $defaultValue);
320         }
321
322         return $defaultValue;
323     }
324
325     /**
326      * Get the user from the database for the specified details.
327      *
328      * @throws UserRegistrationException
329      */
330     protected function getOrRegisterUser(array $userDetails): ?User
331     {
332         $user = User::query()
333           ->where('external_auth_id', '=', $userDetails['external_id'])
334           ->first();
335
336         if (is_null($user)) {
337             $userData = [
338                 'name'             => $userDetails['name'],
339                 'email'            => $userDetails['email'],
340                 'password'         => Str::random(32),
341                 'external_auth_id' => $userDetails['external_id'],
342             ];
343
344             $user = $this->registrationService->registerUser($userData, null, false);
345         }
346
347         return $user;
348     }
349
350     /**
351      * Process the SAML response for a user. Login the user when
352      * they exist, optionally registering them automatically.
353      *
354      * @throws SamlException
355      * @throws JsonDebugException
356      * @throws UserRegistrationException
357      * @throws StoppedAuthenticationException
358      */
359     public function processLoginCallback(string $samlID, array $samlAttributes): User
360     {
361         $userDetails = $this->getUserDetails($samlID, $samlAttributes);
362         $isLoggedIn = auth()->check();
363
364         if ($this->config['dump_user_details']) {
365             throw new JsonDebugException([
366                 'id_from_idp'         => $samlID,
367                 'attrs_from_idp'      => $samlAttributes,
368                 'attrs_after_parsing' => $userDetails,
369             ]);
370         }
371
372         if ($userDetails['email'] === null) {
373             throw new SamlException(trans('errors.saml_no_email_address'));
374         }
375
376         if ($isLoggedIn) {
377             throw new SamlException(trans('errors.saml_already_logged_in'), '/login');
378         }
379
380         $user = $this->getOrRegisterUser($userDetails);
381         if ($user === null) {
382             throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
383         }
384
385         if ($this->shouldSyncGroups()) {
386             $groups = $this->getUserGroups($samlAttributes);
387             $this->syncWithGroups($user, $groups);
388         }
389
390         $this->loginService->login($user, 'saml2');
391
392         return $user;
393     }
394 }