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