X-Git-Url: http://source.bookstackapp.com/bookstack/blobdiff_plain/bda0082461c4609b7333c8e3d9373f8d68da3da7..refs/pull/2393/head:/app/Auth/Access/Saml2Service.php diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index 0b6cbe805..0316ff976 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -1,241 +1,380 @@ config = config('services.saml'); - $this->userRepo = $userRepo; + $this->config = config('saml2'); + $this->registrationService = $registrationService; $this->user = $user; - $this->enabled = config('saml2_settings.enabled') === true; } /** - * Check if groups should be synced. - * @return bool + * Initiate a login flow. + * @throws Error */ - public function shouldSyncGroups() + public function login(): array { - return $this->enabled && $this->config['user_to_groups'] !== false; + $toolKit = $this->getToolkit(); + $returnRoute = url('/http/source.bookstackapp.com/saml2/acs'); + return [ + 'url' => $toolKit->login($returnRoute, [], false, false, true), + 'id' => $toolKit->getLastRequestID(), + ]; } /** - * Extract the details of a user from a SAML response. - * @param $samlID - * @param $samlAttributes - * @return array + * Initiate a logout flow. + * @throws Error */ - public function getUserDetails($samlID, $samlAttributes) + public function logout(): array { - $emailAttr = $this->config['email_attribute']; - $displayNameAttr = $this->config['display_name_attribute']; - $userNameAttr = $this->config['user_name_attribute']; + $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; + } - $email = $this->getSamlResponseAttribute($samlAttributes, $emailAttr, null); + $this->actionLogout(); + $url = '/'; + $id = null; + } - if ($userNameAttr === null) { - $userName = $samlID; - } else { - $userName = $this->getSamlResponseAttribute($samlAttributes, $userNameAttr, $samlID); + 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) + ); } - $displayName = []; - foreach ($displayNameAttr as $dnAttr) { - $dnComponent = $this->getSamlResponseAttribute($samlAttributes, $dnAttr, null); - if ($dnComponent !== null) { - $displayName[] = $dnComponent; - } + if (!$toolkit->isAuthenticated()) { + return null; } - if (count($displayName) == 0) { - $displayName = $userName; - } else { - $displayName = implode(' ', $displayName); + $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) + ); } - return [ - 'uid' => $userName, - 'name' => $displayName, - 'dn' => $samlID, - 'email' => $email, - ]; + $this->actionLogout(); + return $redirect; } /** - * Get the groups a user is a part of from the SAML response. - * @param array $samlAttributes - * @return array + * Do the required actions to log a user out. */ - public function getUserGroups($samlAttributes) + protected function actionLogout() { - $groupsAttr = $this->config['group_attribute']; - $userGroups = $samlAttributes[$groupsAttr]; + auth()->logout(); + session()->invalidate(); + } - if (!is_array($userGroups)) { - $userGroups = []; + /** + * 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 $userGroups; + return $metadata; } /** - * Get a property from an SAML response. - * Handles properties potentially being an array. - * @param array $userDetails - * @param string $propertyKey - * @param $defaultValue - * @return mixed + * Load the underlying Onelogin SAML2 toolkit. + * @throws Error + * @throws Exception */ - protected function getSamlResponseAttribute(array $samlAttributes, string $propertyKey, $defaultValue) + protected function getToolkit(): Auth { - if (isset($samlAttributes[$propertyKey])) { - $data = $samlAttributes[$propertyKey]; - if (!is_array($data)) { - return $data; - } else if (count($data) == 0) { - return $defaultValue; - } else if (count($data) == 1) { - return $data[0]; - } else { - return $data; - } + $settings = $this->config['onelogin']; + $overrides = $this->config['onelogin_overrides'] ?? []; + + if ($overrides && is_string($overrides)) { + $overrides = json_decode($overrides, true); } - return $defaultValue; + $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); } - protected function registerUser($userDetails) { + /** + * 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') + ], + ]; - // Create an array of the user data to create a new user instance - $userData = [ - 'name' => $userDetails['name'], - 'email' => $userDetails['email'], - 'password' => str_random(30), - 'external_auth_id' => $userDetails['uid'], - 'email_confirmed' => true, + return [ + 'baseurl' => url('/http/source.bookstackapp.com/saml2'), + 'sp' => $spDetails ]; + } - $user = $this->user->forceCreate($userData); - $this->userRepo->attachDefaultRole($user); - $this->userRepo->downloadAndAssignUserAvatar($user); - return $user; + /** + * Check if groups should be synced. + */ + protected function shouldSyncGroups(): bool + { + return $this->config['user_to_groups'] !== false; } - public function processLoginCallback($samlID, $samlAttributes) { + /** + * Calculate the display name + */ + protected function getUserDisplayName(array $samlAttributes, string $defaultValue): string + { + $displayNameAttr = $this->config['display_name_attributes']; - $userDetails = $this->getUserDetails($samlID, $samlAttributes); - $user = $this->user - ->where('external_auth_id', $userDetails['uid']) - ->first(); + $displayName = []; + foreach ($displayNameAttr as $dnAttr) { + $dnComponent = $this->getSamlResponseAttribute($samlAttributes, $dnAttr, null); + if ($dnComponent !== null) { + $displayName[] = $dnComponent; + } + } - $isLoggedIn = auth()->check(); + if (count($displayName) == 0) { + $displayName = $defaultValue; + } else { + $displayName = implode(' ', $displayName); + } - if (!$isLoggedIn) { - if ($user === null && config('services.saml.auto_register') === true) { - $user = $this->registerUser($userDetails); - } + return $displayName; + } - if ($user !== null) { - auth()->login($user); - } + /** + * Get the value to use as the external id saved in BookStack + * used to link the user to an existing BookStack DB user. + */ + protected function getExternalId(array $samlAttributes, string $defaultValue) + { + $userNameAttr = $this->config['external_id_attribute']; + if ($userNameAttr === null) { + return $defaultValue; } - return $user; + return $this->getSamlResponseAttribute($samlAttributes, $userNameAttr, $defaultValue); } /** - * Sync the SAML groups to the user roles for the current user - * @param \BookStack\Auth\User $user - * @param array $samlAttributes + * Extract the details of a user from a SAML response. */ - public function syncGroups(User $user, array $samlAttributes) + protected function getUserDetails(string $samlID, $samlAttributes): array { - $userSamlGroups = $this->getUserGroups($samlAttributes); + $emailAttr = $this->config['email_attribute']; + $externalId = $this->getExternalId($samlAttributes, $samlID); - // Get the ids for the roles from the names - $samlGroupsAsRoles = $this->matchSamlGroupsToSystemsRoles($userSamlGroups); + $defaultEmail = filter_var($samlID, FILTER_VALIDATE_EMAIL) ? $samlID : null; + $email = $this->getSamlResponseAttribute($samlAttributes, $emailAttr, $defaultEmail); - // Sync groups - if ($this->config['remove_from_groups']) { - $user->roles()->sync($samlGroupsAsRoles); - $this->userRepo->attachDefaultRole($user); - } else { - $user->roles()->syncWithoutDetaching($samlGroupsAsRoles); - } + return [ + 'external_id' => $externalId, + 'name' => $this->getUserDisplayName($samlAttributes, $externalId), + 'email' => $email, + 'saml_id' => $samlID, + ]; } /** - * Match an array of group names from SAML to BookStack system roles. - * Formats group names to be lower-case and hyphenated. - * @param array $groupNames - * @return \Illuminate\Support\Collection + * Get the groups a user is a part of from the SAML response. */ - protected function matchSamlGroupsToSystemsRoles(array $groupNames) + public function getUserGroups(array $samlAttributes): array { - foreach ($groupNames as $i => $groupName) { - $groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName))); + $groupsAttr = $this->config['group_attribute']; + $userGroups = $samlAttributes[$groupsAttr] ?? null; + + if (!is_array($userGroups)) { + $userGroups = []; } - $roles = Role::query()->where(function (Builder $query) use ($groupNames) { - $query->whereIn('name', $groupNames); - foreach ($groupNames as $groupName) { - $query->orWhere('external_auth_id', 'LIKE', '%' . $groupName . '%'); - } - })->get(); + return $userGroups; + } + + /** + * For an array of strings, return a default for an empty array, + * a string for an array with one element and the full array for + * more than one element. + */ + protected function simplifyValue(array $data, $defaultValue) + { + switch (count($data)) { + case 0: + $data = $defaultValue; + break; + case 1: + $data = $data[0]; + break; + } + return $data; + } - $matchedRoles = $roles->filter(function (Role $role) use ($groupNames) { - return $this->roleMatchesGroupNames($role, $groupNames); - }); + /** + * Get a property from an SAML response. + * Handles properties potentially being an array. + */ + protected function getSamlResponseAttribute(array $samlAttributes, string $propertyKey, $defaultValue) + { + if (isset($samlAttributes[$propertyKey])) { + return $this->simplifyValue($samlAttributes[$propertyKey], $defaultValue); + } - return $matchedRoles->pluck('id'); + return $defaultValue; } /** - * Check a role against an array of group names to see if it matches. - * Checked against role 'external_auth_id' if set otherwise the name of the role. - * @param \BookStack\Auth\Role $role - * @param array $groupNames - * @return bool + * Get the user from the database for the specified details. + * @throws UserRegistrationException */ - protected function roleMatchesGroupNames(Role $role, array $groupNames) + protected function getOrRegisterUser(array $userDetails): ?User { - if ($role->external_auth_id) { - $externalAuthIds = explode(',', strtolower($role->external_auth_id)); - foreach ($externalAuthIds as $externalAuthId) { - if (in_array(trim($externalAuthId), $groupNames)) { - return true; - } - } - return false; + $user = $this->user->newQuery() + ->where('external_auth_id', '=', $userDetails['external_id']) + ->first(); + + 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); } - $roleName = str_replace(' ', '-', trim(strtolower($role->display_name))); - return in_array($roleName, $groupNames); + return $user; } + /** + * 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'); + } + + $user = $this->getOrRegisterUser($userDetails); + if ($user === null) { + throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['external_id']]), '/login'); + } + + if ($this->shouldSyncGroups()) { + $groups = $this->getUserGroups($samlAttributes); + $this->syncWithGroups($user, $groups); + } + + auth()->login($user); + Activity::add(ActivityType::AUTH_LOGIN, "saml2; {$user->logDescriptor()}"); + return $user; + } }