X-Git-Url: http://source.bookstackapp.com/bookstack/blobdiff_plain/488325f4593ec0dd88cd8b4cfda3b9a20e9489e4..refs/pull/3210/head:/app/Auth/Access/Saml2Service.php diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index a5ca54c8d..f5d0cd7cc 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -1,72 +1,116 @@ -config = config('saml2'); - $this->userRepo = $userRepo; - $this->user = $user; - $this->enabled = config('saml2.enabled') === true; + $this->registrationService = $registrationService; + $this->loginService = $loginService; + $this->groupSyncService = $groupSyncService; } /** * Initiate a login flow. - * @throws \OneLogin\Saml2\Error + * + * @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(), + 'id' => $toolKit->getLastRequestID(), ]; } + /** + * Initiate a logout flow. + * + * @throws Error + */ + public function logout(User $user): array + { + $toolKit = $this->getToolkit(); + $returnRoute = url('/'); + + try { + $url = $toolKit->logout( + $returnRoute, + [], + $user->email, + null, + true, + Constants::NAMEID_EMAIL_ADDRESS + ); + $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 \OneLogin\Saml2\ValidationError + * @throws ValidationError * @throws JsonDebugException + * @throws UserRegistrationException */ - public function processAcsResponse(?string $requestId): ?User + public function processAcsResponse(?string $requestId, string $samlResponse): ?User { + // The SAML2 toolkit expects the response to be within the $_POST superglobal + // so we need to manually put it back there at this point. + $_POST['SAMLResponse'] = $samlResponse; $toolkit = $this->getToolkit(); $toolkit->processResponse($requestId); $errors = $toolkit->getErrors(); - if (is_null($requestId)) { - throw new SamlException(trans('errors.saml_invalid_response_id')); - } - if (!empty($errors)) { throw new Error( - 'Invalid ACS Response: '.implode(', ', $errors) + 'Invalid ACS Response: ' . implode(', ', $errors) ); } @@ -80,8 +124,46 @@ class Saml2Service extends ExternalAuthService return $this->processLoginCallback($id, $attrs); } + /** + * Process a response for the single logout service. + * + * @throws Error + */ + public function processSlsResponse(?string $requestId): ?string + { + $toolkit = $this->getToolkit(); + + // The $retrieveParametersFromServer in the call below will mean the library will take the query + // parameters, used for the response signing, from the raw $_SERVER['QUERY_STRING'] + // value so that the exact encoding format is matched when checking the signature. + // This is primarily due to ADFS encoding query params with lowercase percent encoding while + // PHP (And most other sensible providers) standardise on uppercase. + $redirect = $toolkit->processSLO(true, $requestId, true, 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 @@ -93,7 +175,7 @@ class Saml2Service extends ExternalAuthService if (!empty($errors)) { throw new Error( - 'Invalid SP metadata: '.implode(', ', $errors), + 'Invalid SP metadata: ' . implode(', ', $errors), Error::METADATA_SP_INVALID ); } @@ -103,8 +185,9 @@ class Saml2Service extends ExternalAuthService /** * Load the underlying Onelogin SAML2 toolkit. - * @throws \OneLogin\Saml2\Error - * @throws \Exception + * + * @throws Error + * @throws Exception */ protected function getToolkit(): Auth { @@ -122,6 +205,7 @@ class Saml2Service extends ExternalAuthService $spSettings = $this->loadOneloginServiceProviderDetails(); $settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides); + return new Auth($settings); } @@ -131,18 +215,18 @@ class Saml2Service extends ExternalAuthService protected function loadOneloginServiceProviderDetails(): array { $spDetails = [ - 'entityId' => url('/http/source.bookstackapp.com/saml2/metadata'), + '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') + 'url' => url('/http/source.bookstackapp.com/saml2/sls'), ], ]; return [ 'baseurl' => url('/http/source.bookstackapp.com/saml2'), - 'sp' => $spDetails + 'sp' => $spDetails, ]; } @@ -151,11 +235,11 @@ class Saml2Service extends ExternalAuthService */ protected function shouldSyncGroups(): bool { - return $this->enabled && $this->config['user_to_groups'] !== false; + return $this->config['user_to_groups'] !== false; } /** - * Calculate the display name + * Calculate the display name. */ protected function getUserDisplayName(array $samlAttributes, string $defaultValue): string { @@ -194,23 +278,22 @@ class Saml2Service extends ExternalAuthService /** * Extract the details of a user from a SAML response. - * @throws SamlException + * + * @return array{external_id: string, name: string, email: string, saml_id: string} */ - 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, - 'name' => $this->getUserDisplayName($samlAttributes, $externalId), - 'email' => $email, - 'saml_id' => $samlID, + 'name' => $this->getUserDisplayName($samlAttributes, $externalId), + 'email' => $email, + 'saml_id' => $samlID, ]; } @@ -244,6 +327,7 @@ class Saml2Service extends ExternalAuthService $data = $data[0]; break; } + return $data; } @@ -260,53 +344,14 @@ 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, - ]; - - $existingUser = $this->user->newQuery()->where('email', '=', $userDetails['email'])->first(); - if ($existingUser) { - throw new SamlException(trans('errors.saml_email_exists', ['email' => $userDetails['email']])); - } - - $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. - */ - protected function getOrRegisterUser(array $userDetails): ?User - { - $isRegisterEnabled = $this->config['auto_register'] === true; - $user = $this->user - ->where('external_auth_id', $userDetails['external_id']) - ->first(); - - if ($user === null && $isRegisterEnabled) { - $user = $this->registerUser($userDetails); - } - - 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 + * @throws StoppedAuthenticationException */ public function processLoginCallback(string $samlID, array $samlAttributes): User { @@ -315,26 +360,37 @@ class Saml2Service extends ExternalAuthService if ($this->config['dump_user_details']) { throw new JsonDebugException([ - 'attrs_from_idp' => $samlAttributes, + '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); + $user = $this->registrationService->findOrRegister( + $userDetails['name'], + $userDetails['email'], + $userDetails['external_id'] + ); + 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); + $this->groupSyncService->syncUserWithFoundGroups($user, $groups, $this->config['remove_from_groups']); } - auth()->login($user); + $this->loginService->login($user, 'saml2'); + return $user; } }