X-Git-Url: http://source.bookstackapp.com/bookstack/blobdiff_plain/8169c725d55eb64ffd45b472520bb68f5df608d7..refs/pull/2522/head:/app/Auth/Access/Saml2Service.php diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index bb57ceb73..0316ff976 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -1,9 +1,17 @@ config = config('services.saml'); - $this->userRepo = $userRepo; + $this->config = config('saml2'); + $this->registrationService = $registrationService; $this->user = $user; - $this->enabled = config('saml2_settings.enabled') === true; + } + + /** + * Initiate a login flow. + * @throws Error + */ + public function login(): array + { + $toolKit = $this->getToolkit(); + $returnRoute = url('/http/source.bookstackapp.com/saml2/acs'); + return [ + 'url' => $toolKit->login($returnRoute, [], false, false, true), + 'id' => $toolKit->getLastRequestID(), + ]; + } + + /** + * Initiate a logout flow. + * @throws Error + */ + public function logout(): array + { + $toolKit = $this->getToolkit(); + $returnRoute = url('/'); + + try { + $url = $toolKit->logout($returnRoute, [], null, null, true); + $id = $toolKit->getLastRequestID(); + } catch (Error $error) { + if ($error->getCode() !== Error::SAML_SINGLE_LOGOUT_NOT_SUPPORTED) { + throw $error; + } + + $this->actionLogout(); + $url = '/'; + $id = null; + } + + return ['url' => $url, 'id' => $id]; + } + + /** + * Process the ACS response from the idp and return the + * matching, or new if registration active, user matched to the idp. + * Returns null if not authenticated. + * @throws Error + * @throws SamlException + * @throws ValidationError + * @throws JsonDebugException + * @throws UserRegistrationException + */ + public function processAcsResponse(?string $requestId): ?User + { + $toolkit = $this->getToolkit(); + $toolkit->processResponse($requestId); + $errors = $toolkit->getErrors(); + + if (!empty($errors)) { + throw new Error( + 'Invalid ACS Response: '.implode(', ', $errors) + ); + } + + if (!$toolkit->isAuthenticated()) { + return null; + } + + $attrs = $toolkit->getAttributes(); + $id = $toolkit->getNameId(); + + return $this->processLoginCallback($id, $attrs); + } + + /** + * Process a response for the single logout service. + * @throws Error + */ + public function processSlsResponse(?string $requestId): ?string + { + $toolkit = $this->getToolkit(); + $redirect = $toolkit->processSLO(true, $requestId, false, null, true); + + $errors = $toolkit->getErrors(); + + if (!empty($errors)) { + throw new Error( + 'Invalid SLS Response: '.implode(', ', $errors) + ); + } + + $this->actionLogout(); + return $redirect; + } + + /** + * Do the required actions to log a user out. + */ + protected function actionLogout() + { + auth()->logout(); + session()->invalidate(); + } + + /** + * Get the metadata for this service provider. + * @throws Error + */ + public function metadata(): string + { + $toolKit = $this->getToolkit(); + $settings = $toolKit->getSettings(); + $metadata = $settings->getSPMetadata(); + $errors = $settings->validateMetadata($metadata); + + if (!empty($errors)) { + throw new Error( + 'Invalid SP metadata: '.implode(', ', $errors), + Error::METADATA_SP_INVALID + ); + } + + return $metadata; + } + + /** + * Load the underlying Onelogin SAML2 toolkit. + * @throws Error + * @throws Exception + */ + protected function getToolkit(): Auth + { + $settings = $this->config['onelogin']; + $overrides = $this->config['onelogin_overrides'] ?? []; + + if ($overrides && is_string($overrides)) { + $overrides = json_decode($overrides, true); + } + + $metaDataSettings = []; + if ($this->config['autoload_from_metadata']) { + $metaDataSettings = IdPMetadataParser::parseRemoteXML($settings['idp']['entityId']); + } + + $spSettings = $this->loadOneloginServiceProviderDetails(); + $settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides); + return new Auth($settings); + } + + /** + * Load dynamic service provider options required by the onelogin toolkit. + */ + protected function loadOneloginServiceProviderDetails(): array + { + $spDetails = [ + 'entityId' => url('/http/source.bookstackapp.com/saml2/metadata'), + 'assertionConsumerService' => [ + 'url' => url('/http/source.bookstackapp.com/saml2/acs'), + ], + 'singleLogoutService' => [ + 'url' => url('/http/source.bookstackapp.com/saml2/sls') + ], + ]; + + return [ + 'baseurl' => url('/http/source.bookstackapp.com/saml2'), + 'sp' => $spDetails + ]; } /** @@ -32,7 +205,7 @@ class Saml2Service extends ExternalAuthService */ protected function shouldSyncGroups(): bool { - return $this->enabled && $this->config['user_to_groups'] !== false; + return $this->config['user_to_groups'] !== false; } /** @@ -75,17 +248,14 @@ class Saml2Service extends ExternalAuthService /** * Extract the details of a user from a SAML response. - * @throws SamlException */ - public function getUserDetails(string $samlID, $samlAttributes): array + protected function getUserDetails(string $samlID, $samlAttributes): array { $emailAttr = $this->config['email_attribute']; $externalId = $this->getExternalId($samlAttributes, $samlID); - $email = $this->getSamlResponseAttribute($samlAttributes, $emailAttr, null); - if ($email === null) { - throw new SamlException(trans('errors.saml_no_email_address')); - } + $defaultEmail = filter_var($samlID, FILTER_VALIDATE_EMAIL) ? $samlID : null; + $email = $this->getSamlResponseAttribute($samlAttributes, $emailAttr, $defaultEmail); return [ 'external_id' => $externalId, @@ -141,40 +311,25 @@ class Saml2Service extends ExternalAuthService return $defaultValue; } - /** - * Register a user that is authenticated but not already registered. - */ - protected function registerUser(array $userDetails): User - { - // Create an array of the user data to create a new user instance - - $userData = [ - 'name' => $userDetails['name'], - 'email' => $userDetails['email'] ?? '', - 'password' => Str::random(32), - 'external_auth_id' => $userDetails['external_id'], - 'email_confirmed' => true, - ]; - - // TODO - Handle duplicate email address scenario - $user = $this->user->forceCreate($userData); - $this->userRepo->attachDefaultRole($user); - $this->userRepo->downloadAndAssignUserAvatar($user); - return $user; - } - /** * Get the user from the database for the specified details. + * @throws UserRegistrationException */ protected function getOrRegisterUser(array $userDetails): ?User { - $isRegisterEnabled = config('services.saml.auto_register') === true; - $user = $this->user - ->where('external_auth_id', $userDetails['external_id']) + $user = $this->user->newQuery() + ->where('external_auth_id', '=', $userDetails['external_id']) ->first(); - if ($user === null && $isRegisterEnabled) { - $user = $this->registerUser($userDetails); + if (is_null($user)) { + $userData = [ + 'name' => $userDetails['name'], + 'email' => $userDetails['email'], + 'password' => Str::random(32), + 'external_auth_id' => $userDetails['external_id'], + ]; + + $user = $this->registrationService->registerUser($userData, null, false); } return $user; @@ -184,12 +339,26 @@ class Saml2Service extends ExternalAuthService * Process the SAML response for a user. Login the user when * they exist, optionally registering them automatically. * @throws SamlException + * @throws JsonDebugException + * @throws UserRegistrationException */ public function processLoginCallback(string $samlID, array $samlAttributes): User { $userDetails = $this->getUserDetails($samlID, $samlAttributes); $isLoggedIn = auth()->check(); + if ($this->config['dump_user_details']) { + throw new JsonDebugException([ + 'id_from_idp' => $samlID, + 'attrs_from_idp' => $samlAttributes, + 'attrs_after_parsing' => $userDetails, + ]); + } + + if ($userDetails['email'] === null) { + throw new SamlException(trans('errors.saml_no_email_address')); + } + if ($isLoggedIn) { throw new SamlException(trans('errors.saml_already_logged_in'), '/login'); } @@ -205,6 +374,7 @@ class Saml2Service extends ExternalAuthService } auth()->login($user); + Activity::add(ActivityType::AUTH_LOGIN, "saml2; {$user->logDescriptor()}"); return $user; } }