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