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