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