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