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