From 3c41b15be65ca59a89f1588522d2d58f775e71cc Mon Sep 17 00:00:00 2001 From: Daniel Seiler Date: Mon, 5 Aug 2019 20:06:39 +0200 Subject: [PATCH 01/12] Initial work on SAML integration --- app/Config/app.php | 1 + app/Config/saml2_settings.php | 230 ++++++++++++++++++ app/Http/Controllers/Auth/LoginController.php | 7 +- composer.json | 3 +- composer.lock | 152 +++++++++++- resources/views/auth/login.blade.php | 12 +- 6 files changed, 400 insertions(+), 5 deletions(-) create mode 100644 app/Config/saml2_settings.php diff --git a/app/Config/app.php b/app/Config/app.php index 88052e94c..23025a6c4 100755 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -106,6 +106,7 @@ return [ Intervention\Image\ImageServiceProvider::class, Barryvdh\DomPDF\ServiceProvider::class, Barryvdh\Snappy\ServiceProvider::class, + Aacotroneo\Saml2\Saml2ServiceProvider::class, // BookStack replacement service providers (Extends Laravel) diff --git a/app/Config/saml2_settings.php b/app/Config/saml2_settings.php new file mode 100644 index 000000000..a6d7a0204 --- /dev/null +++ b/app/Config/saml2_settings.php @@ -0,0 +1,230 @@ + env("SAML2_ENABLED", false), + + /** + * If 'useRoutes' is set to true, the package defines five new routes: + * + * Method | URI | Name + * -------|--------------------------|------------------ + * POST | {routesPrefix}/acs | saml_acs + * GET | {routesPrefix}/login | saml_login + * GET | {routesPrefix}/logout | saml_logout + * GET | {routesPrefix}/metadata | saml_metadata + * GET | {routesPrefix}/sls | saml_sls + */ + 'useRoutes' => true, + + 'routesPrefix' => '/saml2', + + /** + * which middleware group to use for the saml routes + * Laravel 5.2 will need a group which includes StartSession + */ + 'routesMiddleware' => [], + + /** + * Indicates how the parameters will be + * retrieved from the sls request for signature validation + */ + 'retrieveParametersFromServer' => false, + + /** + * Where to redirect after logout + */ + 'logoutRoute' => '/', + + /** + * Where to redirect after login if no other option was provided + */ + 'loginRoute' => '/', + + + /** + * Where to redirect after login if no other option was provided + */ + 'errorRoute' => '/', + + + + + /***** + * One Login Settings + */ + + + + // If 'strict' is True, then the PHP Toolkit will reject unsigned + // or unencrypted messages if it expects them signed or encrypted + // Also will reject the messages if not strictly follow the SAML + // standard: Destination, NameId, Conditions ... are validated too. + 'strict' => true, //@todo: make this depend on laravel config + + // Enable debug mode (to print errors) + 'debug' => env('APP_DEBUG', false), + + // If 'proxyVars' is True, then the Saml lib will trust proxy headers + // e.g X-Forwarded-Proto / HTTP_X_FORWARDED_PROTO. This is useful if + // your application is running behind a load balancer which terminates + // SSL. + 'proxyVars' => false, + + // Service Provider Data that we are deploying + 'sp' => array( + + // Specifies constraints on the name identifier to be used to + // represent the requested subject. + // Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported + 'NameIDFormat' => 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', + + // Usually x509cert and privateKey of the SP are provided by files placed at + // the certs folder. But we can also provide them with the following parameters + 'x509cert' => env('SAML2_SP_x509',''), + 'privateKey' => env('SAML2_SP_PRIVATEKEY',''), + + // Identifier (URI) of the SP entity. + // Leave blank to use the 'saml_metadata' route. + 'entityId' => env('SAML2_SP_ENTITYID',''), + + // Specifies info about where and how the message MUST be + // returned to the requester, in this case our SP. + 'assertionConsumerService' => array( + // URL Location where the from the IdP will be returned, + // using HTTP-POST binding. + // Leave blank to use the 'saml_acs' route + 'url' => '', + ), + // Specifies info about where and how the message MUST be + // returned to the requester, in this case our SP. + // Remove this part to not include any URL Location in the metadata. + 'singleLogoutService' => array( + // URL Location where the from the IdP will be returned, + // using HTTP-Redirect binding. + // Leave blank to use the 'saml_sls' route + 'url' => '', + ), + ), + + // Identity Provider Data that we want connect with our SP + 'idp' => array( + // Identifier of the IdP entity (must be a URI) + 'entityId' => env('SAML2_IDP_ENTITYID', $idp_host . '/saml2/idp/metadata.php'), + // SSO endpoint info of the IdP. (Authentication Request protocol) + 'singleSignOnService' => array( + // URL Target of the IdP where the SP will send the Authentication Request Message, + // using HTTP-Redirect binding. + 'url' => env('SAML2_IDP_SSO', $idp_host . '/saml2/idp/SSOService.php'), + ), + // SLO endpoint info of the IdP. + 'singleLogoutService' => array( + // URL Location of the IdP where the SP will send the SLO Request, + // using HTTP-Redirect binding. + 'url' => env('SAML2_IDP_SLO', $idp_host . '/saml2/idp/SingleLogoutService.php'), + ), + // Public x509 certificate of the IdP + 'x509cert' => env('SAML2_IDP_x509', 'MIID/TCCAuWgAwIBAgIJAI4R3WyjjmB1MA0GCSqGSIb3DQEBCwUAMIGUMQswCQYDVQQGEwJBUjEVMBMGA1UECAwMQnVlbm9zIEFpcmVzMRUwEwYDVQQHDAxCdWVub3MgQWlyZXMxDDAKBgNVBAoMA1NJVTERMA8GA1UECwwIU2lzdGVtYXMxFDASBgNVBAMMC09yZy5TaXUuQ29tMSAwHgYJKoZIhvcNAQkBFhFhZG1pbmlAc2l1LmVkdS5hcjAeFw0xNDEyMDExNDM2MjVaFw0yNDExMzAxNDM2MjVaMIGUMQswCQYDVQQGEwJBUjEVMBMGA1UECAwMQnVlbm9zIEFpcmVzMRUwEwYDVQQHDAxCdWVub3MgQWlyZXMxDDAKBgNVBAoMA1NJVTERMA8GA1UECwwIU2lzdGVtYXMxFDASBgNVBAMMC09yZy5TaXUuQ29tMSAwHgYJKoZIhvcNAQkBFhFhZG1pbmlAc2l1LmVkdS5hcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbzW/EpEv+qqZzfT1Buwjg9nnNNVrxkCfuR9fQiQw2tSouS5X37W5h7RmchRt54wsm046PDKtbSz1NpZT2GkmHN37yALW2lY7MyVUC7itv9vDAUsFr0EfKIdCKgxCKjrzkZ5ImbNvjxf7eA77PPGJnQ/UwXY7W+cvLkirp0K5uWpDk+nac5W0JXOCFR1BpPUJRbz2jFIEHyChRt7nsJZH6ejzNqK9lABEC76htNy1Ll/D3tUoPaqo8VlKW3N3MZE0DB9O7g65DmZIIlFqkaMH3ALd8adodJtOvqfDU/A6SxuwMfwDYPjoucykGDu1etRZ7dF2gd+W+1Pn7yizPT1q8CAwEAAaNQME4wHQYDVR0OBBYEFPsn8tUHN8XXf23ig5Qro3beP8BuMB8GA1UdIwQYMBaAFPsn8tUHN8XXf23ig5Qro3beP8BuMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGu60odWFiK+DkQekozGnlpNBQz5lQ/bwmOWdktnQj6HYXu43e7sh9oZWArLYHEOyMUekKQAxOK51vbTHzzw66BZU91/nqvaOBfkJyZKGfluHbD0/hfOl/D5kONqI9kyTu4wkLQcYGyuIi75CJs15uA03FSuULQdY/Liv+czS/XYDyvtSLnu43VuAQWN321PQNhuGueIaLJANb2C5qq5ilTBUw6PxY9Z+vtMjAjTJGKEkE/tQs7CvzLPKXX3KTD9lIILmX5yUC3dLgjVKi1KGDqNApYGOMtjr5eoxPQrqDBmyx3flcy0dQTdLXud3UjWVW3N0PYgJtw5yBsS74QTGD4='), + /* + * Instead of use the whole x509cert you can use a fingerprint + * (openssl x509 -noout -fingerprint -in "idp.crt" to generate it) + */ + // 'certFingerprint' => '', + ), + + + + /*** + * + * OneLogin advanced settings + * + * + */ + // Security settings + 'security' => array( + + /** signatures and encryptions offered */ + + // Indicates that the nameID of the sent by this SP + // will be encrypted. + 'nameIdEncrypted' => false, + + // Indicates whether the messages sent by this SP + // will be signed. [The Metadata of the SP will offer this info] + 'authnRequestsSigned' => false, + + // Indicates whether the messages sent by this SP + // will be signed. + 'logoutRequestSigned' => false, + + // Indicates whether the messages sent by this SP + // will be signed. + 'logoutResponseSigned' => false, + + /* Sign the Metadata + False || True (use sp certs) || array ( + keyFileName => 'metadata.key', + certFileName => 'metadata.crt' + ) + */ + 'signMetadata' => false, + + + /** signatures and encryptions required **/ + + // Indicates a requirement for the , and + // elements received by this SP to be signed. + 'wantMessagesSigned' => false, + + // Indicates a requirement for the elements received by + // this SP to be signed. [The Metadata of the SP will offer this info] + 'wantAssertionsSigned' => false, + + // Indicates a requirement for the NameID received by + // this SP to be encrypted. + 'wantNameIdEncrypted' => false, + + // Authentication context. + // Set to false and no AuthContext will be sent in the AuthNRequest, + // Set true or don't present thi parameter and you will get an AuthContext 'exact' 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport' + // Set an array with the possible auth context values: array ('urn:oasis:names:tc:SAML:2.0:ac:classes:Password', 'urn:oasis:names:tc:SAML:2.0:ac:classes:X509'), + 'requestedAuthnContext' => true, + ), + + // Contact information template, it is recommended to suply a technical and support contacts + 'contactPerson' => array( + 'technical' => array( + 'givenName' => 'name', + 'emailAddress' => 'no@reply.com' + ), + 'support' => array( + 'givenName' => 'Support', + 'emailAddress' => 'no@reply.com' + ), + ), + + // Organization information template, the info in en_US lang is recomended, add more if required + 'organization' => array( + 'en-US' => array( + 'name' => 'Name', + 'displayname' => 'Display Name', + 'url' => 'http://url' + ), + ), + +/* Interoperable SAML 2.0 Web Browser SSO Profile [saml2int] http://saml2int.org/profile/current + + 'authnRequestsSigned' => false, // SP SHOULD NOT sign the , + // MUST NOT assume that the IdP validates the sign + 'wantAssertionsSigned' => true, + 'wantAssertionsEncrypted' => true, // MUST be enabled if SSL/HTTPs is disabled + 'wantNameIdEncrypted' => false, +*/ + +); diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index c739fd9a3..9c5467e25 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -118,6 +118,7 @@ class LoginController extends Controller { $socialDrivers = $this->socialAuthService->getActiveDrivers(); $authMethod = config('auth.method'); + $samlEnabled = config('saml2_settings.enabled') == true; if ($request->has('email')) { session()->flashInput([ @@ -126,7 +127,11 @@ class LoginController extends Controller ]); } - return view('auth.login', ['socialDrivers' => $socialDrivers, 'authMethod' => $authMethod]); + return view('auth.login', [ + 'socialDrivers' => $socialDrivers, + 'authMethod' => $authMethod, + 'samlEnabled' => $samlEnabled, + ]); } /** diff --git a/composer.json b/composer.json index 61bb8509e..457ce5093 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,8 @@ "socialiteproviders/gitlab": "^3.0", "socialiteproviders/twitch": "^3.0", "socialiteproviders/discord": "^2.0", - "doctrine/dbal": "^2.5" + "doctrine/dbal": "^2.5", + "aacotroneo/laravel-saml2": "^1.0" }, "require-dev": { "filp/whoops": "~2.0", diff --git a/composer.lock b/composer.lock index d7734ce1a..d35594642 100644 --- a/composer.lock +++ b/composer.lock @@ -1,11 +1,70 @@ { "_readme": [ "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0946a07729a7a1bfef9bac185a870afd", + "content-hash": "26a2c3ad0409c970f4f0c9b6dad49322", "packages": [ + { + "name": "aacotroneo/laravel-saml2", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/aacotroneo/laravel-saml2.git", + "reference": "5045701a07bcd7600a17c92971368669870f546a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aacotroneo/laravel-saml2/zipball/5045701a07bcd7600a17c92971368669870f546a", + "reference": "5045701a07bcd7600a17c92971368669870f546a", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "illuminate/support": ">=5.0.0", + "onelogin/php-saml": "^3.0.0", + "php": ">=5.4.0" + }, + "require-dev": { + "mockery/mockery": "0.9.*" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Aacotroneo\\Saml2\\Saml2ServiceProvider" + ], + "aliases": { + "Saml2": "Aacotroneo\\Saml2\\Facades\\Saml2Auth" + } + } + }, + "autoload": { + "psr-0": { + "Aacotroneo\\Saml2\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "aacotroneo", + "email": "aacotroneo@gmail.com" + } + ], + "description": "A Laravel package for Saml2 integration as a SP (service provider) based on OneLogin toolkit, which is much lightweight than simplesamlphp", + "homepage": "https://github.com/aacotroneo/laravel-saml2", + "keywords": [ + "SAML2", + "laravel", + "onelogin", + "saml" + ], + "time": "2018-11-08T14:03:58+00:00" + }, { "name": "aws/aws-sdk-php", "version": "3.86.2", @@ -1947,6 +2006,56 @@ ], "time": "2018-12-28T10:07:33+00:00" }, + { + "name": "onelogin/php-saml", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/onelogin/php-saml.git", + "reference": "845a6ce39e839ed9e687f80bffb02ffde16a70d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/onelogin/php-saml/zipball/845a6ce39e839ed9e687f80bffb02ffde16a70d0", + "reference": "845a6ce39e839ed9e687f80bffb02ffde16a70d0", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "robrichards/xmlseclibs": ">=3.0.3" + }, + "require-dev": { + "pdepend/pdepend": "^2.5.0", + "php-coveralls/php-coveralls": "^1.0.2 || ^2.0", + "phploc/phploc": "^2.1 || ^3.0 || ^4.0", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5 || ^7.1", + "sebastian/phpcpd": "^2.0 || ^3.0 || ^4.0", + "squizlabs/php_codesniffer": "^3.1.1" + }, + "suggest": { + "ext-curl": "Install curl lib to be able to use the IdPMetadataParser for parsing remote XMLs", + "ext-gettext": "Install gettext and php5-gettext libs to handle translations", + "ext-openssl": "Install openssl lib in order to handle with x509 certs (require to support sign and encryption)" + }, + "type": "library", + "autoload": { + "psr-4": { + "OneLogin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "OneLogin PHP SAML Toolkit", + "homepage": "https://developers.onelogin.com/saml/php", + "keywords": [ + "SAML2", + "onelogin", + "saml" + ], + "time": "2019-06-25T10:28:20+00:00" + }, { "name": "paragonie/random_compat", "version": "v9.99.99", @@ -2435,6 +2544,44 @@ ], "time": "2018-07-19T23:38:55+00:00" }, + { + "name": "robrichards/xmlseclibs", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/robrichards/xmlseclibs.git", + "reference": "406c68ac9124db033d079284b719958b829cb830" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/406c68ac9124db033d079284b719958b829cb830", + "reference": "406c68ac9124db033d079284b719958b829cb830", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">= 5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "RobRichards\\XMLSecLibs\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "A PHP library for XML Security", + "homepage": "https://github.com/robrichards/xmlseclibs", + "keywords": [ + "security", + "signature", + "xml", + "xmldsig" + ], + "time": "2018-11-15T11:59:02+00:00" + }, { "name": "sabberworm/php-css-parser", "version": "8.1.0", @@ -5416,6 +5563,7 @@ "mock", "xunit" ], + "abandoned": true, "time": "2018-08-09T05:50:03+00:00" }, { diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 76aa3a6e9..72d8d00aa 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -46,6 +46,16 @@ @endforeach @endif + @if($samlEnabled) +
+ + @endif + @if(setting('registration-enabled', false))

@@ -55,4 +65,4 @@
-@stop \ No newline at end of file +@stop From bda0082461c4609b7333c8e3d9373f8d68da3da7 Mon Sep 17 00:00:00 2001 From: Daniel Seiler Date: Tue, 6 Aug 2019 23:42:46 +0200 Subject: [PATCH 02/12] Add login and automatic registration; Prepare Group sync --- app/Auth/Access/Saml2Service.php | 241 ++++++++++++++++++ app/Config/saml2_settings.php | 15 +- app/Config/services.php | 25 +- app/Exceptions/SamlException.php | 6 + app/Http/Controllers/Auth/LoginController.php | 2 +- app/Http/Kernel.php | 5 + app/Listeners/Saml2LoginEventListener.php | 42 +++ app/Providers/EventServiceProvider.php | 4 + resources/views/settings/roles/form.blade.php | 4 +- resources/views/users/form.blade.php | 4 +- 10 files changed, 334 insertions(+), 14 deletions(-) create mode 100644 app/Auth/Access/Saml2Service.php create mode 100644 app/Exceptions/SamlException.php create mode 100644 app/Listeners/Saml2LoginEventListener.php diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php new file mode 100644 index 000000000..0b6cbe805 --- /dev/null +++ b/app/Auth/Access/Saml2Service.php @@ -0,0 +1,241 @@ +config = config('services.saml'); + $this->userRepo = $userRepo; + $this->user = $user; + $this->enabled = config('saml2_settings.enabled') === true; + } + + /** + * Check if groups should be synced. + * @return bool + */ + public function shouldSyncGroups() + { + return $this->enabled && $this->config['user_to_groups'] !== false; + } + + /** + * Extract the details of a user from a SAML response. + * @param $samlID + * @param $samlAttributes + * @return array + */ + public function getUserDetails($samlID, $samlAttributes) + { + $emailAttr = $this->config['email_attribute']; + $displayNameAttr = $this->config['display_name_attribute']; + $userNameAttr = $this->config['user_name_attribute']; + + $email = $this->getSamlResponseAttribute($samlAttributes, $emailAttr, null); + + if ($userNameAttr === null) { + $userName = $samlID; + } else { + $userName = $this->getSamlResponseAttribute($samlAttributes, $userNameAttr, $samlID); + } + + $displayName = []; + foreach ($displayNameAttr as $dnAttr) { + $dnComponent = $this->getSamlResponseAttribute($samlAttributes, $dnAttr, null); + if ($dnComponent !== null) { + $displayName[] = $dnComponent; + } + } + + if (count($displayName) == 0) { + $displayName = $userName; + } else { + $displayName = implode(' ', $displayName); + } + + return [ + 'uid' => $userName, + 'name' => $displayName, + 'dn' => $samlID, + 'email' => $email, + ]; + } + + /** + * Get the groups a user is a part of from the SAML response. + * @param array $samlAttributes + * @return array + */ + public function getUserGroups($samlAttributes) + { + $groupsAttr = $this->config['group_attribute']; + $userGroups = $samlAttributes[$groupsAttr]; + + if (!is_array($userGroups)) { + $userGroups = []; + } + + return $userGroups; + } + + /** + * Get a property from an SAML response. + * Handles properties potentially being an array. + * @param array $userDetails + * @param string $propertyKey + * @param $defaultValue + * @return mixed + */ + protected function getSamlResponseAttribute(array $samlAttributes, string $propertyKey, $defaultValue) + { + 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; + } + } + + return $defaultValue; + } + + protected function registerUser($userDetails) { + + // 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, + ]; + + $user = $this->user->forceCreate($userData); + $this->userRepo->attachDefaultRole($user); + $this->userRepo->downloadAndAssignUserAvatar($user); + return $user; + } + + public function processLoginCallback($samlID, $samlAttributes) { + + $userDetails = $this->getUserDetails($samlID, $samlAttributes); + $user = $this->user + ->where('external_auth_id', $userDetails['uid']) + ->first(); + + $isLoggedIn = auth()->check(); + + if (!$isLoggedIn) { + if ($user === null && config('services.saml.auto_register') === true) { + $user = $this->registerUser($userDetails); + } + + if ($user !== null) { + auth()->login($user); + } + } + + return $user; + } + + /** + * Sync the SAML groups to the user roles for the current user + * @param \BookStack\Auth\User $user + * @param array $samlAttributes + */ + public function syncGroups(User $user, array $samlAttributes) + { + $userSamlGroups = $this->getUserGroups($samlAttributes); + + // Get the ids for the roles from the names + $samlGroupsAsRoles = $this->matchSamlGroupsToSystemsRoles($userSamlGroups); + + // Sync groups + if ($this->config['remove_from_groups']) { + $user->roles()->sync($samlGroupsAsRoles); + $this->userRepo->attachDefaultRole($user); + } else { + $user->roles()->syncWithoutDetaching($samlGroupsAsRoles); + } + } + + /** + * 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 + */ + protected function matchSamlGroupsToSystemsRoles(array $groupNames) + { + foreach ($groupNames as $i => $groupName) { + $groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName))); + } + + $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(); + + $matchedRoles = $roles->filter(function (Role $role) use ($groupNames) { + return $this->roleMatchesGroupNames($role, $groupNames); + }); + + return $matchedRoles->pluck('id'); + } + + /** + * 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 + */ + protected function roleMatchesGroupNames(Role $role, array $groupNames) + { + 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; + } + + $roleName = str_replace(' ', '-', trim(strtolower($role->display_name))); + return in_array($roleName, $groupNames); + } + +} diff --git a/app/Config/saml2_settings.php b/app/Config/saml2_settings.php index a6d7a0204..015763b46 100644 --- a/app/Config/saml2_settings.php +++ b/app/Config/saml2_settings.php @@ -29,7 +29,7 @@ return $settings = array( * which middleware group to use for the saml routes * Laravel 5.2 will need a group which includes StartSession */ - 'routesMiddleware' => [], + 'routesMiddleware' => ['saml'], /** * Indicates how the parameters will be @@ -101,6 +101,8 @@ return $settings = array( // using HTTP-POST binding. // Leave blank to use the 'saml_acs' route 'url' => '', + + 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', ), // Specifies info about where and how the message MUST be // returned to the requester, in this case our SP. @@ -138,7 +140,16 @@ return $settings = array( // 'certFingerprint' => '', ), - + /*** + * OneLogin compression settings + * + */ + 'compress' => array( + /** Whether requests should be GZ encoded */ + 'requests' => true, + /** Whether responses should be GZ compressed */ + 'responses' => true, + ), /*** * diff --git a/app/Config/services.php b/app/Config/services.php index 97cb71ddc..9cd647e6d 100644 --- a/app/Config/services.php +++ b/app/Config/services.php @@ -98,8 +98,8 @@ return [ 'okta' => [ 'client_id' => env('OKTA_APP_ID'), 'client_secret' => env('OKTA_APP_SECRET'), - 'redirect' => env('APP_URL') . '/login/service/okta/callback', - 'base_url' => env('OKTA_BASE_URL'), + 'redirect' => env('APP_URL') . '/login/service/okta/callback', + 'base_url' => env('OKTA_BASE_URL'), 'name' => 'Okta', 'auto_register' => env('OKTA_AUTO_REGISTER', false), 'auto_confirm' => env('OKTA_AUTO_CONFIRM_EMAIL', false), @@ -143,10 +143,21 @@ return [ 'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'), 'display_name_attribute' => env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn'), 'follow_referrals' => env('LDAP_FOLLOW_REFERRALS', false), - 'user_to_groups' => env('LDAP_USER_TO_GROUPS',false), - 'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'), - 'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS',false), - 'tls_insecure' => env('LDAP_TLS_INSECURE', false), - ] + 'user_to_groups' => env('LDAP_USER_TO_GROUPS',false), + 'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'), + 'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS',false), + 'tls_insecure' => env('LDAP_TLS_INSECURE', false), + ], + + 'saml' => [ + 'enabled' => env('SAML2_ENABLED', false), + 'auto_register' => env('SAML_AUTO_REGISTER', false), + 'email_attribute' => env('SAML_EMAIL_ATTRIBUTE', 'email'), + 'display_name_attribute' => explode('|', env('SAML_DISPLAY_NAME_ATTRIBUTE', 'username')), + 'user_name_attribute' => env('SAML_USER_NAME_ATTRIBUTE', null), + 'group_attribute' => env('SAML_GROUP_ATTRIBUTE', 'group'), + 'user_to_groups' => env('SAML_USER_TO_GROUPS', false), + 'id_is_user_name' => env('SAML_ID_IS_USER_NAME', true), + ] ]; diff --git a/app/Exceptions/SamlException.php b/app/Exceptions/SamlException.php new file mode 100644 index 000000000..f9668919c --- /dev/null +++ b/app/Exceptions/SamlException.php @@ -0,0 +1,6 @@ +socialAuthService->getActiveDrivers(); $authMethod = config('auth.method'); - $samlEnabled = config('saml2_settings.enabled') == true; + $samlEnabled = config('services.saml.enabled') == true; if ($request->has('email')) { session()->flashInput([ diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index cd894de95..7794f3401 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -37,6 +37,11 @@ class Kernel extends HttpKernel 'throttle:60,1', 'bindings', ], + 'saml' => [ + \BookStack\Http\Middleware\EncryptCookies::class, + \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, + \Illuminate\Session\Middleware\StartSession::class, + ], ]; /** diff --git a/app/Listeners/Saml2LoginEventListener.php b/app/Listeners/Saml2LoginEventListener.php new file mode 100644 index 000000000..74c4d6f27 --- /dev/null +++ b/app/Listeners/Saml2LoginEventListener.php @@ -0,0 +1,42 @@ +saml = $saml; + } + + /** + * Handle the event. + * + * @param Saml2LoginEvent $event + * @return void + */ + public function handle(Saml2LoginEvent $event) + { + $messageId = $event->getSaml2Auth()->getLastMessageId(); + // TODO: Add your own code preventing reuse of a $messageId to stop replay attacks + + $samlUser = $event->getSaml2User(); + + $attrs = $samlUser->getAttributes(); + $id = $samlUser->getUserId(); + //$assertion = $user->getRawSamlAssertion() + + $user = $this->saml->processLoginCallback($id, $attrs); + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index a826185d8..50436916a 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -4,6 +4,7 @@ namespace BookStack\Providers; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use SocialiteProviders\Manager\SocialiteWasCalled; +use Aacotroneo\Saml2\Events\Saml2LoginEvent; class EventServiceProvider extends ServiceProvider { @@ -21,6 +22,9 @@ class EventServiceProvider extends ServiceProvider 'SocialiteProviders\Twitch\TwitchExtendSocialite@handle', 'SocialiteProviders\Discord\DiscordExtendSocialite@handle', ], + Saml2LoginEvent::class => [ + 'BookStack\Listeners\Saml2LoginEventListener@handle', + ] ]; /** diff --git a/resources/views/settings/roles/form.blade.php b/resources/views/settings/roles/form.blade.php index 68b841e03..d7c1fc47c 100644 --- a/resources/views/settings/roles/form.blade.php +++ b/resources/views/settings/roles/form.blade.php @@ -19,7 +19,7 @@ @include('form.text', ['name' => 'description']) - @if(config('auth.method') === 'ldap') + @if(config('auth.method') === 'ldap' || config('services.saml.enabled') === true)
@include('form.text', ['name' => 'external_auth_id']) @@ -254,4 +254,4 @@ {{ trans('settings.role_users_none') }}

@endif -
\ No newline at end of file + diff --git a/resources/views/users/form.blade.php b/resources/views/users/form.blade.php index 96beb7b2f..7a3d44935 100644 --- a/resources/views/users/form.blade.php +++ b/resources/views/users/form.blade.php @@ -25,7 +25,7 @@ -@if($authMethod === 'ldap' && userCan('users-manage')) +@if(($authMethod === 'ldap' || config('services.saml.enabled') === true) && userCan('users-manage'))
@@ -67,4 +67,4 @@
-@endif \ No newline at end of file +@endif From 03dbe32f9926b53c1a0c35534e57f526c5d2bc2b Mon Sep 17 00:00:00 2001 From: Daniel Seiler Date: Wed, 7 Aug 2019 12:07:21 +0200 Subject: [PATCH 03/12] Refactor for codestyle --- app/Auth/Access/ExternalAuthService.php | 75 ++++++++++ app/Auth/Access/LdapService.php | 64 +------- app/Auth/Access/Saml2Service.php | 189 ++++++++++-------------- 3 files changed, 157 insertions(+), 171 deletions(-) create mode 100644 app/Auth/Access/ExternalAuthService.php diff --git a/app/Auth/Access/ExternalAuthService.php b/app/Auth/Access/ExternalAuthService.php new file mode 100644 index 000000000..b1c036018 --- /dev/null +++ b/app/Auth/Access/ExternalAuthService.php @@ -0,0 +1,75 @@ +external_auth_id) { + $externalAuthIds = explode(',', strtolower($role->external_auth_id)); + foreach ($externalAuthIds as $externalAuthId) { + if (in_array(trim($externalAuthId), $groupNames)) { + return true; + } + } + return false; + } + + $roleName = str_replace(' ', '-', trim(strtolower($role->display_name))); + return in_array($roleName, $groupNames); + } + + /** + * Match an array of group names to BookStack system roles. + * Formats group names to be lower-case and hyphenated. + * @param array $groupNames + * @return \Illuminate\Support\Collection + */ + protected function matchGroupsToSystemsRoles(array $groupNames) + { + foreach ($groupNames as $i => $groupName) { + $groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName))); + } + + $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(); + + $matchedRoles = $roles->filter(function (Role $role) use ($groupNames) { + return $this->roleMatchesGroupNames($role, $groupNames); + }); + + return $matchedRoles->pluck('id'); + } + + /** + * Sync the groups to the user roles for the current user + * @param \BookStack\Auth\User $user + * @param array $samlAttributes + */ + public function syncWithGroups(User $user, array $userGroups) + { + // Get the ids for the roles from the names + $samlGroupsAsRoles = $this->matchGroupsToSystemsRoles($userSamlGroups); + + // Sync groups + if ($this->config['remove_from_groups']) { + $user->roles()->sync($samlGroupsAsRoles); + $this->userRepo->attachDefaultRole($user); + } else { + $user->roles()->syncWithoutDetaching($samlGroupsAsRoles); + } + } +} diff --git a/app/Auth/Access/LdapService.php b/app/Auth/Access/LdapService.php index c7415e1f7..3111ea9fa 100644 --- a/app/Auth/Access/LdapService.php +++ b/app/Auth/Access/LdapService.php @@ -1,7 +1,6 @@ getUserGroups($username); - - // Get the ids for the roles from the names - $ldapGroupsAsRoles = $this->matchLdapGroupsToSystemsRoles($userLdapGroups); - - // Sync groups - if ($this->config['remove_from_groups']) { - $user->roles()->sync($ldapGroupsAsRoles); - $this->userRepo->attachDefaultRole($user); - } else { - $user->roles()->syncWithoutDetaching($ldapGroupsAsRoles); - } - } - - /** - * Match an array of group names from LDAP to BookStack system roles. - * Formats LDAP group names to be lower-case and hyphenated. - * @param array $groupNames - * @return \Illuminate\Support\Collection - */ - protected function matchLdapGroupsToSystemsRoles(array $groupNames) - { - foreach ($groupNames as $i => $groupName) { - $groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName))); - } - - $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(); - - $matchedRoles = $roles->filter(function (Role $role) use ($groupNames) { - return $this->roleMatchesGroupNames($role, $groupNames); - }); - - return $matchedRoles->pluck('id'); - } - - /** - * 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 - */ - protected function roleMatchesGroupNames(Role $role, array $groupNames) - { - 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; - } - - $roleName = str_replace(' ', '-', trim(strtolower($role->display_name))); - return in_array($roleName, $groupNames); + $this->syncWithGroups($user, $userLdapGroups); } } diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index 0b6cbe805..95049efd2 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -1,13 +1,11 @@ enabled && $this->config['user_to_groups'] !== false; } - /** - * Extract the details of a user from a SAML response. - * @param $samlID - * @param $samlAttributes - * @return array + /** Calculate the display name + * @param array $samlAttributes + * @param string $defaultValue + * @return string */ - public function getUserDetails($samlID, $samlAttributes) + protected function getUserDisplayName(array $samlAttributes, string $defaultValue) { - $emailAttr = $this->config['email_attribute']; $displayNameAttr = $this->config['display_name_attribute']; - $userNameAttr = $this->config['user_name_attribute']; - - $email = $this->getSamlResponseAttribute($samlAttributes, $emailAttr, null); - - if ($userNameAttr === null) { - $userName = $samlID; - } else { - $userName = $this->getSamlResponseAttribute($samlAttributes, $userNameAttr, $samlID); - } $displayName = []; foreach ($displayNameAttr as $dnAttr) { @@ -73,16 +60,43 @@ class Saml2Service } if (count($displayName) == 0) { - $displayName = $userName; + $displayName = $defaultValue; } else { $displayName = implode(' ', $displayName); } + return $displayName; + } + + protected function getUserName(array $samlAttributes, string $defaultValue) + { + $userNameAttr = $this->config['user_name_attribute']; + + if ($userNameAttr === null) { + $userName = $defaultValue; + } else { + $userName = $this->getSamlResponseAttribute($samlAttributes, $userNameAttr, $defaultValue); + } + + return $userName; + } + + /** + * Extract the details of a user from a SAML response. + * @param $samlID + * @param $samlAttributes + * @return array + */ + public function getUserDetails($samlID, $samlAttributes) + { + $emailAttr = $this->config['email_attribute']; + $userName = $this->getUserName($samlAttributes, $samlID); + return [ 'uid' => $userName, - 'name' => $displayName, + 'name' => $this->getUserDisplayName($samlAttributes, $userName), 'dn' => $samlID, - 'email' => $email, + 'email' => $this->getSamlResponseAttribute($samlAttributes, $emailAttr, null), ]; } @@ -115,22 +129,28 @@ class Saml2Service { 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; + if (is_array($data)) { + if (count($data) == 0) { + $data = $defaultValue; + } else if (count($data) == 1) { + $data = $data[0]; + } } + } else { + $data = $defaultValue; } - return $defaultValue; + return $data; } - protected function registerUser($userDetails) { - + /** + * Register a user that is authenticated but not + * already registered. + * @param array $userDetails + * @return User + */ + protected function registerUser($userDetails) + { // Create an array of the user data to create a new user instance $userData = [ 'name' => $userDetails['name'], @@ -146,96 +166,47 @@ class Saml2Service return $user; } - public function processLoginCallback($samlID, $samlAttributes) { - - $userDetails = $this->getUserDetails($samlID, $samlAttributes); + /** + * Get the user from the database for the specified details. + * @param array $userDetails + * @return User|null + */ + protected function getOrRegisterUser($userDetails) + { + $isRegisterEnabled = config('services.saml.auto_register') === true; $user = $this->user - ->where('external_auth_id', $userDetails['uid']) - ->first(); + ->where('external_auth_id', $userDetails['uid']) + ->first(); - $isLoggedIn = auth()->check(); - - if (!$isLoggedIn) { - if ($user === null && config('services.saml.auto_register') === true) { - $user = $this->registerUser($userDetails); - } - - if ($user !== null) { - auth()->login($user); - } + if ($user === null && $isRegisterEnabled) { + $user = $this->registerUser($userDetails); } return $user; } /** - * Sync the SAML groups to the user roles for the current user - * @param \BookStack\Auth\User $user - * @param array $samlAttributes + * Process the SAML response for a user. Login the user when + * they exist, optionally registering them automatically. + * @param string $samlID + * @param array $samlAttributes */ - public function syncGroups(User $user, array $samlAttributes) + public function processLoginCallback($samlID, $samlAttributes) { - $userSamlGroups = $this->getUserGroups($samlAttributes); + $userDetails = $this->getUserDetails($samlID, $samlAttributes); + $isLoggedIn = auth()->check(); - // Get the ids for the roles from the names - $samlGroupsAsRoles = $this->matchSamlGroupsToSystemsRoles($userSamlGroups); - - // Sync groups - if ($this->config['remove_from_groups']) { - $user->roles()->sync($samlGroupsAsRoles); - $this->userRepo->attachDefaultRole($user); + if ($isLoggedIn) { + logger()->error("Already logged in"); } else { - $user->roles()->syncWithoutDetaching($samlGroupsAsRoles); - } - } - - /** - * 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 - */ - protected function matchSamlGroupsToSystemsRoles(array $groupNames) - { - foreach ($groupNames as $i => $groupName) { - $groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName))); - } - - $roles = Role::query()->where(function (Builder $query) use ($groupNames) { - $query->whereIn('name', $groupNames); - foreach ($groupNames as $groupName) { - $query->orWhere('external_auth_id', 'LIKE', '%' . $groupName . '%'); + $user = $this->getOrRegisterUser($userDetails); + if ($user === null) { + logger()->error("User does not exist"); + } else { + auth()->login($user); } - })->get(); - - $matchedRoles = $roles->filter(function (Role $role) use ($groupNames) { - return $this->roleMatchesGroupNames($role, $groupNames); - }); - - return $matchedRoles->pluck('id'); - } - - /** - * 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 - */ - protected function roleMatchesGroupNames(Role $role, array $groupNames) - { - 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; } - $roleName = str_replace(' ', '-', trim(strtolower($role->display_name))); - return in_array($roleName, $groupNames); + return $user; } - } From 8e723f10dc3db49df9dc66ea5a90e3153eda54e8 Mon Sep 17 00:00:00 2001 From: Daniel Seiler Date: Wed, 7 Aug 2019 15:31:10 +0200 Subject: [PATCH 04/12] Add error messages, fix LDAP error --- app/Auth/Access/ExternalAuthService.php | 9 +++--- app/Auth/Access/LdapService.php | 1 - app/Auth/Access/Saml2Service.php | 41 +++++++++++++++++-------- app/Config/services.php | 2 ++ app/Exceptions/SamlException.php | 2 +- resources/lang/de/errors.php | 2 ++ resources/lang/de_informal/errors.php | 1 + resources/lang/en/errors.php | 2 ++ resources/views/auth/login.blade.php | 2 +- 9 files changed, 42 insertions(+), 20 deletions(-) diff --git a/app/Auth/Access/ExternalAuthService.php b/app/Auth/Access/ExternalAuthService.php index b1c036018..77c7d1351 100644 --- a/app/Auth/Access/ExternalAuthService.php +++ b/app/Auth/Access/ExternalAuthService.php @@ -2,6 +2,7 @@ use BookStack\Auth\Role; use BookStack\Auth\User; +use Illuminate\Database\Eloquent\Builder; class ExternalAuthService { @@ -57,19 +58,19 @@ class ExternalAuthService /** * Sync the groups to the user roles for the current user * @param \BookStack\Auth\User $user - * @param array $samlAttributes + * @param array $userGroups */ public function syncWithGroups(User $user, array $userGroups) { // Get the ids for the roles from the names - $samlGroupsAsRoles = $this->matchGroupsToSystemsRoles($userSamlGroups); + $groupsAsRoles = $this->matchGroupsToSystemsRoles($userGroups); // Sync groups if ($this->config['remove_from_groups']) { - $user->roles()->sync($samlGroupsAsRoles); + $user->roles()->sync($groupsAsRoles); $this->userRepo->attachDefaultRole($user); } else { - $user->roles()->syncWithoutDetaching($samlGroupsAsRoles); + $user->roles()->syncWithoutDetaching($groupsAsRoles); } } } diff --git a/app/Auth/Access/LdapService.php b/app/Auth/Access/LdapService.php index 3111ea9fa..b0700322f 100644 --- a/app/Auth/Access/LdapService.php +++ b/app/Auth/Access/LdapService.php @@ -5,7 +5,6 @@ use BookStack\Auth\User; use BookStack\Auth\UserRepo; use BookStack\Exceptions\LdapException; use Illuminate\Contracts\Auth\Authenticatable; -use Illuminate\Database\Eloquent\Builder; /** * Class LdapService diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index 95049efd2..056977a3d 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -5,8 +5,6 @@ use BookStack\Auth\User; use BookStack\Auth\UserRepo; use BookStack\Exceptions\SamlException; use Illuminate\Contracts\Auth\Authenticatable; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Facades\Log; /** @@ -117,6 +115,27 @@ class Saml2Service extends Access\ExternalAuthService 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. + * + * @param array $data + * @param $defaultValue + * @return string + */ + protected function simplifyValue(array $data, $defaultValue) { + switch (count($data)) { + case 0: + $data = $defaultValue; + break; + case 1: + $data = $data[0]; + break; + } + return $data; + } + /** * Get a property from an SAML response. * Handles properties potentially being an array. @@ -128,16 +147,9 @@ class Saml2Service extends Access\ExternalAuthService protected function getSamlResponseAttribute(array $samlAttributes, string $propertyKey, $defaultValue) { if (isset($samlAttributes[$propertyKey])) { - $data = $samlAttributes[$propertyKey]; - if (is_array($data)) { - if (count($data) == 0) { - $data = $defaultValue; - } else if (count($data) == 1) { - $data = $data[0]; - } - } + $data = $this->simplifyValue($samlAttributes[$propertyKey], $defaultValue); } else { - $data = $defaultValue; + $data = $defaultValue; } return $data; @@ -190,6 +202,7 @@ class Saml2Service extends Access\ExternalAuthService * they exist, optionally registering them automatically. * @param string $samlID * @param array $samlAttributes + * @throws SamlException */ public function processLoginCallback($samlID, $samlAttributes) { @@ -197,12 +210,14 @@ class Saml2Service extends Access\ExternalAuthService $isLoggedIn = auth()->check(); if ($isLoggedIn) { - logger()->error("Already logged in"); + throw new SamlException(trans('errors.saml_already_logged_in'), '/login'); } else { $user = $this->getOrRegisterUser($userDetails); if ($user === null) { - logger()->error("User does not exist"); + throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['uid']]), '/login'); } else { + $groups = $this->getUserGroups($samlAttributes); + $this->syncWithGroups($user, $groups); auth()->login($user); } } diff --git a/app/Config/services.php b/app/Config/services.php index 9cd647e6d..b3dc9f087 100644 --- a/app/Config/services.php +++ b/app/Config/services.php @@ -150,12 +150,14 @@ return [ ], 'saml' => [ + 'name' => env('SAML_NAME', 'SSO'), 'enabled' => env('SAML2_ENABLED', false), 'auto_register' => env('SAML_AUTO_REGISTER', false), 'email_attribute' => env('SAML_EMAIL_ATTRIBUTE', 'email'), 'display_name_attribute' => explode('|', env('SAML_DISPLAY_NAME_ATTRIBUTE', 'username')), 'user_name_attribute' => env('SAML_USER_NAME_ATTRIBUTE', null), 'group_attribute' => env('SAML_GROUP_ATTRIBUTE', 'group'), + 'remove_from_groups' => env('SAML_REMOVE_FROM_GROUPS',false), 'user_to_groups' => env('SAML_USER_TO_GROUPS', false), 'id_is_user_name' => env('SAML_ID_IS_USER_NAME', true), ] diff --git a/app/Exceptions/SamlException.php b/app/Exceptions/SamlException.php index f9668919c..13db23f27 100644 --- a/app/Exceptions/SamlException.php +++ b/app/Exceptions/SamlException.php @@ -1,6 +1,6 @@ 'LDAP-Zugriff mit DN und Passwort ist fehlgeschlagen', 'ldap_extension_not_installed' => 'LDAP-PHP-Erweiterung ist nicht installiert.', 'ldap_cannot_connect' => 'Die Verbindung zum LDAP-Server ist fehlgeschlagen. Beim initialen Verbindungsaufbau trat ein Fehler auf.', + 'saml_already_logged_in' => 'Sie sind bereits angemeldet', + 'saml_user_not_registered' => 'Kein Benutzer mit ID :name registriert und die automatische Registrierung ist deaktiviert', 'social_no_action_defined' => 'Es ist keine Aktion definiert', 'social_login_bad_response' => "Fehler bei der :socialAccount-Anmeldung: \n:error", 'social_account_in_use' => 'Dieses :socialAccount-Konto wird bereits verwendet. Bitte melden Sie sich mit dem :socialAccount-Konto an.', diff --git a/resources/lang/de_informal/errors.php b/resources/lang/de_informal/errors.php index 924deee0d..420c35c8d 100644 --- a/resources/lang/de_informal/errors.php +++ b/resources/lang/de_informal/errors.php @@ -9,6 +9,7 @@ return [ // Auth 'email_already_confirmed' => 'Die E-Mail-Adresse ist bereits bestätigt. Bitte melde dich an.', 'email_confirmation_invalid' => 'Der Bestätigungslink ist nicht gültig oder wurde bereits verwendet. Bitte registriere dich erneut.', + 'saml_already_logged_in' => 'Du bist bereits angemeldet', 'social_account_in_use' => 'Dieses :socialAccount-Konto wird bereits verwendet. Bitte melde dich mit dem :socialAccount-Konto an.', 'social_account_email_in_use' => 'Die E-Mail-Adresse ":email" ist bereits registriert. Wenn Du bereits registriert bist, kannst Du Dein :socialAccount-Konto in Deinen Profil-Einstellungen verknüpfen.', 'social_account_not_used' => 'Dieses :socialAccount-Konto ist bisher keinem Benutzer zugeordnet. Du kannst das in Deinen Profil-Einstellungen tun.', diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index b91a0c3e1..40c0bbffb 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -17,6 +17,8 @@ return [ 'ldap_fail_authed' => 'LDAP access failed using given dn & password details', 'ldap_extension_not_installed' => 'LDAP PHP extension not installed', 'ldap_cannot_connect' => 'Cannot connect to ldap server, Initial connection failed', + 'saml_already_logged_in' => 'Already logged in', + 'saml_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', 'social_no_action_defined' => 'No action defined', 'social_login_bad_response' => "Error received during :socialAccount login: \n:error", 'social_account_in_use' => 'This :socialAccount account is already in use, Try logging in via the :socialAccount option.', diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 72d8d00aa..8d89c1288 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -51,7 +51,7 @@ @endif From 8169c725d55eb64ffd45b472520bb68f5df608d7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 16 Nov 2019 14:42:51 +0000 Subject: [PATCH 05/12] Started review of SAML implementation - Updated PHPdoc of SAML service to use type hinting instead. - Updated groups to only sync if enabled. - Updated names of some config props. - Removed a couple of unused config props. - Added exception to handle no email on SAML response. --- app/Auth/Access/Saml2Service.php | 143 +++--- app/Config/services.php | 7 +- composer.lock | 848 +++++++++++++++++++------------ resources/lang/en/errors.php | 1 + 4 files changed, 577 insertions(+), 422 deletions(-) diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index 056977a3d..bb57ceb73 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -1,18 +1,15 @@ enabled && $this->config['user_to_groups'] !== false; } - /** Calculate the display name - * @param array $samlAttributes - * @param string $defaultValue - * @return string + /** + * Calculate the display name */ - protected function getUserDisplayName(array $samlAttributes, string $defaultValue) + protected function getUserDisplayName(array $samlAttributes, string $defaultValue): string { - $displayNameAttr = $this->config['display_name_attribute']; + $displayNameAttr = $this->config['display_name_attributes']; $displayName = []; foreach ($displayNameAttr as $dnAttr) { - $dnComponent = $this->getSamlResponseAttribute($samlAttributes, $dnAttr, null); - if ($dnComponent !== null) { - $displayName[] = $dnComponent; - } + $dnComponent = $this->getSamlResponseAttribute($samlAttributes, $dnAttr, null); + if ($dnComponent !== null) { + $displayName[] = $dnComponent; + } } if (count($displayName) == 0) { - $displayName = $defaultValue; + $displayName = $defaultValue; } else { - $displayName = implode(' ', $displayName); + $displayName = implode(' ', $displayName); } return $displayName; } - protected function getUserName(array $samlAttributes, string $defaultValue) + /** + * 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['user_name_attribute']; - + $userNameAttr = $this->config['external_id_attribute']; if ($userNameAttr === null) { - $userName = $defaultValue; - } else { - $userName = $this->getSamlResponseAttribute($samlAttributes, $userNameAttr, $defaultValue); + return $defaultValue; } - return $userName; + return $this->getSamlResponseAttribute($samlAttributes, $userNameAttr, $defaultValue); } /** * Extract the details of a user from a SAML response. - * @param $samlID - * @param $samlAttributes - * @return array + * @throws SamlException */ - public function getUserDetails($samlID, $samlAttributes) + public function getUserDetails(string $samlID, $samlAttributes): array { $emailAttr = $this->config['email_attribute']; - $userName = $this->getUserName($samlAttributes, $samlID); + $externalId = $this->getExternalId($samlAttributes, $samlID); + $email = $this->getSamlResponseAttribute($samlAttributes, $emailAttr, null); + + if ($email === null) { + throw new SamlException(trans('errors.saml_no_email_address')); + } return [ - 'uid' => $userName, - 'name' => $this->getUserDisplayName($samlAttributes, $userName), - 'dn' => $samlID, - 'email' => $this->getSamlResponseAttribute($samlAttributes, $emailAttr, null), + 'external_id' => $externalId, + 'name' => $this->getUserDisplayName($samlAttributes, $externalId), + 'email' => $email, + 'saml_id' => $samlID, ]; } /** * Get the groups a user is a part of from the SAML response. - * @param array $samlAttributes - * @return array */ - public function getUserGroups($samlAttributes) + public function getUserGroups(array $samlAttributes): array { $groupsAttr = $this->config['group_attribute']; - $userGroups = $samlAttributes[$groupsAttr]; + $userGroups = $samlAttributes[$groupsAttr] ?? null; if (!is_array($userGroups)) { $userGroups = []; @@ -119,12 +114,9 @@ class Saml2Service extends Access\ExternalAuthService * 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. - * - * @param array $data - * @param $defaultValue - * @return string */ - protected function simplifyValue(array $data, $defaultValue) { + protected function simplifyValue(array $data, $defaultValue) + { switch (count($data)) { case 0: $data = $defaultValue; @@ -139,39 +131,32 @@ class Saml2Service extends Access\ExternalAuthService /** * Get a property from an SAML response. * Handles properties potentially being an array. - * @param array $userDetails - * @param string $propertyKey - * @param $defaultValue - * @return mixed */ protected function getSamlResponseAttribute(array $samlAttributes, string $propertyKey, $defaultValue) { if (isset($samlAttributes[$propertyKey])) { - $data = $this->simplifyValue($samlAttributes[$propertyKey], $defaultValue); - } else { - $data = $defaultValue; + return $this->simplifyValue($samlAttributes[$propertyKey], $defaultValue); } - return $data; + return $defaultValue; } /** - * Register a user that is authenticated but not - * already registered. - * @param array $userDetails - * @return User + * Register a user that is authenticated but not already registered. */ - protected function registerUser($userDetails) + 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(30), - 'external_auth_id' => $userDetails['uid'], + '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); @@ -180,14 +165,12 @@ class Saml2Service extends Access\ExternalAuthService /** * Get the user from the database for the specified details. - * @param array $userDetails - * @return User|null */ - protected function getOrRegisterUser($userDetails) + protected function getOrRegisterUser(array $userDetails): ?User { $isRegisterEnabled = config('services.saml.auto_register') === true; $user = $this->user - ->where('external_auth_id', $userDetails['uid']) + ->where('external_auth_id', $userDetails['external_id']) ->first(); if ($user === null && $isRegisterEnabled) { @@ -198,30 +181,30 @@ class Saml2Service extends Access\ExternalAuthService } /** - * Process the SAML response for a user. Login the user when - * they exist, optionally registering them automatically. - * @param string $samlID - * @param array $samlAttributes - * @throws SamlException + * Process the SAML response for a user. Login the user when + * they exist, optionally registering them automatically. + * @throws SamlException */ - public function processLoginCallback($samlID, $samlAttributes) + public function processLoginCallback(string $samlID, array $samlAttributes): User { $userDetails = $this->getUserDetails($samlID, $samlAttributes); $isLoggedIn = auth()->check(); if ($isLoggedIn) { throw new SamlException(trans('errors.saml_already_logged_in'), '/login'); - } else { - $user = $this->getOrRegisterUser($userDetails); - if ($user === null) { - throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['uid']]), '/login'); - } else { - $groups = $this->getUserGroups($samlAttributes); - $this->syncWithGroups($user, $groups); - auth()->login($user); - } } + $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); return $user; } } diff --git a/app/Config/services.php b/app/Config/services.php index 0f80a9fc1..4f00d42c5 100644 --- a/app/Config/services.php +++ b/app/Config/services.php @@ -137,12 +137,11 @@ return [ 'enabled' => env('SAML2_ENABLED', false), 'auto_register' => env('SAML_AUTO_REGISTER', false), 'email_attribute' => env('SAML_EMAIL_ATTRIBUTE', 'email'), - 'display_name_attribute' => explode('|', env('SAML_DISPLAY_NAME_ATTRIBUTE', 'username')), - 'user_name_attribute' => env('SAML_USER_NAME_ATTRIBUTE', null), + 'display_name_attributes' => explode('|', env('SAML_DISPLAY_NAME_ATTRIBUTES', 'username')), + 'external_id_attribute' => env('SAML_EXTERNAL_ID_ATTRIBUTE', null), 'group_attribute' => env('SAML_GROUP_ATTRIBUTE', 'group'), - 'remove_from_groups' => env('SAML_REMOVE_FROM_GROUPS',false), + 'remove_from_groups' => env('SAML_REMOVE_FROM_GROUPS', false), 'user_to_groups' => env('SAML_USER_TO_GROUPS', false), - 'id_is_user_name' => env('SAML_ID_IS_USER_NAME', true), ] ]; diff --git a/composer.lock b/composer.lock index 3ec106ded..6de48b13a 100644 --- a/composer.lock +++ b/composer.lock @@ -1,23 +1,82 @@ { "_readme": [ "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c156e1738dbab2a57f9a926d9a9a5a6a", + "content-hash": "42d7a337f6d603ab247b525ade5c3cee", "packages": [ { - "name": "aws/aws-sdk-php", - "version": "3.112.0", + "name": "aacotroneo/laravel-saml2", + "version": "1.0.0", "source": { "type": "git", - "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "1e21446c6780a3b9b5e4315bd6d4347d2c3381eb" + "url": "https://github.com/aacotroneo/laravel-saml2.git", + "reference": "5045701a07bcd7600a17c92971368669870f546a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/1e21446c6780a3b9b5e4315bd6d4347d2c3381eb", - "reference": "1e21446c6780a3b9b5e4315bd6d4347d2c3381eb", + "url": "https://api.github.com/repos/aacotroneo/laravel-saml2/zipball/5045701a07bcd7600a17c92971368669870f546a", + "reference": "5045701a07bcd7600a17c92971368669870f546a", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "illuminate/support": ">=5.0.0", + "onelogin/php-saml": "^3.0.0", + "php": ">=5.4.0" + }, + "require-dev": { + "mockery/mockery": "0.9.*" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Aacotroneo\\Saml2\\Saml2ServiceProvider" + ], + "aliases": { + "Saml2": "Aacotroneo\\Saml2\\Facades\\Saml2Auth" + } + } + }, + "autoload": { + "psr-0": { + "Aacotroneo\\Saml2\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "aacotroneo", + "email": "aacotroneo@gmail.com" + } + ], + "description": "A Laravel package for Saml2 integration as a SP (service provider) based on OneLogin toolkit, which is much lightweight than simplesamlphp", + "homepage": "https://github.com/aacotroneo/laravel-saml2", + "keywords": [ + "SAML2", + "laravel", + "onelogin", + "saml" + ], + "time": "2018-11-08T14:03:58+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.117.2", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "3dc81df70f1cdf2842c85915548bffb870c1e1da" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/3dc81df70f1cdf2842c85915548bffb870c1e1da", + "reference": "3dc81df70f1cdf2842c85915548bffb870c1e1da", "shasum": "" }, "require": { @@ -87,7 +146,7 @@ "s3", "sdk" ], - "time": "2019-09-12T18:09:53+00:00" + "time": "2019-11-15T19:21:02+00:00" }, { "name": "barryvdh/laravel-dompdf", @@ -147,21 +206,21 @@ }, { "name": "barryvdh/laravel-snappy", - "version": "v0.4.5", + "version": "v0.4.6", "source": { "type": "git", "url": "https://github.com/barryvdh/laravel-snappy.git", - "reference": "9be767fc7a082665a84945f36c70b0cbead91ce9" + "reference": "94d53c88fa58baa4573c5854663ebc9955f21265" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/barryvdh/laravel-snappy/zipball/9be767fc7a082665a84945f36c70b0cbead91ce9", - "reference": "9be767fc7a082665a84945f36c70b0cbead91ce9", + "url": "https://api.github.com/repos/barryvdh/laravel-snappy/zipball/94d53c88fa58baa4573c5854663ebc9955f21265", + "reference": "94d53c88fa58baa4573c5854663ebc9955f21265", "shasum": "" }, "require": { - "illuminate/filesystem": "5.5.x|5.6.x|5.7.x|5.8.x|6.0.*", - "illuminate/support": "5.5.x|5.6.x|5.7.x|5.8.x|6.0.*", + "illuminate/filesystem": "5.5.x|5.6.x|5.7.x|5.8.x|6.*", + "illuminate/support": "5.5.x|5.6.x|5.7.x|5.8.x|6.*", "knplabs/knp-snappy": "^1", "php": ">=7" }, @@ -204,7 +263,7 @@ "wkhtmltoimage", "wkhtmltopdf" ], - "time": "2019-08-30T16:12:23+00:00" + "time": "2019-10-02T23:27:09+00:00" }, { "name": "cogpowered/finediff", @@ -259,16 +318,16 @@ }, { "name": "doctrine/cache", - "version": "v1.8.0", + "version": "1.9.1", "source": { "type": "git", "url": "https://github.com/doctrine/cache.git", - "reference": "d768d58baee9a4862ca783840eca1b9add7a7f57" + "reference": "89a5c76c39c292f7798f964ab3c836c3f8192a55" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/cache/zipball/d768d58baee9a4862ca783840eca1b9add7a7f57", - "reference": "d768d58baee9a4862ca783840eca1b9add7a7f57", + "url": "https://api.github.com/repos/doctrine/cache/zipball/89a5c76c39c292f7798f964ab3c836c3f8192a55", + "reference": "89a5c76c39c292f7798f964ab3c836c3f8192a55", "shasum": "" }, "require": { @@ -279,7 +338,7 @@ }, "require-dev": { "alcaeus/mongo-php-adapter": "^1.1", - "doctrine/coding-standard": "^4.0", + "doctrine/coding-standard": "^6.0", "mongodb/mongodb": "^1.1", "phpunit/phpunit": "^7.0", "predis/predis": "~1.0" @@ -290,7 +349,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.8.x-dev" + "dev-master": "1.9.x-dev" } }, "autoload": { @@ -303,6 +362,10 @@ "MIT" ], "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, { "name": "Roman Borschel", "email": "roman@code-factory.org" @@ -311,10 +374,6 @@ "name": "Benjamin Eberlei", "email": "kontakt@beberlei.de" }, - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, { "name": "Jonathan Wage", "email": "jonwage@gmail.com" @@ -324,41 +383,48 @@ "email": "schmittjoh@gmail.com" } ], - "description": "Caching library offering an object-oriented API for many cache backends", - "homepage": "https://www.doctrine-project.org", + "description": "PHP Doctrine Cache library is a popular cache implementation that supports many different drivers such as redis, memcache, apc, mongodb and others.", + "homepage": "https://www.doctrine-project.org/projects/cache.html", "keywords": [ + "abstraction", + "apcu", "cache", - "caching" + "caching", + "couchdb", + "memcached", + "php", + "redis", + "riak", + "xcache" ], - "time": "2018-08-21T18:01:43+00:00" + "time": "2019-11-15T14:31:57+00:00" }, { "name": "doctrine/dbal", - "version": "v2.9.2", + "version": "v2.10.0", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "22800bd651c1d8d2a9719e2a3dc46d5108ebfcc9" + "reference": "0c9a646775ef549eb0a213a4f9bd4381d9b4d934" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/22800bd651c1d8d2a9719e2a3dc46d5108ebfcc9", - "reference": "22800bd651c1d8d2a9719e2a3dc46d5108ebfcc9", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/0c9a646775ef549eb0a213a4f9bd4381d9b4d934", + "reference": "0c9a646775ef549eb0a213a4f9bd4381d9b4d934", "shasum": "" }, "require": { "doctrine/cache": "^1.0", "doctrine/event-manager": "^1.0", "ext-pdo": "*", - "php": "^7.1" + "php": "^7.2" }, "require-dev": { - "doctrine/coding-standard": "^5.0", - "jetbrains/phpstorm-stubs": "^2018.1.2", - "phpstan/phpstan": "^0.10.1", - "phpunit/phpunit": "^7.4", - "symfony/console": "^2.0.5|^3.0|^4.0", - "symfony/phpunit-bridge": "^3.4.5|^4.0.5" + "doctrine/coding-standard": "^6.0", + "jetbrains/phpstorm-stubs": "^2019.1", + "phpstan/phpstan": "^0.11.3", + "phpunit/phpunit": "^8.4.1", + "symfony/console": "^2.0.5|^3.0|^4.0|^5.0" }, "suggest": { "symfony/console": "For helpful console commands such as SQL execution and import of files." @@ -369,7 +435,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.9.x-dev", + "dev-master": "2.10.x-dev", "dev-develop": "3.0.x-dev" } }, @@ -383,6 +449,10 @@ "MIT" ], "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, { "name": "Roman Borschel", "email": "roman@code-factory.org" @@ -391,10 +461,6 @@ "name": "Benjamin Eberlei", "email": "kontakt@beberlei.de" }, - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, { "name": "Jonathan Wage", "email": "jonwage@gmail.com" @@ -405,27 +471,38 @@ "keywords": [ "abstraction", "database", + "db2", "dbal", + "mariadb", + "mssql", "mysql", - "persistence", + "oci8", + "oracle", + "pdo", "pgsql", - "php", - "queryobject" + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlanywhere", + "sqlite", + "sqlserver", + "sqlsrv" ], - "time": "2018-12-31T03:27:51+00:00" + "time": "2019-11-03T16:50:43+00:00" }, { "name": "doctrine/event-manager", - "version": "v1.0.0", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/event-manager.git", - "reference": "a520bc093a0170feeb6b14e9d83f3a14452e64b3" + "reference": "629572819973f13486371cb611386eb17851e85c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/event-manager/zipball/a520bc093a0170feeb6b14e9d83f3a14452e64b3", - "reference": "a520bc093a0170feeb6b14e9d83f3a14452e64b3", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/629572819973f13486371cb611386eb17851e85c", + "reference": "629572819973f13486371cb611386eb17851e85c", "shasum": "" }, "require": { @@ -435,7 +512,7 @@ "doctrine/common": "<2.9@dev" }, "require-dev": { - "doctrine/coding-standard": "^4.0", + "doctrine/coding-standard": "^6.0", "phpunit/phpunit": "^7.0" }, "type": "library", @@ -454,6 +531,10 @@ "MIT" ], "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, { "name": "Roman Borschel", "email": "roman@code-factory.org" @@ -462,10 +543,6 @@ "name": "Benjamin Eberlei", "email": "kontakt@beberlei.de" }, - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, { "name": "Jonathan Wage", "email": "jonwage@gmail.com" @@ -479,27 +556,29 @@ "email": "ocramius@gmail.com" } ], - "description": "Doctrine Event Manager component", + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", "homepage": "https://www.doctrine-project.org/projects/event-manager.html", "keywords": [ "event", - "eventdispatcher", - "eventmanager" + "event dispatcher", + "event manager", + "event system", + "events" ], - "time": "2018-06-11T11:59:03+00:00" + "time": "2019-11-10T09:48:07+00:00" }, { "name": "doctrine/inflector", - "version": "v1.3.0", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/doctrine/inflector.git", - "reference": "5527a48b7313d15261292c149e55e26eae771b0a" + "reference": "ec3a55242203ffa6a4b27c58176da97ff0a7aec1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/inflector/zipball/5527a48b7313d15261292c149e55e26eae771b0a", - "reference": "5527a48b7313d15261292c149e55e26eae771b0a", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/ec3a55242203ffa6a4b27c58176da97ff0a7aec1", + "reference": "ec3a55242203ffa6a4b27c58176da97ff0a7aec1", "shasum": "" }, "require": { @@ -524,6 +603,10 @@ "MIT" ], "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, { "name": "Roman Borschel", "email": "roman@code-factory.org" @@ -532,10 +615,6 @@ "name": "Benjamin Eberlei", "email": "kontakt@beberlei.de" }, - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, { "name": "Jonathan Wage", "email": "jonwage@gmail.com" @@ -553,20 +632,20 @@ "singularize", "string" ], - "time": "2018-01-09T20:05:19+00:00" + "time": "2019-10-30T19:59:35+00:00" }, { "name": "doctrine/lexer", - "version": "1.1.0", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/doctrine/lexer.git", - "reference": "e17f069ede36f7534b95adec71910ed1b49c74ea" + "reference": "5242d66dbeb21a30dd8a3e66bf7a73b66e05e1f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/e17f069ede36f7534b95adec71910ed1b49c74ea", - "reference": "e17f069ede36f7534b95adec71910ed1b49c74ea", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/5242d66dbeb21a30dd8a3e66bf7a73b66e05e1f6", + "reference": "5242d66dbeb21a30dd8a3e66bf7a73b66e05e1f6", "shasum": "" }, "require": { @@ -580,7 +659,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1.x-dev" + "dev-master": "1.2.x-dev" } }, "autoload": { @@ -615,7 +694,7 @@ "parser", "php" ], - "time": "2019-07-30T19:33:28+00:00" + "time": "2019-10-30T14:39:59+00:00" }, { "name": "dompdf/dompdf", @@ -946,27 +1025,28 @@ }, { "name": "guzzlehttp/guzzle", - "version": "6.3.3", + "version": "6.4.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "407b0cb880ace85c9b63c5f9551db498cb2d50ba" + "reference": "0895c932405407fd3a7368b6910c09a24d26db11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/407b0cb880ace85c9b63c5f9551db498cb2d50ba", - "reference": "407b0cb880ace85c9b63c5f9551db498cb2d50ba", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/0895c932405407fd3a7368b6910c09a24d26db11", + "reference": "0895c932405407fd3a7368b6910c09a24d26db11", "shasum": "" }, "require": { + "ext-json": "*", "guzzlehttp/promises": "^1.0", - "guzzlehttp/psr7": "^1.4", + "guzzlehttp/psr7": "^1.6.1", "php": ">=5.5" }, "require-dev": { "ext-curl": "*", "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.4 || ^7.0", - "psr/log": "^1.0" + "psr/log": "^1.1" }, "suggest": { "psr/log": "Required for using the Log middleware" @@ -978,12 +1058,12 @@ } }, "autoload": { - "files": [ - "src/functions_include.php" - ], "psr-4": { "GuzzleHttp\\": "src/" - } + }, + "files": [ + "src/functions_include.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1007,7 +1087,7 @@ "rest", "web service" ], - "time": "2018-04-22T15:46:56+00:00" + "time": "2019-10-23T15:58:00+00:00" }, { "name": "guzzlehttp/promises", @@ -1133,16 +1213,16 @@ }, { "name": "intervention/image", - "version": "2.5.0", + "version": "2.5.1", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "39eaef720d082ecc54c64bf54541c55f10db546d" + "reference": "abbf18d5ab8367f96b3205ca3c89fb2fa598c69e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/39eaef720d082ecc54c64bf54541c55f10db546d", - "reference": "39eaef720d082ecc54c64bf54541c55f10db546d", + "url": "https://api.github.com/repos/Intervention/image/zipball/abbf18d5ab8367f96b3205ca3c89fb2fa598c69e", + "reference": "abbf18d5ab8367f96b3205ca3c89fb2fa598c69e", "shasum": "" }, "require": { @@ -1199,7 +1279,7 @@ "thumbnail", "watermark" ], - "time": "2019-06-24T14:06:31+00:00" + "time": "2019-11-02T09:15:47+00:00" }, { "name": "knplabs/knp-snappy", @@ -1269,16 +1349,16 @@ }, { "name": "laravel/framework", - "version": "v6.0.3", + "version": "v6.5.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "56789e9dec750e0fbe8e9e6ae90a01a4e6887902" + "reference": "e47180500498cf8aa2a8ffb59a3b4daa007fa13d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/56789e9dec750e0fbe8e9e6ae90a01a4e6887902", - "reference": "56789e9dec750e0fbe8e9e6ae90a01a4e6887902", + "url": "https://api.github.com/repos/laravel/framework/zipball/e47180500498cf8aa2a8ffb59a3b4daa007fa13d", + "reference": "e47180500498cf8aa2a8ffb59a3b4daa007fa13d", "shasum": "" }, "require": { @@ -1374,7 +1454,8 @@ "league/flysystem-sftp": "Required to use the Flysystem SFTP driver (^1.0).", "moontoast/math": "Required to use ordered UUIDs (^1.1).", "pda/pheanstalk": "Required to use the beanstalk queue driver (^4.0).", - "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0)", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^4.0).", "symfony/cache": "Required to PSR-6 cache bridge (^4.3.4).", "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^1.2).", "wildbit/swiftmailer-postmark": "Required to use Postmark mail driver (^3.0)." @@ -1410,7 +1491,7 @@ "framework", "laravel" ], - "time": "2019-09-10T18:46:24+00:00" + "time": "2019-11-12T15:20:18+00:00" }, { "name": "laravel/socialite", @@ -1478,16 +1559,16 @@ }, { "name": "league/flysystem", - "version": "1.0.55", + "version": "1.0.57", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "33c91155537c6dc899eacdc54a13ac6303f156e6" + "reference": "0e9db7f0b96b9f12dcf6f65bc34b72b1a30ea55a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/33c91155537c6dc899eacdc54a13ac6303f156e6", - "reference": "33c91155537c6dc899eacdc54a13ac6303f156e6", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/0e9db7f0b96b9f12dcf6f65bc34b72b1a30ea55a", + "reference": "0e9db7f0b96b9f12dcf6f65bc34b72b1a30ea55a", "shasum": "" }, "require": { @@ -1558,7 +1639,7 @@ "sftp", "storage" ], - "time": "2019-08-24T11:17:19+00:00" + "time": "2019-10-16T21:01:05+00:00" }, { "name": "league/flysystem-aws-s3-v3", @@ -1672,16 +1753,16 @@ }, { "name": "monolog/monolog", - "version": "2.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "68545165e19249013afd1d6f7485aecff07a2d22" + "reference": "f9d56fd2f5533322caccdfcddbb56aedd622ef1c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/68545165e19249013afd1d6f7485aecff07a2d22", - "reference": "68545165e19249013afd1d6f7485aecff07a2d22", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/f9d56fd2f5533322caccdfcddbb56aedd622ef1c", + "reference": "f9d56fd2f5533322caccdfcddbb56aedd622ef1c", "shasum": "" }, "require": { @@ -1749,7 +1830,7 @@ "logging", "psr-3" ], - "time": "2019-08-30T09:56:44+00:00" + "time": "2019-11-13T10:27:43+00:00" }, { "name": "mtdowling/jmespath.php", @@ -1808,16 +1889,16 @@ }, { "name": "nesbot/carbon", - "version": "2.24.0", + "version": "2.26.0", "source": { "type": "git", "url": "https://github.com/briannesbitt/Carbon.git", - "reference": "934459c5ac0658bc765ad1e53512c7c77adcac29" + "reference": "e01ecc0b71168febb52ae1fdc1cfcc95428e604e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/934459c5ac0658bc765ad1e53512c7c77adcac29", - "reference": "934459c5ac0658bc765ad1e53512c7c77adcac29", + "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/e01ecc0b71168febb52ae1fdc1cfcc95428e604e", + "reference": "e01ecc0b71168febb52ae1fdc1cfcc95428e604e", "shasum": "" }, "require": { @@ -1864,27 +1945,77 @@ "homepage": "http://github.com/kylekatarnls" } ], - "description": "A API extension for DateTime that supports 281 different languages.", + "description": "An API extension for DateTime that supports 281 different languages.", "homepage": "http://carbon.nesbot.com", "keywords": [ "date", "datetime", "time" ], - "time": "2019-08-31T16:37:55+00:00" + "time": "2019-10-21T21:32:25+00:00" }, { - "name": "opis/closure", - "version": "3.4.0", + "name": "onelogin/php-saml", + "version": "3.3.1", "source": { "type": "git", - "url": "https://github.com/opis/closure.git", - "reference": "60a97fff133b1669a5b1776aa8ab06db3f3962b7" + "url": "https://github.com/onelogin/php-saml.git", + "reference": "bb34489635cd5c7eb1b42833e4c57ca1c786a81a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opis/closure/zipball/60a97fff133b1669a5b1776aa8ab06db3f3962b7", - "reference": "60a97fff133b1669a5b1776aa8ab06db3f3962b7", + "url": "https://api.github.com/repos/onelogin/php-saml/zipball/bb34489635cd5c7eb1b42833e4c57ca1c786a81a", + "reference": "bb34489635cd5c7eb1b42833e4c57ca1c786a81a", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "robrichards/xmlseclibs": ">=3.0.4" + }, + "require-dev": { + "pdepend/pdepend": "^2.5.0", + "php-coveralls/php-coveralls": "^1.0.2 || ^2.0", + "phploc/phploc": "^2.1 || ^3.0 || ^4.0", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5 || ^7.1", + "sebastian/phpcpd": "^2.0 || ^3.0 || ^4.0", + "squizlabs/php_codesniffer": "^3.1.1" + }, + "suggest": { + "ext-curl": "Install curl lib to be able to use the IdPMetadataParser for parsing remote XMLs", + "ext-gettext": "Install gettext and php5-gettext libs to handle translations", + "ext-openssl": "Install openssl lib in order to handle with x509 certs (require to support sign and encryption)" + }, + "type": "library", + "autoload": { + "psr-4": { + "OneLogin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "OneLogin PHP SAML Toolkit", + "homepage": "https://developers.onelogin.com/saml/php", + "keywords": [ + "SAML2", + "onelogin", + "saml" + ], + "time": "2019-11-06T16:59:38+00:00" + }, + { + "name": "opis/closure", + "version": "3.4.1", + "source": { + "type": "git", + "url": "https://github.com/opis/closure.git", + "reference": "e79f851749c3caa836d7ccc01ede5828feb762c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/closure/zipball/e79f851749c3caa836d7ccc01ede5828feb762c7", + "reference": "e79f851749c3caa836d7ccc01ede5828feb762c7", "shasum": "" }, "require": { @@ -1932,7 +2063,7 @@ "serialization", "serialize" ], - "time": "2019-09-02T21:07:33+00:00" + "time": "2019-10-19T18:38:51+00:00" }, { "name": "paragonie/random_compat", @@ -2058,28 +2189,28 @@ }, { "name": "phpoption/phpoption", - "version": "1.5.0", + "version": "1.5.2", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "94e644f7d2051a5f0fcf77d81605f152eecff0ed" + "reference": "2ba2586380f8d2b44ad1b9feb61c371020b27793" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/94e644f7d2051a5f0fcf77d81605f152eecff0ed", - "reference": "94e644f7d2051a5f0fcf77d81605f152eecff0ed", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/2ba2586380f8d2b44ad1b9feb61c371020b27793", + "reference": "2ba2586380f8d2b44ad1b9feb61c371020b27793", "shasum": "" }, "require": { "php": ">=5.3.0" }, "require-dev": { - "phpunit/phpunit": "4.7.*" + "phpunit/phpunit": "^4.7|^5.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.3-dev" + "dev-master": "1.5-dev" } }, "autoload": { @@ -2089,7 +2220,7 @@ }, "notification-url": "https://packagist.org/downloads/", "license": [ - "Apache2" + "Apache-2.0" ], "authors": [ { @@ -2104,7 +2235,7 @@ "php", "type" ], - "time": "2015-07-25T16:39:46+00:00" + "time": "2019-11-06T22:27:00+00:00" }, { "name": "predis/predis", @@ -2257,16 +2388,16 @@ }, { "name": "psr/log", - "version": "1.1.0", + "version": "1.1.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd" + "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd", - "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd", + "url": "https://api.github.com/repos/php-fig/log/zipball/446d54b4cb6bf489fc9d75f55843658e6f25d801", + "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801", "shasum": "" }, "require": { @@ -2275,7 +2406,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.1.x-dev" } }, "autoload": { @@ -2300,7 +2431,7 @@ "psr", "psr-3" ], - "time": "2018-11-20T15:27:04+00:00" + "time": "2019-11-01T11:05:21+00:00" }, { "name": "psr/simple-cache", @@ -2472,6 +2603,44 @@ ], "time": "2018-07-19T23:38:55+00:00" }, + { + "name": "robrichards/xmlseclibs", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/robrichards/xmlseclibs.git", + "reference": "0a53d3c3aa87564910cae4ed01416441d3ae0db5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/0a53d3c3aa87564910cae4ed01416441d3ae0db5", + "reference": "0a53d3c3aa87564910cae4ed01416441d3ae0db5", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">= 5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "RobRichards\\XMLSecLibs\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "A PHP library for XML Security", + "homepage": "https://github.com/robrichards/xmlseclibs", + "keywords": [ + "security", + "signature", + "xml", + "xmldsig" + ], + "time": "2019-11-05T11:44:22+00:00" + }, { "name": "sabberworm/php-css-parser", "version": "8.3.0", @@ -2593,16 +2762,16 @@ }, { "name": "socialiteproviders/manager", - "version": "v3.4.2", + "version": "v3.4.3", "source": { "type": "git", "url": "https://github.com/SocialiteProviders/Manager.git", - "reference": "e3e8e78b9a3060801cd008941a0894a0a0c479e1" + "reference": "09903d33429f9f6c0da32c545c036a3e18964bbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/e3e8e78b9a3060801cd008941a0894a0a0c479e1", - "reference": "e3e8e78b9a3060801cd008941a0894a0a0c479e1", + "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/09903d33429f9f6c0da32c545c036a3e18964bbf", + "reference": "09903d33429f9f6c0da32c545c036a3e18964bbf", "shasum": "" }, "require": { @@ -2646,7 +2815,7 @@ } ], "description": "Easily add new or override built-in providers in Laravel Socialite.", - "time": "2019-09-09T03:07:52+00:00" + "time": "2019-09-25T06:06:35+00:00" }, { "name": "socialiteproviders/microsoft-azure", @@ -2724,16 +2893,16 @@ }, { "name": "socialiteproviders/slack", - "version": "v3.0.3", + "version": "v3.1", "source": { "type": "git", "url": "https://github.com/SocialiteProviders/Slack.git", - "reference": "8d5d0c0c916adf2af6b406679130441db0afc387" + "reference": "d46826640fbeae8f34328d99c358404a1e1050a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Slack/zipball/8d5d0c0c916adf2af6b406679130441db0afc387", - "reference": "8d5d0c0c916adf2af6b406679130441db0afc387", + "url": "https://api.github.com/repos/SocialiteProviders/Slack/zipball/d46826640fbeae8f34328d99c358404a1e1050a3", + "reference": "d46826640fbeae8f34328d99c358404a1e1050a3", "shasum": "" }, "require": { @@ -2757,20 +2926,20 @@ } ], "description": "Slack OAuth2 Provider for Laravel Socialite", - "time": "2017-04-10T05:10:48+00:00" + "time": "2019-01-11T19:48:14+00:00" }, { "name": "socialiteproviders/twitch", - "version": "v5.0.0", + "version": "v5.1", "source": { "type": "git", "url": "https://github.com/SocialiteProviders/Twitch.git", - "reference": "8c19b26ff24c40cc019413042a5492c5ed21a658" + "reference": "f9b1f90a94f539e1b29e84ee0f731f42d59f3213" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Twitch/zipball/8c19b26ff24c40cc019413042a5492c5ed21a658", - "reference": "8c19b26ff24c40cc019413042a5492c5ed21a658", + "url": "https://api.github.com/repos/SocialiteProviders/Twitch/zipball/f9b1f90a94f539e1b29e84ee0f731f42d59f3213", + "reference": "f9b1f90a94f539e1b29e84ee0f731f42d59f3213", "shasum": "" }, "require": { @@ -2794,20 +2963,20 @@ } ], "description": "Twitch OAuth2 Provider for Laravel Socialite", - "time": "2018-06-20T10:59:51+00:00" + "time": "2019-07-01T10:35:46+00:00" }, { "name": "swiftmailer/swiftmailer", - "version": "v6.2.1", + "version": "v6.2.3", "source": { "type": "git", "url": "https://github.com/swiftmailer/swiftmailer.git", - "reference": "5397cd05b0a0f7937c47b0adcb4c60e5ab936b6a" + "reference": "149cfdf118b169f7840bbe3ef0d4bc795d1780c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/swiftmailer/swiftmailer/zipball/5397cd05b0a0f7937c47b0adcb4c60e5ab936b6a", - "reference": "5397cd05b0a0f7937c47b0adcb4c60e5ab936b6a", + "url": "https://api.github.com/repos/swiftmailer/swiftmailer/zipball/149cfdf118b169f7840bbe3ef0d4bc795d1780c9", + "reference": "149cfdf118b169f7840bbe3ef0d4bc795d1780c9", "shasum": "" }, "require": { @@ -2856,20 +3025,20 @@ "mail", "mailer" ], - "time": "2019-04-21T09:21:45+00:00" + "time": "2019-11-12T09:31:26+00:00" }, { "name": "symfony/console", - "version": "v4.3.4", + "version": "v4.3.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "de63799239b3881b8a08f8481b22348f77ed7b36" + "reference": "831424efae0a1fe6642784bd52aae14ece6538e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/de63799239b3881b8a08f8481b22348f77ed7b36", - "reference": "de63799239b3881b8a08f8481b22348f77ed7b36", + "url": "https://api.github.com/repos/symfony/console/zipball/831424efae0a1fe6642784bd52aae14ece6538e6", + "reference": "831424efae0a1fe6642784bd52aae14ece6538e6", "shasum": "" }, "require": { @@ -2931,20 +3100,20 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-08-26T08:26:39+00:00" + "time": "2019-11-13T07:29:07+00:00" }, { "name": "symfony/css-selector", - "version": "v4.3.4", + "version": "v4.3.8", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "c6e5e2a00db768c92c3ae131532af4e1acc7bd03" + "reference": "f4b3ff6a549d9ed28b2b0ecd1781bf67cf220ee9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/c6e5e2a00db768c92c3ae131532af4e1acc7bd03", - "reference": "c6e5e2a00db768c92c3ae131532af4e1acc7bd03", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/f4b3ff6a549d9ed28b2b0ecd1781bf67cf220ee9", + "reference": "f4b3ff6a549d9ed28b2b0ecd1781bf67cf220ee9", "shasum": "" }, "require": { @@ -2984,20 +3153,20 @@ ], "description": "Symfony CssSelector Component", "homepage": "https://symfony.com", - "time": "2019-08-20T14:07:54+00:00" + "time": "2019-10-02T08:36:26+00:00" }, { "name": "symfony/debug", - "version": "v4.3.4", + "version": "v4.3.8", "source": { "type": "git", "url": "https://github.com/symfony/debug.git", - "reference": "afcdea44a2e399c1e4b52246ec8d54c715393ced" + "reference": "5ea9c3e01989a86ceaa0283f21234b12deadf5e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/afcdea44a2e399c1e4b52246ec8d54c715393ced", - "reference": "afcdea44a2e399c1e4b52246ec8d54c715393ced", + "url": "https://api.github.com/repos/symfony/debug/zipball/5ea9c3e01989a86ceaa0283f21234b12deadf5e2", + "reference": "5ea9c3e01989a86ceaa0283f21234b12deadf5e2", "shasum": "" }, "require": { @@ -3040,20 +3209,20 @@ ], "description": "Symfony Debug Component", "homepage": "https://symfony.com", - "time": "2019-08-20T14:27:59+00:00" + "time": "2019-10-28T17:07:32+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v4.3.4", + "version": "v4.3.8", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "429d0a1451d4c9c4abe1959b2986b88794b9b7d2" + "reference": "0df002fd4f500392eabd243c2947061a50937287" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/429d0a1451d4c9c4abe1959b2986b88794b9b7d2", - "reference": "429d0a1451d4c9c4abe1959b2986b88794b9b7d2", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0df002fd4f500392eabd243c2947061a50937287", + "reference": "0df002fd4f500392eabd243c2947061a50937287", "shasum": "" }, "require": { @@ -3110,20 +3279,20 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-08-26T08:55:16+00:00" + "time": "2019-11-03T09:04:05+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v1.1.5", + "version": "v1.1.7", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "c61766f4440ca687de1084a5c00b08e167a2575c" + "reference": "c43ab685673fb6c8d84220c77897b1d6cdbe1d18" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c61766f4440ca687de1084a5c00b08e167a2575c", - "reference": "c61766f4440ca687de1084a5c00b08e167a2575c", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c43ab685673fb6c8d84220c77897b1d6cdbe1d18", + "reference": "c43ab685673fb6c8d84220c77897b1d6cdbe1d18", "shasum": "" }, "require": { @@ -3168,20 +3337,20 @@ "interoperability", "standards" ], - "time": "2019-06-20T06:46:26+00:00" + "time": "2019-09-17T09:54:03+00:00" }, { "name": "symfony/finder", - "version": "v4.3.4", + "version": "v4.3.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "86c1c929f0a4b24812e1eb109262fc3372c8e9f2" + "reference": "72a068f77e317ae77c0a0495236ad292cfb5ce6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/86c1c929f0a4b24812e1eb109262fc3372c8e9f2", - "reference": "86c1c929f0a4b24812e1eb109262fc3372c8e9f2", + "url": "https://api.github.com/repos/symfony/finder/zipball/72a068f77e317ae77c0a0495236ad292cfb5ce6f", + "reference": "72a068f77e317ae77c0a0495236ad292cfb5ce6f", "shasum": "" }, "require": { @@ -3217,20 +3386,20 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-08-14T12:26:46+00:00" + "time": "2019-10-30T12:53:54+00:00" }, { "name": "symfony/http-foundation", - "version": "v4.3.4", + "version": "v4.3.8", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "d804bea118ff340a12e22a79f9c7e7eb56b35adc" + "reference": "cabe67275034e173350e158f3b1803d023880227" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d804bea118ff340a12e22a79f9c7e7eb56b35adc", - "reference": "d804bea118ff340a12e22a79f9c7e7eb56b35adc", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/cabe67275034e173350e158f3b1803d023880227", + "reference": "cabe67275034e173350e158f3b1803d023880227", "shasum": "" }, "require": { @@ -3272,20 +3441,20 @@ ], "description": "Symfony HttpFoundation Component", "homepage": "https://symfony.com", - "time": "2019-08-26T08:55:16+00:00" + "time": "2019-11-12T13:07:20+00:00" }, { "name": "symfony/http-kernel", - "version": "v4.3.4", + "version": "v4.3.8", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "5e0fc71be03d52cd00c423061cfd300bd6f92a52" + "reference": "5fdf186f26f9080de531d3f1d024348b2f0ab12f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/5e0fc71be03d52cd00c423061cfd300bd6f92a52", - "reference": "5e0fc71be03d52cd00c423061cfd300bd6f92a52", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/5fdf186f26f9080de531d3f1d024348b2f0ab12f", + "reference": "5fdf186f26f9080de531d3f1d024348b2f0ab12f", "shasum": "" }, "require": { @@ -3364,20 +3533,20 @@ ], "description": "Symfony HttpKernel Component", "homepage": "https://symfony.com", - "time": "2019-08-26T16:47:42+00:00" + "time": "2019-11-13T09:07:28+00:00" }, { "name": "symfony/mime", - "version": "v4.3.4", + "version": "v4.3.8", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "987a05df1c6ac259b34008b932551353f4f408df" + "reference": "22aecf6b11638ef378fab25d6c5a2da8a31a1448" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/987a05df1c6ac259b34008b932551353f4f408df", - "reference": "987a05df1c6ac259b34008b932551353f4f408df", + "url": "https://api.github.com/repos/symfony/mime/zipball/22aecf6b11638ef378fab25d6c5a2da8a31a1448", + "reference": "22aecf6b11638ef378fab25d6c5a2da8a31a1448", "shasum": "" }, "require": { @@ -3423,7 +3592,7 @@ "mime", "mime-type" ], - "time": "2019-08-22T08:16:11+00:00" + "time": "2019-11-12T13:10:02+00:00" }, { "name": "symfony/polyfill-ctype", @@ -3778,16 +3947,16 @@ }, { "name": "symfony/process", - "version": "v4.3.4", + "version": "v4.3.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "e89969c00d762349f078db1128506f7f3dcc0d4a" + "reference": "3b2e0cb029afbb0395034509291f21191d1a4db0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/e89969c00d762349f078db1128506f7f3dcc0d4a", - "reference": "e89969c00d762349f078db1128506f7f3dcc0d4a", + "url": "https://api.github.com/repos/symfony/process/zipball/3b2e0cb029afbb0395034509291f21191d1a4db0", + "reference": "3b2e0cb029afbb0395034509291f21191d1a4db0", "shasum": "" }, "require": { @@ -3823,20 +3992,20 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2019-08-26T08:26:39+00:00" + "time": "2019-10-28T17:07:32+00:00" }, { "name": "symfony/routing", - "version": "v4.3.4", + "version": "v4.3.8", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "ff1049f6232dc5b6023b1ff1c6de56f82bcd264f" + "reference": "533fd12a41fb9ce8d4e861693365427849487c0e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/ff1049f6232dc5b6023b1ff1c6de56f82bcd264f", - "reference": "ff1049f6232dc5b6023b1ff1c6de56f82bcd264f", + "url": "https://api.github.com/repos/symfony/routing/zipball/533fd12a41fb9ce8d4e861693365427849487c0e", + "reference": "533fd12a41fb9ce8d4e861693365427849487c0e", "shasum": "" }, "require": { @@ -3899,20 +4068,20 @@ "uri", "url" ], - "time": "2019-08-26T08:26:39+00:00" + "time": "2019-11-04T20:23:03+00:00" }, { "name": "symfony/service-contracts", - "version": "v1.1.6", + "version": "v1.1.8", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "ea7263d6b6d5f798b56a45a5b8d686725f2719a3" + "reference": "ffc7f5692092df31515df2a5ecf3b7302b3ddacf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/ea7263d6b6d5f798b56a45a5b8d686725f2719a3", - "reference": "ea7263d6b6d5f798b56a45a5b8d686725f2719a3", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/ffc7f5692092df31515df2a5ecf3b7302b3ddacf", + "reference": "ffc7f5692092df31515df2a5ecf3b7302b3ddacf", "shasum": "" }, "require": { @@ -3957,20 +4126,20 @@ "interoperability", "standards" ], - "time": "2019-08-20T14:44:19+00:00" + "time": "2019-10-14T12:27:06+00:00" }, { "name": "symfony/translation", - "version": "v4.3.4", + "version": "v4.3.8", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "28498169dd334095fa981827992f3a24d50fed0f" + "reference": "bbce239b35b0cd47bd75848b23e969f17dd970e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/28498169dd334095fa981827992f3a24d50fed0f", - "reference": "28498169dd334095fa981827992f3a24d50fed0f", + "url": "https://api.github.com/repos/symfony/translation/zipball/bbce239b35b0cd47bd75848b23e969f17dd970e7", + "reference": "bbce239b35b0cd47bd75848b23e969f17dd970e7", "shasum": "" }, "require": { @@ -4033,20 +4202,20 @@ ], "description": "Symfony Translation Component", "homepage": "https://symfony.com", - "time": "2019-08-26T08:55:16+00:00" + "time": "2019-11-06T23:21:49+00:00" }, { "name": "symfony/translation-contracts", - "version": "v1.1.6", + "version": "v1.1.7", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "325b17c24f3ee23cbecfa63ba809c6d89b5fa04a" + "reference": "364518c132c95642e530d9b2d217acbc2ccac3e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/325b17c24f3ee23cbecfa63ba809c6d89b5fa04a", - "reference": "325b17c24f3ee23cbecfa63ba809c6d89b5fa04a", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/364518c132c95642e530d9b2d217acbc2ccac3e6", + "reference": "364518c132c95642e530d9b2d217acbc2ccac3e6", "shasum": "" }, "require": { @@ -4090,20 +4259,20 @@ "interoperability", "standards" ], - "time": "2019-08-02T12:15:04+00:00" + "time": "2019-09-17T11:12:18+00:00" }, { "name": "symfony/var-dumper", - "version": "v4.3.4", + "version": "v4.3.8", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "641043e0f3e615990a0f29479f9c117e8a6698c6" + "reference": "ea4940845535c85ff5c505e13b3205b0076d07bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/641043e0f3e615990a0f29479f9c117e8a6698c6", - "reference": "641043e0f3e615990a0f29479f9c117e8a6698c6", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/ea4940845535c85ff5c505e13b3205b0076d07bf", + "reference": "ea4940845535c85ff5c505e13b3205b0076d07bf", "shasum": "" }, "require": { @@ -4166,25 +4335,27 @@ "debug", "dump" ], - "time": "2019-08-26T08:26:39+00:00" + "time": "2019-10-13T12:02:04+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", - "version": "2.2.1", + "version": "2.2.2", "source": { "type": "git", "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", - "reference": "0ed4a2ea4e0902dac0489e6436ebcd5bbcae9757" + "reference": "dda2ee426acd6d801d5b7fd1001cde9b5f790e15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0ed4a2ea4e0902dac0489e6436ebcd5bbcae9757", - "reference": "0ed4a2ea4e0902dac0489e6436ebcd5bbcae9757", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/dda2ee426acd6d801d5b7fd1001cde9b5f790e15", + "reference": "dda2ee426acd6d801d5b7fd1001cde9b5f790e15", "shasum": "" }, "require": { + "ext-dom": "*", + "ext-libxml": "*", "php": "^5.5 || ^7.0", - "symfony/css-selector": "^2.7 || ^3.0 || ^4.0" + "symfony/css-selector": "^2.7 || ^3.0 || ^4.0 || ^5.0" }, "require-dev": { "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0" @@ -4213,7 +4384,7 @@ ], "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", - "time": "2017-11-27T11:13:29+00:00" + "time": "2019-10-24T08:53:34+00:00" }, { "name": "vlucas/phpdotenv", @@ -4520,16 +4691,16 @@ }, { "name": "composer/composer", - "version": "1.9.0", + "version": "1.9.1", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "314aa57fdcfc942065996f59fb73a8b3f74f3fa5" + "reference": "bb01f2180df87ce7992b8331a68904f80439dd2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/314aa57fdcfc942065996f59fb73a8b3f74f3fa5", - "reference": "314aa57fdcfc942065996f59fb73a8b3f74f3fa5", + "url": "https://api.github.com/repos/composer/composer/zipball/bb01f2180df87ce7992b8331a68904f80439dd2f", + "reference": "bb01f2180df87ce7992b8331a68904f80439dd2f", "shasum": "" }, "require": { @@ -4596,7 +4767,7 @@ "dependency", "package" ], - "time": "2019-08-02T18:55:33+00:00" + "time": "2019-11-01T16:20:17+00:00" }, { "name": "composer/semver", @@ -4722,24 +4893,24 @@ }, { "name": "composer/xdebug-handler", - "version": "1.3.3", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/composer/xdebug-handler.git", - "reference": "46867cbf8ca9fb8d60c506895449eb799db1184f" + "reference": "cbe23383749496fe0f373345208b79568e4bc248" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/46867cbf8ca9fb8d60c506895449eb799db1184f", - "reference": "46867cbf8ca9fb8d60c506895449eb799db1184f", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/cbe23383749496fe0f373345208b79568e4bc248", + "reference": "cbe23383749496fe0f373345208b79568e4bc248", "shasum": "" }, "require": { - "php": "^5.3.2 || ^7.0", + "php": "^5.3.2 || ^7.0 || ^8.0", "psr/log": "^1.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5" + "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 8" }, "type": "library", "autoload": { @@ -4757,25 +4928,25 @@ "email": "john-stevenson@blueyonder.co.uk" } ], - "description": "Restarts a process without xdebug.", + "description": "Restarts a process without Xdebug.", "keywords": [ "Xdebug", "performance" ], - "time": "2019-05-27T17:52:04+00:00" + "time": "2019-11-06T16:40:04+00:00" }, { "name": "doctrine/instantiator", - "version": "1.2.0", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "a2c590166b2133a4633738648b6b064edae0814a" + "reference": "ae466f726242e637cebdd526a7d991b9433bacf1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/a2c590166b2133a4633738648b6b064edae0814a", - "reference": "a2c590166b2133a4633738648b6b064edae0814a", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/ae466f726242e637cebdd526a7d991b9433bacf1", + "reference": "ae466f726242e637cebdd526a7d991b9433bacf1", "shasum": "" }, "require": { @@ -4818,20 +4989,20 @@ "constructor", "instantiate" ], - "time": "2019-03-17T17:37:11+00:00" + "time": "2019-10-21T16:45:58+00:00" }, { "name": "facade/flare-client-php", - "version": "1.0.4", + "version": "1.1.2", "source": { "type": "git", "url": "https://github.com/facade/flare-client-php.git", - "reference": "7128b251b48f24ef64e5cddd7f8d40cc3a06fd3e" + "reference": "04c0bbd1881942f59e27877bac3b29ba57519666" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/facade/flare-client-php/zipball/7128b251b48f24ef64e5cddd7f8d40cc3a06fd3e", - "reference": "7128b251b48f24ef64e5cddd7f8d40cc3a06fd3e", + "url": "https://api.github.com/repos/facade/flare-client-php/zipball/04c0bbd1881942f59e27877bac3b29ba57519666", + "reference": "04c0bbd1881942f59e27877bac3b29ba57519666", "shasum": "" }, "require": { @@ -4843,7 +5014,7 @@ }, "require-dev": { "larapack/dd": "^1.1", - "phpunit/phpunit": "^7.0", + "phpunit/phpunit": "^7.5.16", "spatie/phpunit-snapshot-assertions": "^2.0" }, "type": "library", @@ -4872,26 +5043,26 @@ "flare", "reporting" ], - "time": "2019-09-11T14:19:56+00:00" + "time": "2019-11-08T11:11:17+00:00" }, { "name": "facade/ignition", - "version": "1.6.5", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/facade/ignition.git", - "reference": "97244f6d511332f3574acab8242c09ddcfda892b" + "reference": "67736a01597b9e08f00a1fc8966b92b918dba5ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/facade/ignition/zipball/97244f6d511332f3574acab8242c09ddcfda892b", - "reference": "97244f6d511332f3574acab8242c09ddcfda892b", + "url": "https://api.github.com/repos/facade/ignition/zipball/67736a01597b9e08f00a1fc8966b92b918dba5ea", + "reference": "67736a01597b9e08f00a1fc8966b92b918dba5ea", "shasum": "" }, "require": { "ext-json": "*", "ext-mbstring": "*", - "facade/flare-client-php": "^1.0.4", + "facade/flare-client-php": "^1.1", "facade/ignition-contracts": "^1.0", "filp/whoops": "^2.4", "illuminate/support": "~5.5.0 || ~5.6.0 || ~5.7.0 || ~5.8.0 || ^6.0", @@ -4926,7 +5097,10 @@ "autoload": { "psr-4": { "Facade\\Ignition\\": "src" - } + }, + "files": [ + "src/helpers.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4940,7 +5114,7 @@ "laravel", "page" ], - "time": "2019-09-13T13:38:04+00:00" + "time": "2019-11-14T10:51:35+00:00" }, { "name": "facade/ignition-contracts", @@ -5049,16 +5223,16 @@ }, { "name": "fzaninotto/faker", - "version": "v1.8.0", + "version": "v1.9.0", "source": { "type": "git", "url": "https://github.com/fzaninotto/Faker.git", - "reference": "f72816b43e74063c8b10357394b6bba8cb1c10de" + "reference": "27a216cbe72327b2d6369fab721a5843be71e57d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fzaninotto/Faker/zipball/f72816b43e74063c8b10357394b6bba8cb1c10de", - "reference": "f72816b43e74063c8b10357394b6bba8cb1c10de", + "url": "https://api.github.com/repos/fzaninotto/Faker/zipball/27a216cbe72327b2d6369fab721a5843be71e57d", + "reference": "27a216cbe72327b2d6369fab721a5843be71e57d", "shasum": "" }, "require": { @@ -5067,13 +5241,11 @@ "require-dev": { "ext-intl": "*", "phpunit/phpunit": "^4.8.35 || ^5.7", - "squizlabs/php_codesniffer": "^1.5" + "squizlabs/php_codesniffer": "^2.9.2" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "1.8-dev" - } + "branch-alias": [] }, "autoload": { "psr-4": { @@ -5095,7 +5267,7 @@ "faker", "fixtures" ], - "time": "2018-07-12T10:23:15+00:00" + "time": "2019-11-14T13:13:06+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -5235,23 +5407,23 @@ }, { "name": "justinrainbow/json-schema", - "version": "5.2.8", + "version": "5.2.9", "source": { "type": "git", "url": "https://github.com/justinrainbow/json-schema.git", - "reference": "dcb6e1006bb5fd1e392b4daa68932880f37550d4" + "reference": "44c6787311242a979fa15c704327c20e7221a0e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/dcb6e1006bb5fd1e392b4daa68932880f37550d4", - "reference": "dcb6e1006bb5fd1e392b4daa68932880f37550d4", + "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/44c6787311242a979fa15c704327c20e7221a0e4", + "reference": "44c6787311242a979fa15c704327c20e7221a0e4", "shasum": "" }, "require": { "php": ">=5.3.3" }, "require-dev": { - "friendsofphp/php-cs-fixer": "~2.2.20", + "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", "json-schema/json-schema-test-suite": "1.2.0", "phpunit/phpunit": "^4.8.35" }, @@ -5297,7 +5469,7 @@ "json", "schema" ], - "time": "2019-01-14T23:55:14+00:00" + "time": "2019-09-25T14:49:45+00:00" }, { "name": "laravel/browser-kit-testing", @@ -5362,25 +5534,25 @@ }, { "name": "maximebf/debugbar", - "version": "v1.15.0", + "version": "v1.15.1", "source": { "type": "git", "url": "https://github.com/maximebf/php-debugbar.git", - "reference": "30e7d60937ee5f1320975ca9bc7bcdd44d500f07" + "reference": "6c4277f6117e4864966c9cb58fb835cee8c74a1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/30e7d60937ee5f1320975ca9bc7bcdd44d500f07", - "reference": "30e7d60937ee5f1320975ca9bc7bcdd44d500f07", + "url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/6c4277f6117e4864966c9cb58fb835cee8c74a1e", + "reference": "6c4277f6117e4864966c9cb58fb835cee8c74a1e", "shasum": "" }, "require": { - "php": ">=5.3.0", + "php": ">=5.6", "psr/log": "^1.0", - "symfony/var-dumper": "^2.6|^3.0|^4.0" + "symfony/var-dumper": "^2.6|^3|^4" }, "require-dev": { - "phpunit/phpunit": "^4.0|^5.0" + "phpunit/phpunit": "^5" }, "suggest": { "kriswallsmith/assetic": "The best way to manage assets", @@ -5390,7 +5562,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.14-dev" + "dev-master": "1.15-dev" } }, "autoload": { @@ -5419,20 +5591,20 @@ "debug", "debugbar" ], - "time": "2017-12-15T11:13:46+00:00" + "time": "2019-09-24T14:55:42+00:00" }, { "name": "mockery/mockery", - "version": "1.2.3", + "version": "1.2.4", "source": { "type": "git", "url": "https://github.com/mockery/mockery.git", - "reference": "4eff936d83eb809bde2c57a3cea0ee9643769031" + "reference": "b3453f75fd23d9fd41685f2148f4abeacabc6405" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mockery/mockery/zipball/4eff936d83eb809bde2c57a3cea0ee9643769031", - "reference": "4eff936d83eb809bde2c57a3cea0ee9643769031", + "url": "https://api.github.com/repos/mockery/mockery/zipball/b3453f75fd23d9fd41685f2148f4abeacabc6405", + "reference": "b3453f75fd23d9fd41685f2148f4abeacabc6405", "shasum": "" }, "require": { @@ -5446,7 +5618,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.2.x-dev" } }, "autoload": { @@ -5484,7 +5656,7 @@ "test double", "testing" ], - "time": "2019-08-07T15:01:07+00:00" + "time": "2019-09-30T08:30:27+00:00" }, { "name": "myclabs/deep-copy", @@ -5901,22 +6073,22 @@ }, { "name": "phpspec/prophecy", - "version": "1.8.1", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "1927e75f4ed19131ec9bcc3b002e07fb1173ee76" + "reference": "f6811d96d97bdf400077a0cc100ae56aa32b9203" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/1927e75f4ed19131ec9bcc3b002e07fb1173ee76", - "reference": "1927e75f4ed19131ec9bcc3b002e07fb1173ee76", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/f6811d96d97bdf400077a0cc100ae56aa32b9203", + "reference": "f6811d96d97bdf400077a0cc100ae56aa32b9203", "shasum": "" }, "require": { "doctrine/instantiator": "^1.0.2", "php": "^5.3|^7.0", - "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0", + "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0|^5.0", "sebastian/comparator": "^1.1|^2.0|^3.0", "sebastian/recursion-context": "^1.0|^2.0|^3.0" }, @@ -5960,20 +6132,20 @@ "spy", "stub" ], - "time": "2019-06-13T12:50:23+00:00" + "time": "2019-10-03T11:07:50+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "7.0.7", + "version": "7.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "7743bbcfff2a907e9ee4a25be13d0f8ec5e73800" + "reference": "aa0d179a13284c7420fc281fc32750e6cc7c9e2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7743bbcfff2a907e9ee4a25be13d0f8ec5e73800", - "reference": "7743bbcfff2a907e9ee4a25be13d0f8ec5e73800", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/aa0d179a13284c7420fc281fc32750e6cc7c9e2f", + "reference": "aa0d179a13284c7420fc281fc32750e6cc7c9e2f", "shasum": "" }, "require": { @@ -5982,7 +6154,7 @@ "php": "^7.2", "phpunit/php-file-iterator": "^2.0.2", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-token-stream": "^3.1.0", + "phpunit/php-token-stream": "^3.1.1", "sebastian/code-unit-reverse-lookup": "^1.0.1", "sebastian/environment": "^4.2.2", "sebastian/version": "^2.0.1", @@ -6023,7 +6195,7 @@ "testing", "xunit" ], - "time": "2019-07-25T05:31:54+00:00" + "time": "2019-09-17T06:24:36+00:00" }, { "name": "phpunit/php-file-iterator", @@ -6167,16 +6339,16 @@ }, { "name": "phpunit/php-token-stream", - "version": "3.1.0", + "version": "3.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "e899757bb3df5ff6e95089132f32cd59aac2220a" + "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/e899757bb3df5ff6e95089132f32cd59aac2220a", - "reference": "e899757bb3df5ff6e95089132f32cd59aac2220a", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/995192df77f63a59e47f025390d2d1fdf8f425ff", + "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff", "shasum": "" }, "require": { @@ -6212,20 +6384,20 @@ "keywords": [ "tokenizer" ], - "time": "2019-07-25T05:29:42+00:00" + "time": "2019-09-17T06:23:10+00:00" }, { "name": "phpunit/phpunit", - "version": "8.3.5", + "version": "8.4.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "302faed7059fde575cf3403a78c730c5e3a62750" + "reference": "67f9e35bffc0dd52d55d565ddbe4230454fd6a4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/302faed7059fde575cf3403a78c730c5e3a62750", - "reference": "302faed7059fde575cf3403a78c730c5e3a62750", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/67f9e35bffc0dd52d55d565ddbe4230454fd6a4e", + "reference": "67f9e35bffc0dd52d55d565ddbe4230454fd6a4e", "shasum": "" }, "require": { @@ -6269,7 +6441,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "8.3-dev" + "dev-master": "8.4-dev" } }, "autoload": { @@ -6295,7 +6467,7 @@ "testing", "xunit" ], - "time": "2019-09-14T09:12:03+00:00" + "time": "2019-11-06T09:42:23+00:00" }, { "name": "scrivo/highlight.php", @@ -7020,16 +7192,16 @@ }, { "name": "seld/jsonlint", - "version": "1.7.1", + "version": "1.7.2", "source": { "type": "git", "url": "https://github.com/Seldaek/jsonlint.git", - "reference": "d15f59a67ff805a44c50ea0516d2341740f81a38" + "reference": "e2e5d290e4d2a4f0eb449f510071392e00e10d19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/d15f59a67ff805a44c50ea0516d2341740f81a38", - "reference": "d15f59a67ff805a44c50ea0516d2341740f81a38", + "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/e2e5d290e4d2a4f0eb449f510071392e00e10d19", + "reference": "e2e5d290e4d2a4f0eb449f510071392e00e10d19", "shasum": "" }, "require": { @@ -7065,7 +7237,7 @@ "parser", "validator" ], - "time": "2018-01-24T12:46:19+00:00" + "time": "2019-10-24T14:27:39+00:00" }, { "name": "seld/phar-utils", @@ -7113,16 +7285,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.4.2", + "version": "3.5.2", "source": { "type": "git", "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "b8a7362af1cc1aadb5bd36c3defc4dda2cf5f0a8" + "reference": "65b12cdeaaa6cd276d4c3033a95b9b88b12701e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/b8a7362af1cc1aadb5bd36c3defc4dda2cf5f0a8", - "reference": "b8a7362af1cc1aadb5bd36c3defc4dda2cf5f0a8", + "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/65b12cdeaaa6cd276d4c3033a95b9b88b12701e7", + "reference": "65b12cdeaaa6cd276d4c3033a95b9b88b12701e7", "shasum": "" }, "require": { @@ -7160,20 +7332,20 @@ "phpcs", "standards" ], - "time": "2019-04-10T23:49:02+00:00" + "time": "2019-10-28T04:36:32+00:00" }, { "name": "symfony/dom-crawler", - "version": "v4.3.4", + "version": "v4.3.8", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "cc686552948d627528c0e2e759186dff67c2610e" + "reference": "4b9efd5708c3a38593e19b6a33e40867f4f89d72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/cc686552948d627528c0e2e759186dff67c2610e", - "reference": "cc686552948d627528c0e2e759186dff67c2610e", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/4b9efd5708c3a38593e19b6a33e40867f4f89d72", + "reference": "4b9efd5708c3a38593e19b6a33e40867f4f89d72", "shasum": "" }, "require": { @@ -7221,11 +7393,11 @@ ], "description": "Symfony DomCrawler Component", "homepage": "https://symfony.com", - "time": "2019-08-26T08:26:39+00:00" + "time": "2019-10-28T17:07:32+00:00" }, { "name": "symfony/filesystem", - "version": "v4.3.4", + "version": "v4.3.8", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index 37de5dcc1..fd687f041 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -19,6 +19,7 @@ return [ 'ldap_cannot_connect' => 'Cannot connect to ldap server, Initial connection failed', 'saml_already_logged_in' => 'Already logged in', 'saml_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', 'social_no_action_defined' => 'No action defined', 'social_login_bad_response' => "Error received during :socialAccount login: \n:error", 'social_account_in_use' => 'This :socialAccount account is already in use, Try logging in via the :socialAccount option.', From 9bba84684fa53a6c4f38c4fd7cea08552208b228 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 16 Nov 2019 15:24:09 +0000 Subject: [PATCH 06/12] Appeased codeclimate by extracting out external_auth_id group matching --- app/Auth/Access/ExternalAuthService.php | 29 +++++++++++++++---------- app/Auth/Access/Saml2Service.php | 3 +-- app/Auth/Role.php | 7 ++++++ 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/app/Auth/Access/ExternalAuthService.php b/app/Auth/Access/ExternalAuthService.php index 77c7d1351..4bd8f8680 100644 --- a/app/Auth/Access/ExternalAuthService.php +++ b/app/Auth/Access/ExternalAuthService.php @@ -9,26 +9,33 @@ class ExternalAuthService /** * 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 */ - protected function roleMatchesGroupNames(Role $role, array $groupNames) + protected function roleMatchesGroupNames(Role $role, array $groupNames): bool { 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; + return $this->externalIdMatchesGroupNames($role->external_auth_id, $groupNames); } $roleName = str_replace(' ', '-', trim(strtolower($role->display_name))); return in_array($roleName, $groupNames); } + /** + * Check if the given external auth ID string matches one of the given group names. + */ + protected function externalIdMatchesGroupNames(string $externalId, array $groupNames): bool + { + $externalAuthIds = explode(',', strtolower($externalId)); + + foreach ($externalAuthIds as $externalAuthId) { + if (in_array(trim($externalAuthId), $groupNames)) { + return true; + } + } + + return false; + } + /** * Match an array of group names to BookStack system roles. * Formats group names to be lower-case and hyphenated. diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index bb57ceb73..57db3ce65 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -147,10 +147,9 @@ class Saml2Service extends ExternalAuthService 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'] ?? '', + 'email' => $userDetails['email'], 'password' => Str::random(32), 'external_auth_id' => $userDetails['external_id'], 'email_confirmed' => true, diff --git a/app/Auth/Role.php b/app/Auth/Role.php index 712f5299b..3342ef5a8 100644 --- a/app/Auth/Role.php +++ b/app/Auth/Role.php @@ -4,6 +4,13 @@ use BookStack\Auth\Permissions\JointPermission; use BookStack\Auth\Permissions\RolePermission; use BookStack\Model; +/** + * Class Role + * @property string $display_name + * @property string $description + * @property string $external_auth_id + * @package BookStack\Auth + */ class Role extends Model { From 3a17ba2cb9e923d70df6439be242df49918389fa Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 17 Nov 2019 13:26:43 +0000 Subject: [PATCH 07/12] Started using OneLogin SAML lib directly - Aligned and formatted config options. - Provided way to override onelogin lib options if required. - Added endpoints in core bookstack routes. - Provided way to debug details provided by idp and formatted by bookstack. - Started on test work - Handled case of email address already in use. --- app/Auth/Access/Saml2Service.php | 132 +++++++++- app/Config/app.php | 1 - app/Config/saml2.php | 145 +++++++++++ app/Config/saml2_settings.php | 241 ------------------ app/Config/services.php | 12 - app/Exceptions/JsonDebugException.php | 25 ++ app/Http/Controllers/Auth/LoginController.php | 2 +- app/Http/Controllers/Auth/Saml2Controller.php | 71 ++++++ app/Http/Kernel.php | 5 - app/Http/Middleware/VerifyCsrfToken.php | 2 +- app/Listeners/Saml2LoginEventListener.php | 42 --- app/Providers/EventServiceProvider.php | 4 - composer.json | 4 +- composer.lock | 61 +---- phpunit.xml | 1 + resources/lang/en/errors.php | 3 + resources/views/auth/login.blade.php | 12 +- resources/views/settings/roles/form.blade.php | 2 +- resources/views/users/form.blade.php | 2 +- routes/web.php | 9 + tests/Auth/Saml2.php | 47 ++++ 21 files changed, 442 insertions(+), 381 deletions(-) create mode 100644 app/Config/saml2.php delete mode 100644 app/Config/saml2_settings.php create mode 100644 app/Exceptions/JsonDebugException.php create mode 100644 app/Http/Controllers/Auth/Saml2Controller.php delete mode 100644 app/Listeners/Saml2LoginEventListener.php create mode 100644 tests/Auth/Saml2.php diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index 57db3ce65..8266c3ba3 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -2,8 +2,11 @@ use BookStack\Auth\User; use BookStack\Auth\UserRepo; +use BookStack\Exceptions\JsonDebugException; use BookStack\Exceptions\SamlException; use Illuminate\Support\Str; +use OneLogin\Saml2\Auth; +use OneLogin\Saml2\Error; /** * Class Saml2Service @@ -21,10 +24,119 @@ class Saml2Service extends ExternalAuthService */ public function __construct(UserRepo $userRepo, User $user) { - $this->config = config('services.saml'); + $this->config = config('saml2'); $this->userRepo = $userRepo; $this->user = $user; - $this->enabled = config('saml2_settings.enabled') === true; + $this->enabled = config('saml2.enabled') === true; + } + + /** + * Initiate a login flow. + * @throws \OneLogin\Saml2\Error + */ + public function login(): array + { + $toolKit = $this->getToolkit(); + $returnRoute = url('/saml2/acs'); + return [ + 'url' => $toolKit->login($returnRoute, [], false, false, true), + 'id' => $toolKit->getLastRequestID(), + ]; + } + + /** + * 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 JsonDebugException + */ + public function processAcsResponse(?string $requestId): ?User + { + $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) + ); + } + + if (!$toolkit->isAuthenticated()) { + return null; + } + + $attrs = $toolkit->getAttributes(); + $id = $toolkit->getNameId(); + + return $this->processLoginCallback($id, $attrs); + } + + /** + * 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 \OneLogin\Saml2\Error + */ + protected function getToolkit(): Auth + { + $settings = $this->config['onelogin']; + $overrides = $this->config['onelogin_overrides'] ?? []; + + if ($overrides && is_string($overrides)) { + $overrides = json_decode($overrides, true); + } + + $spSettings = $this->loadOneloginServiceProviderDetails(); + $settings = array_replace_recursive($settings, $spSettings, $overrides); + return new Auth($settings); + } + + /** + * Load dynamic service provider options required by the onelogin toolkit. + */ + protected function loadOneloginServiceProviderDetails(): array + { + $spDetails = [ + 'entityId' => url('/saml2/metadata'), + 'assertionConsumerService' => [ + 'url' => url('/saml2/acs'), + ], + 'singleLogoutService' => [ + 'url' => url('/saml2/sls') + ], + ]; + + return [ + 'baseurl' => url('/saml2'), + 'sp' => $spDetails + ]; } /** @@ -155,7 +267,11 @@ class Saml2Service extends ExternalAuthService 'email_confirmed' => true, ]; - // TODO - Handle duplicate email address scenario + $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); @@ -167,7 +283,7 @@ class Saml2Service extends ExternalAuthService */ protected function getOrRegisterUser(array $userDetails): ?User { - $isRegisterEnabled = config('services.saml.auto_register') === true; + $isRegisterEnabled = $this->config['auto_register'] === true; $user = $this->user ->where('external_auth_id', $userDetails['external_id']) ->first(); @@ -183,12 +299,20 @@ 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 */ 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([ + 'attrs_from_idp' => $samlAttributes, + 'attrs_after_parsing' => $userDetails, + ]); + } + if ($isLoggedIn) { throw new SamlException(trans('errors.saml_already_logged_in'), '/login'); } diff --git a/app/Config/app.php b/app/Config/app.php index 9dae697da..0d06a9b21 100755 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -105,7 +105,6 @@ return [ Intervention\Image\ImageServiceProvider::class, Barryvdh\DomPDF\ServiceProvider::class, Barryvdh\Snappy\ServiceProvider::class, - Aacotroneo\Saml2\Saml2ServiceProvider::class, // BookStack replacement service providers (Extends Laravel) BookStack\Providers\PaginationServiceProvider::class, diff --git a/app/Config/saml2.php b/app/Config/saml2.php new file mode 100644 index 000000000..bcfddc534 --- /dev/null +++ b/app/Config/saml2.php @@ -0,0 +1,145 @@ + env('SAML2_NAME', 'SSO'), + // Toggle whether the SAML2 option is active + 'enabled' => env('SAML2_ENABLED', false), + // Enable registration via SAML2 authentication + 'auto_register' => env('SAML2_AUTO_REGISTER', true), + + // Dump user details after a login request for debugging purposes + 'dump_user_details' => env('SAML2_DUMP_USER_DETAILS', false), + + // Attribute, within a SAML response, to find the user's email address + 'email_attribute' => env('SAML2_EMAIL_ATTRIBUTE', 'email'), + // Attribute, within a SAML response, to find the user's display name + 'display_name_attributes' => explode('|', env('SAML2_DISPLAY_NAME_ATTRIBUTES', 'username')), + // Attribute, within a SAML response, to use to connect a BookStack user to the SAML user. + 'external_id_attribute' => env('SAML2_EXTERNAL_ID_ATTRIBUTE', null), + + // Group sync options + // Enable syncing, upon login, of SAML2 groups to BookStack groups + 'user_to_groups' => env('SAML2_USER_TO_GROUPS', false), + // Attribute, within a SAML response, to find group names on + 'group_attribute' => env('SAML2_GROUP_ATTRIBUTE', 'group'), + // When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups. + 'remove_from_groups' => env('SAML2_REMOVE_FROM_GROUPS', false), + + // Overrides, in JSON format, to the configuration passed to underlying onelogin library. + 'onelogin_overrides' => env('SAML2_ONELOGIN_OVERRIDES', null), + + + 'onelogin' => [ + // If 'strict' is True, then the PHP Toolkit will reject unsigned + // or unencrypted messages if it expects them signed or encrypted + // Also will reject the messages if not strictly follow the SAML + // standard: Destination, NameId, Conditions ... are validated too. + 'strict' => true, + + // Enable debug mode (to print errors) + 'debug' => env('APP_DEBUG', false), + + // Set a BaseURL to be used instead of try to guess + // the BaseURL of the view that process the SAML Message. + // Ex. http://sp.example.com/ + // http://example.com/sp/ + 'baseurl' => null, + + // Service Provider Data that we are deploying + 'sp' => [ + // Identifier of the SP entity (must be a URI) + 'entityId' => '', + + // Specifies info about where and how the message MUST be + // returned to the requester, in this case our SP. + 'assertionConsumerService' => [ + // URL Location where the from the IdP will be returned + 'url' => '', + // SAML protocol binding to be used when returning the + // message. Onelogin Toolkit supports for this endpoint the + // HTTP-POST binding only + 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + ], + + // Specifies info about where and how the message MUST be + // returned to the requester, in this case our SP. + 'singleLogoutService' => [ + // URL Location where the from the IdP will be returned + 'url' => '', + // SAML protocol binding to be used when returning the + // message. Onelogin Toolkit supports for this endpoint the + // HTTP-Redirect binding only + 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + ], + + // Specifies constraints on the name identifier to be used to + // represent the requested subject. + // Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported + 'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + // Usually x509cert and privateKey of the SP are provided by files placed at + // the certs folder. But we can also provide them with the following parameters + 'x509cert' => '', + 'privateKey' => '', + ], + // Identity Provider Data that we want connect with our SP + 'idp' => [ + // Identifier of the IdP entity (must be a URI) + 'entityId' => env('SAML2_IDP_ENTITYID', null), + // SSO endpoint info of the IdP. (Authentication Request protocol) + 'singleSignOnService' => [ + // URL Target of the IdP where the SP will send the Authentication Request Message + 'url' => env('SAML2_IDP_SSO', null), + // SAML protocol binding to be used when returning the + // message. Onelogin Toolkit supports for this endpoint the + // HTTP-Redirect binding only + 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + ], + // SLO endpoint info of the IdP. + 'singleLogoutService' => [ + // URL Location of the IdP where the SP will send the SLO Request + 'url' => env('SAML2_IDP_SLO', null), + // URL location of the IdP where the SP will send the SLO Response (ResponseLocation) + // if not set, url for the SLO Request will be used + 'responseUrl' => '', + // SAML protocol binding to be used when returning the + // message. Onelogin Toolkit supports for this endpoint the + // HTTP-Redirect binding only + 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + ], + // Public x509 certificate of the IdP + 'x509cert' => env('SAML2_IDP_x509', null), + /* + * Instead of use the whole x509cert you can use a fingerprint in + * order to validate the SAMLResponse, but we don't recommend to use + * that method on production since is exploitable by a collision + * attack. + * (openssl x509 -noout -fingerprint -in "idp.crt" to generate it, + * or add for example the -sha256 , -sha384 or -sha512 parameter) + * + * If a fingerprint is provided, then the certFingerprintAlgorithm is required in order to + * let the toolkit know which Algorithm was used. Possible values: sha1, sha256, sha384 or sha512 + * 'sha1' is the default value. + */ + // 'certFingerprint' => '', + // 'certFingerprintAlgorithm' => 'sha1', + /* In some scenarios the IdP uses different certificates for + * signing/encryption, or is under key rollover phase and more + * than one certificate is published on IdP metadata. + * In order to handle that the toolkit offers that parameter. + * (when used, 'x509cert' and 'certFingerprint' values are + * ignored). + */ + // 'x509certMulti' => array( + // 'signing' => array( + // 0 => '', + // ), + // 'encryption' => array( + // 0 => '', + // ) + // ), + ], + ], + +]; diff --git a/app/Config/saml2_settings.php b/app/Config/saml2_settings.php deleted file mode 100644 index 015763b46..000000000 --- a/app/Config/saml2_settings.php +++ /dev/null @@ -1,241 +0,0 @@ - env("SAML2_ENABLED", false), - - /** - * If 'useRoutes' is set to true, the package defines five new routes: - * - * Method | URI | Name - * -------|--------------------------|------------------ - * POST | {routesPrefix}/acs | saml_acs - * GET | {routesPrefix}/login | saml_login - * GET | {routesPrefix}/logout | saml_logout - * GET | {routesPrefix}/metadata | saml_metadata - * GET | {routesPrefix}/sls | saml_sls - */ - 'useRoutes' => true, - - 'routesPrefix' => '/saml2', - - /** - * which middleware group to use for the saml routes - * Laravel 5.2 will need a group which includes StartSession - */ - 'routesMiddleware' => ['saml'], - - /** - * Indicates how the parameters will be - * retrieved from the sls request for signature validation - */ - 'retrieveParametersFromServer' => false, - - /** - * Where to redirect after logout - */ - 'logoutRoute' => '/', - - /** - * Where to redirect after login if no other option was provided - */ - 'loginRoute' => '/', - - - /** - * Where to redirect after login if no other option was provided - */ - 'errorRoute' => '/', - - - - - /***** - * One Login Settings - */ - - - - // If 'strict' is True, then the PHP Toolkit will reject unsigned - // or unencrypted messages if it expects them signed or encrypted - // Also will reject the messages if not strictly follow the SAML - // standard: Destination, NameId, Conditions ... are validated too. - 'strict' => true, //@todo: make this depend on laravel config - - // Enable debug mode (to print errors) - 'debug' => env('APP_DEBUG', false), - - // If 'proxyVars' is True, then the Saml lib will trust proxy headers - // e.g X-Forwarded-Proto / HTTP_X_FORWARDED_PROTO. This is useful if - // your application is running behind a load balancer which terminates - // SSL. - 'proxyVars' => false, - - // Service Provider Data that we are deploying - 'sp' => array( - - // Specifies constraints on the name identifier to be used to - // represent the requested subject. - // Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported - 'NameIDFormat' => 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', - - // Usually x509cert and privateKey of the SP are provided by files placed at - // the certs folder. But we can also provide them with the following parameters - 'x509cert' => env('SAML2_SP_x509',''), - 'privateKey' => env('SAML2_SP_PRIVATEKEY',''), - - // Identifier (URI) of the SP entity. - // Leave blank to use the 'saml_metadata' route. - 'entityId' => env('SAML2_SP_ENTITYID',''), - - // Specifies info about where and how the message MUST be - // returned to the requester, in this case our SP. - 'assertionConsumerService' => array( - // URL Location where the from the IdP will be returned, - // using HTTP-POST binding. - // Leave blank to use the 'saml_acs' route - 'url' => '', - - 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', - ), - // Specifies info about where and how the message MUST be - // returned to the requester, in this case our SP. - // Remove this part to not include any URL Location in the metadata. - 'singleLogoutService' => array( - // URL Location where the from the IdP will be returned, - // using HTTP-Redirect binding. - // Leave blank to use the 'saml_sls' route - 'url' => '', - ), - ), - - // Identity Provider Data that we want connect with our SP - 'idp' => array( - // Identifier of the IdP entity (must be a URI) - 'entityId' => env('SAML2_IDP_ENTITYID', $idp_host . '/saml2/idp/metadata.php'), - // SSO endpoint info of the IdP. (Authentication Request protocol) - 'singleSignOnService' => array( - // URL Target of the IdP where the SP will send the Authentication Request Message, - // using HTTP-Redirect binding. - 'url' => env('SAML2_IDP_SSO', $idp_host . '/saml2/idp/SSOService.php'), - ), - // SLO endpoint info of the IdP. - 'singleLogoutService' => array( - // URL Location of the IdP where the SP will send the SLO Request, - // using HTTP-Redirect binding. - 'url' => env('SAML2_IDP_SLO', $idp_host . '/saml2/idp/SingleLogoutService.php'), - ), - // Public x509 certificate of the IdP - 'x509cert' => env('SAML2_IDP_x509', 'MIID/TCCAuWgAwIBAgIJAI4R3WyjjmB1MA0GCSqGSIb3DQEBCwUAMIGUMQswCQYDVQQGEwJBUjEVMBMGA1UECAwMQnVlbm9zIEFpcmVzMRUwEwYDVQQHDAxCdWVub3MgQWlyZXMxDDAKBgNVBAoMA1NJVTERMA8GA1UECwwIU2lzdGVtYXMxFDASBgNVBAMMC09yZy5TaXUuQ29tMSAwHgYJKoZIhvcNAQkBFhFhZG1pbmlAc2l1LmVkdS5hcjAeFw0xNDEyMDExNDM2MjVaFw0yNDExMzAxNDM2MjVaMIGUMQswCQYDVQQGEwJBUjEVMBMGA1UECAwMQnVlbm9zIEFpcmVzMRUwEwYDVQQHDAxCdWVub3MgQWlyZXMxDDAKBgNVBAoMA1NJVTERMA8GA1UECwwIU2lzdGVtYXMxFDASBgNVBAMMC09yZy5TaXUuQ29tMSAwHgYJKoZIhvcNAQkBFhFhZG1pbmlAc2l1LmVkdS5hcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbzW/EpEv+qqZzfT1Buwjg9nnNNVrxkCfuR9fQiQw2tSouS5X37W5h7RmchRt54wsm046PDKtbSz1NpZT2GkmHN37yALW2lY7MyVUC7itv9vDAUsFr0EfKIdCKgxCKjrzkZ5ImbNvjxf7eA77PPGJnQ/UwXY7W+cvLkirp0K5uWpDk+nac5W0JXOCFR1BpPUJRbz2jFIEHyChRt7nsJZH6ejzNqK9lABEC76htNy1Ll/D3tUoPaqo8VlKW3N3MZE0DB9O7g65DmZIIlFqkaMH3ALd8adodJtOvqfDU/A6SxuwMfwDYPjoucykGDu1etRZ7dF2gd+W+1Pn7yizPT1q8CAwEAAaNQME4wHQYDVR0OBBYEFPsn8tUHN8XXf23ig5Qro3beP8BuMB8GA1UdIwQYMBaAFPsn8tUHN8XXf23ig5Qro3beP8BuMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGu60odWFiK+DkQekozGnlpNBQz5lQ/bwmOWdktnQj6HYXu43e7sh9oZWArLYHEOyMUekKQAxOK51vbTHzzw66BZU91/nqvaOBfkJyZKGfluHbD0/hfOl/D5kONqI9kyTu4wkLQcYGyuIi75CJs15uA03FSuULQdY/Liv+czS/XYDyvtSLnu43VuAQWN321PQNhuGueIaLJANb2C5qq5ilTBUw6PxY9Z+vtMjAjTJGKEkE/tQs7CvzLPKXX3KTD9lIILmX5yUC3dLgjVKi1KGDqNApYGOMtjr5eoxPQrqDBmyx3flcy0dQTdLXud3UjWVW3N0PYgJtw5yBsS74QTGD4='), - /* - * Instead of use the whole x509cert you can use a fingerprint - * (openssl x509 -noout -fingerprint -in "idp.crt" to generate it) - */ - // 'certFingerprint' => '', - ), - - /*** - * OneLogin compression settings - * - */ - 'compress' => array( - /** Whether requests should be GZ encoded */ - 'requests' => true, - /** Whether responses should be GZ compressed */ - 'responses' => true, - ), - - /*** - * - * OneLogin advanced settings - * - * - */ - // Security settings - 'security' => array( - - /** signatures and encryptions offered */ - - // Indicates that the nameID of the sent by this SP - // will be encrypted. - 'nameIdEncrypted' => false, - - // Indicates whether the messages sent by this SP - // will be signed. [The Metadata of the SP will offer this info] - 'authnRequestsSigned' => false, - - // Indicates whether the messages sent by this SP - // will be signed. - 'logoutRequestSigned' => false, - - // Indicates whether the messages sent by this SP - // will be signed. - 'logoutResponseSigned' => false, - - /* Sign the Metadata - False || True (use sp certs) || array ( - keyFileName => 'metadata.key', - certFileName => 'metadata.crt' - ) - */ - 'signMetadata' => false, - - - /** signatures and encryptions required **/ - - // Indicates a requirement for the , and - // elements received by this SP to be signed. - 'wantMessagesSigned' => false, - - // Indicates a requirement for the elements received by - // this SP to be signed. [The Metadata of the SP will offer this info] - 'wantAssertionsSigned' => false, - - // Indicates a requirement for the NameID received by - // this SP to be encrypted. - 'wantNameIdEncrypted' => false, - - // Authentication context. - // Set to false and no AuthContext will be sent in the AuthNRequest, - // Set true or don't present thi parameter and you will get an AuthContext 'exact' 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport' - // Set an array with the possible auth context values: array ('urn:oasis:names:tc:SAML:2.0:ac:classes:Password', 'urn:oasis:names:tc:SAML:2.0:ac:classes:X509'), - 'requestedAuthnContext' => true, - ), - - // Contact information template, it is recommended to suply a technical and support contacts - 'contactPerson' => array( - 'technical' => array( - 'givenName' => 'name', - 'emailAddress' => 'no@reply.com' - ), - 'support' => array( - 'givenName' => 'Support', - 'emailAddress' => 'no@reply.com' - ), - ), - - // Organization information template, the info in en_US lang is recomended, add more if required - 'organization' => array( - 'en-US' => array( - 'name' => 'Name', - 'displayname' => 'Display Name', - 'url' => 'http://url' - ), - ), - -/* Interoperable SAML 2.0 Web Browser SSO Profile [saml2int] http://saml2int.org/profile/current - - 'authnRequestsSigned' => false, // SP SHOULD NOT sign the , - // MUST NOT assume that the IdP validates the sign - 'wantAssertionsSigned' => true, - 'wantAssertionsEncrypted' => true, // MUST be enabled if SSL/HTTPs is disabled - 'wantNameIdEncrypted' => false, -*/ - -); diff --git a/app/Config/services.php b/app/Config/services.php index 4f00d42c5..a3ddf4f4d 100644 --- a/app/Config/services.php +++ b/app/Config/services.php @@ -132,16 +132,4 @@ return [ 'tls_insecure' => env('LDAP_TLS_INSECURE', false), ], - 'saml' => [ - 'name' => env('SAML_NAME', 'SSO'), - 'enabled' => env('SAML2_ENABLED', false), - 'auto_register' => env('SAML_AUTO_REGISTER', false), - 'email_attribute' => env('SAML_EMAIL_ATTRIBUTE', 'email'), - 'display_name_attributes' => explode('|', env('SAML_DISPLAY_NAME_ATTRIBUTES', 'username')), - 'external_id_attribute' => env('SAML_EXTERNAL_ID_ATTRIBUTE', null), - 'group_attribute' => env('SAML_GROUP_ATTRIBUTE', 'group'), - 'remove_from_groups' => env('SAML_REMOVE_FROM_GROUPS', false), - 'user_to_groups' => env('SAML_USER_TO_GROUPS', false), - ] - ]; diff --git a/app/Exceptions/JsonDebugException.php b/app/Exceptions/JsonDebugException.php new file mode 100644 index 000000000..6314533ce --- /dev/null +++ b/app/Exceptions/JsonDebugException.php @@ -0,0 +1,25 @@ +data = $data; + } + + /** + * Covert this exception into a response. + */ + public function render() + { + return response()->json($this->data); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 0cb050a89..477d3d26b 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -119,7 +119,7 @@ class LoginController extends Controller { $socialDrivers = $this->socialAuthService->getActiveDrivers(); $authMethod = config('auth.method'); - $samlEnabled = config('services.saml.enabled') == true; + $samlEnabled = config('saml2.enabled') === true; if ($request->has('email')) { session()->flashInput([ diff --git a/app/Http/Controllers/Auth/Saml2Controller.php b/app/Http/Controllers/Auth/Saml2Controller.php new file mode 100644 index 000000000..d54e925bb --- /dev/null +++ b/app/Http/Controllers/Auth/Saml2Controller.php @@ -0,0 +1,71 @@ +samlService = $samlService; + } + + /** + * Start the login flow via SAML2. + */ + public function login() + { + $loginDetails = $this->samlService->login(); + session()->flash('saml2_request_id', $loginDetails['id']); + + return redirect($loginDetails['url']); + } + + /* + * Get the metadata for this SAML2 service provider. + */ + public function metadata() + { + $metaData = $this->samlService->metadata(); + return response()->make($metaData, 200, [ + 'Content-Type' => 'text/xml' + ]); + } + + /** + * Single logout service. + * Handle logout requests and responses. + */ + public function sls() + { + // TODO + } + + /** + * Assertion Consumer Service. + * Processes the SAML response from the IDP. + */ + public function acs() + { + $requestId = session()->pull('saml2_request_id', null); + + $user = $this->samlService->processAcsResponse($requestId); + if ($user === null) { + $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')])); + return redirect('/login'); + } + + return redirect()->intended(); + } + +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 027e469c8..f9752da09 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -39,11 +39,6 @@ class Kernel extends HttpKernel 'throttle:60,1', 'bindings', ], - 'saml' => [ - \BookStack\Http\Middleware\EncryptCookies::class, - \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, - \Illuminate\Session\Middleware\StartSession::class, - ], ]; /** diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 1a29a2b1d..bdeb26554 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -19,6 +19,6 @@ class VerifyCsrfToken extends Middleware * @var array */ protected $except = [ - // + 'saml2/*' ]; } diff --git a/app/Listeners/Saml2LoginEventListener.php b/app/Listeners/Saml2LoginEventListener.php deleted file mode 100644 index 74c4d6f27..000000000 --- a/app/Listeners/Saml2LoginEventListener.php +++ /dev/null @@ -1,42 +0,0 @@ -saml = $saml; - } - - /** - * Handle the event. - * - * @param Saml2LoginEvent $event - * @return void - */ - public function handle(Saml2LoginEvent $event) - { - $messageId = $event->getSaml2Auth()->getLastMessageId(); - // TODO: Add your own code preventing reuse of a $messageId to stop replay attacks - - $samlUser = $event->getSaml2User(); - - $attrs = $samlUser->getAttributes(); - $id = $samlUser->getUserId(); - //$assertion = $user->getRawSamlAssertion() - - $user = $this->saml->processLoginCallback($id, $attrs); - } -} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 50436916a..a826185d8 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -4,7 +4,6 @@ namespace BookStack\Providers; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use SocialiteProviders\Manager\SocialiteWasCalled; -use Aacotroneo\Saml2\Events\Saml2LoginEvent; class EventServiceProvider extends ServiceProvider { @@ -22,9 +21,6 @@ class EventServiceProvider extends ServiceProvider 'SocialiteProviders\Twitch\TwitchExtendSocialite@handle', 'SocialiteProviders\Discord\DiscordExtendSocialite@handle', ], - Saml2LoginEvent::class => [ - 'BookStack\Listeners\Saml2LoginEventListener@handle', - ] ]; /** diff --git a/composer.json b/composer.json index 1d952a0c5..98cfa1e2a 100644 --- a/composer.json +++ b/composer.json @@ -22,14 +22,14 @@ "laravel/framework": "^6.0", "laravel/socialite": "^4.2", "league/flysystem-aws-s3-v3": "^1.0", + "onelogin/php-saml": "^3.3", "predis/predis": "^1.1", "socialiteproviders/discord": "^2.0", "socialiteproviders/gitlab": "^3.0", "socialiteproviders/microsoft-azure": "^3.0", "socialiteproviders/okta": "^1.0", "socialiteproviders/slack": "^3.0", - "socialiteproviders/twitch": "^5.0", - "aacotroneo/laravel-saml2": "^1.0" + "socialiteproviders/twitch": "^5.0" }, "require-dev": { "barryvdh/laravel-debugbar": "^3.2.8", diff --git a/composer.lock b/composer.lock index 6de48b13a..346adb47c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,67 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "42d7a337f6d603ab247b525ade5c3cee", + "content-hash": "140c7a04a20cef6d7ed8c1fc48257e66", "packages": [ - { - "name": "aacotroneo/laravel-saml2", - "version": "1.0.0", - "source": { - "type": "git", - "url": "https://github.com/aacotroneo/laravel-saml2.git", - "reference": "5045701a07bcd7600a17c92971368669870f546a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/aacotroneo/laravel-saml2/zipball/5045701a07bcd7600a17c92971368669870f546a", - "reference": "5045701a07bcd7600a17c92971368669870f546a", - "shasum": "" - }, - "require": { - "ext-openssl": "*", - "illuminate/support": ">=5.0.0", - "onelogin/php-saml": "^3.0.0", - "php": ">=5.4.0" - }, - "require-dev": { - "mockery/mockery": "0.9.*" - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Aacotroneo\\Saml2\\Saml2ServiceProvider" - ], - "aliases": { - "Saml2": "Aacotroneo\\Saml2\\Facades\\Saml2Auth" - } - } - }, - "autoload": { - "psr-0": { - "Aacotroneo\\Saml2\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "aacotroneo", - "email": "aacotroneo@gmail.com" - } - ], - "description": "A Laravel package for Saml2 integration as a SP (service provider) based on OneLogin toolkit, which is much lightweight than simplesamlphp", - "homepage": "https://github.com/aacotroneo/laravel-saml2", - "keywords": [ - "SAML2", - "laravel", - "onelogin", - "saml" - ], - "time": "2018-11-08T14:03:58+00:00" - }, { "name": "aws/aws-sdk-php", "version": "3.117.2", diff --git a/phpunit.xml b/phpunit.xml index 9f83e95ff..48eba5e99 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -48,5 +48,6 @@ + diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index fd687f041..a7c591c5d 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -20,6 +20,9 @@ return [ 'saml_already_logged_in' => 'Already logged in', 'saml_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', 'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.', + 'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization', + 'saml_email_exists' => 'Registration unsuccessful since a user already exists with email address ":email"', 'social_no_action_defined' => 'No action defined', 'social_login_bad_response' => "Error received during :socialAccount login: \n:error", 'social_account_in_use' => 'This :socialAccount account is already in use, Try logging in via the :socialAccount option.', diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 7e4a3992b..4ac7a50e0 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -46,13 +46,13 @@ @endif @if($samlEnabled) -
- @endif @if(setting('registration-enabled', false)) diff --git a/resources/views/settings/roles/form.blade.php b/resources/views/settings/roles/form.blade.php index 94e63b158..4617b1f52 100644 --- a/resources/views/settings/roles/form.blade.php +++ b/resources/views/settings/roles/form.blade.php @@ -19,7 +19,7 @@ @include('form.text', ['name' => 'description']) - @if(config('auth.method') === 'ldap' || config('services.saml.enabled') === true) + @if(config('auth.method') === 'ldap' || config('saml2.enabled') === true)
@include('form.text', ['name' => 'external_auth_id']) diff --git a/resources/views/users/form.blade.php b/resources/views/users/form.blade.php index 6c08cad44..6eafd43bc 100644 --- a/resources/views/users/form.blade.php +++ b/resources/views/users/form.blade.php @@ -25,7 +25,7 @@
-@if(($authMethod === 'ldap' || config('services.saml.enabled') === true) && userCan('users-manage')) +@if(($authMethod === 'ldap' || config('saml2.enabled') === true) && userCan('users-manage'))
diff --git a/routes/web.php b/routes/web.php index eafb6a45c..461d3c1aa 100644 --- a/routes/web.php +++ b/routes/web.php @@ -216,6 +216,15 @@ Route::post('/register/confirm/resend', 'Auth\ConfirmEmailController@resend'); Route::get('/register/confirm/{token}', 'Auth\ConfirmEmailController@confirm'); Route::post('/register', 'Auth\RegisterController@postRegister'); +// SAML routes +// TODO - Prevent access without SAML2 enabled via middleware +Route::get('/saml2/login', 'Auth\Saml2Controller@login'); +// TODO - Handle logout? +// Route::get('/saml2/logout', 'Auth\Saml2Controller@logout'); +Route::get('/saml2/metadata', 'Auth\Saml2Controller@metadata'); +Route::get('/saml2/sls', 'Auth\Saml2Controller@sls'); +Route::post('/saml2/acs', 'Auth\Saml2Controller@acs'); + // User invitation routes Route::get('/register/invite/{token}', 'Auth\UserInviteController@showSetPassword'); Route::post('/register/invite/{token}', 'Auth\UserInviteController@setPassword'); diff --git a/tests/Auth/Saml2.php b/tests/Auth/Saml2.php new file mode 100644 index 000000000..65b2ed3ad --- /dev/null +++ b/tests/Auth/Saml2.php @@ -0,0 +1,47 @@ +set([ + 'saml2.name' => 'SingleSignOn', + 'saml2.enabled' => true, + 'saml2.auto_register' => true, + 'saml2.email_attribute' => 'email', + 'saml2.display_name_attributes' => 'username', + 'saml2.external_id_attribute' => 'external_id', + 'saml2.user_to_groups' => false, + 'saml2.group_attribute' => 'group', + 'saml2.remove_from_groups' => false, + 'saml2.onelogin_overrides' => null, + 'saml2.onelogin.idp.entityId' => 'https://example.com/saml2/idp/metadata', + 'saml2.onelogin.idp.singleSignOnService.url' => 'https://example.com/saml2/idp/sso', + 'saml2.onelogin.idp.singleLogoutService.url' => 'https://example.com/saml2/idp/sls', + 'saml2.onelogin.idp.x509cert' => 'MIIEazCCAtOgAwIBAgIUe7a088Cnr4izmrnBEnx5q3HTMvYwDQYJKoZIhvcNAQELBQAwRTELMAkGA1UEBhMCR0IxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0xOTExMTYxMjE3MTVaFw0yOTExMTUxMjE3MTVaMEUxCzAJBgNVBAYTAkdCMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQDzLe9FfdyplTxHp4SuQ9gQtZT3t+SDfvEL72ppCfFZw7+B5s5B/T73aXpoQ3S53pGI1RIWCge2iCUQ2tzm27aSNH0iu9aJYcUQZ/RITqd0ayyDks1NA2PT3TW6t3m7KV5re4P0Nb+YDeuyHdkz+jcMtpn8CmBoT0H+skha0hiqINkjkRPiHvLHVGp+tHUEA/I6mN4aB/UExSTLs79NsLUfteqqxe9+tvdUaToyDPrhPFjONs+9NKCkzIC6vcv7J6AtuKG6nET+zB9yOWgtGYQifXqQA2y5dL81BB0q5uMaBLS2pq3aPPjzU2F3+EysjySWTnCkfk7C5SsCXRu8Q+U95tunpNfwf5olE6Was48NMM+PwV7iCNMPkNzllq6PCiM+P8DrMSczzUZZQUSv6dSwPCo+YSVimEM0Og3XJTiNhQ5ANlaIn66Kw5gfoBfuiXmyIKiSDyAiDYmFaf4395wWwLkTR+cw8WfjaHswKZTomn1MR3OJsY2UJ0eRBYM+YSsCAwEAAaNTMFEwHQYDVR0OBBYEFImp2CYCGfcb7w91H/cShTCkXwR/MB8GA1UdIwQYMBaAFImp2CYCGfcb7w91H/cShTCkXwR/MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggGBAA+g/C7uL9ln+W+qBknLW81kojYflgPK1I1MHIwnMvl/ZTHX4dRXKDrk7KcUq1KjqajNV66f1cakp03IijBiO0Xi1gXUZYLoCiNGUyyp9XloiIy9Xw2PiWnrw0+yZyvVssbehXXYJl4RihBjBWul9R4wMYLOUSJDe2WxcUBhJnxyNRs+P0xLSQX6B2n6nxoDko4p07s8ZKXQkeiZ2iwFdTxzRkGjthMUv704nzsVGBT0DCPtfSaO5KJZW1rCs3yiMthnBxq4qEDOQJFIl+/LD71KbB9vZcW5JuavzBFmkKGNro/6G1I7el46IR4wijTyNFCYUuD9dtignNmpWtN8OW+ptiL/jtTySWukjys0s+vLn83CVvjB0dJtVAIYOgXFdIuii66gczwwM/LGiOExJn0dTNzsJ/IYhpxL4FBEuP0pskY0o0aUlJ2LS2j+wSQTRKsBgMjyrUrekle2ODStStn3eabjIx0/FHlpFr0jNIm/oMP7kwjtUX4zaNe47QI4Gg==', + ]); + } + + public function test_metadata_endpoint_displays_xml_as_expected() + { + $req = $this->get('/saml2/metadata'); + $req->assertHeader('Content-Type', 'text/xml; charset=UTF-8'); + $req->assertSee('md:EntityDescriptor'); + $req->assertSee(url('/saml2/acs')); + } + + public function test_onelogin_overrides_functions_as_expected() + { + $json = '{"sp": {"assertionConsumerService": {"url": "https://example.com/super-cats"}}, "contactPerson": {"technical": {"givenName": "Barry Scott", "emailAddress": "barry@example.com"}}}'; + config()->set(['saml2.onelogin_overrides' => $json]); + + $req = $this->get('/saml2/metadata'); + $req->assertSee('https://example.com/super-cats'); + $req->assertSee('md:ContactPerson'); + $req->assertSee('Barry Scott'); + } +} From 488325f4593ec0dd88cd8b4cfda3b9a20e9489e4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 17 Nov 2019 14:44:26 +0000 Subject: [PATCH 08/12] Added the ability to auto-load config from metadata url --- app/Auth/Access/Saml2Service.php | 9 ++++++++- app/Config/saml2.php | 3 +++ tests/Auth/Saml2.php | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index 8266c3ba3..a5ca54c8d 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -7,6 +7,7 @@ use BookStack\Exceptions\SamlException; use Illuminate\Support\Str; use OneLogin\Saml2\Auth; use OneLogin\Saml2\Error; +use OneLogin\Saml2\IdPMetadataParser; /** * Class Saml2Service @@ -103,6 +104,7 @@ class Saml2Service extends ExternalAuthService /** * Load the underlying Onelogin SAML2 toolkit. * @throws \OneLogin\Saml2\Error + * @throws \Exception */ protected function getToolkit(): Auth { @@ -113,8 +115,13 @@ class Saml2Service extends ExternalAuthService $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, $overrides); + $settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides); return new Auth($settings); } diff --git a/app/Config/saml2.php b/app/Config/saml2.php index bcfddc534..2f2ad14f1 100644 --- a/app/Config/saml2.php +++ b/app/Config/saml2.php @@ -27,6 +27,9 @@ return [ // When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups. 'remove_from_groups' => env('SAML2_REMOVE_FROM_GROUPS', false), + // Autoload IDP details from the metadata endpoint + 'autoload_from_metadata' => env('SAML2_AUTOLOAD_METADATA', false), + // Overrides, in JSON format, to the configuration passed to underlying onelogin library. 'onelogin_overrides' => env('SAML2_ONELOGIN_OVERRIDES', null), diff --git a/tests/Auth/Saml2.php b/tests/Auth/Saml2.php index 65b2ed3ad..90561896a 100644 --- a/tests/Auth/Saml2.php +++ b/tests/Auth/Saml2.php @@ -22,6 +22,7 @@ class Saml2 extends TestCase 'saml2.onelogin.idp.entityId' => 'https://example.com/saml2/idp/metadata', 'saml2.onelogin.idp.singleSignOnService.url' => 'https://example.com/saml2/idp/sso', 'saml2.onelogin.idp.singleLogoutService.url' => 'https://example.com/saml2/idp/sls', + 'saml2.autoload_from_metadata' => false, 'saml2.onelogin.idp.x509cert' => 'MIIEazCCAtOgAwIBAgIUe7a088Cnr4izmrnBEnx5q3HTMvYwDQYJKoZIhvcNAQELBQAwRTELMAkGA1UEBhMCR0IxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0xOTExMTYxMjE3MTVaFw0yOTExMTUxMjE3MTVaMEUxCzAJBgNVBAYTAkdCMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQDzLe9FfdyplTxHp4SuQ9gQtZT3t+SDfvEL72ppCfFZw7+B5s5B/T73aXpoQ3S53pGI1RIWCge2iCUQ2tzm27aSNH0iu9aJYcUQZ/RITqd0ayyDks1NA2PT3TW6t3m7KV5re4P0Nb+YDeuyHdkz+jcMtpn8CmBoT0H+skha0hiqINkjkRPiHvLHVGp+tHUEA/I6mN4aB/UExSTLs79NsLUfteqqxe9+tvdUaToyDPrhPFjONs+9NKCkzIC6vcv7J6AtuKG6nET+zB9yOWgtGYQifXqQA2y5dL81BB0q5uMaBLS2pq3aPPjzU2F3+EysjySWTnCkfk7C5SsCXRu8Q+U95tunpNfwf5olE6Was48NMM+PwV7iCNMPkNzllq6PCiM+P8DrMSczzUZZQUSv6dSwPCo+YSVimEM0Og3XJTiNhQ5ANlaIn66Kw5gfoBfuiXmyIKiSDyAiDYmFaf4395wWwLkTR+cw8WfjaHswKZTomn1MR3OJsY2UJ0eRBYM+YSsCAwEAAaNTMFEwHQYDVR0OBBYEFImp2CYCGfcb7w91H/cShTCkXwR/MB8GA1UdIwQYMBaAFImp2CYCGfcb7w91H/cShTCkXwR/MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggGBAA+g/C7uL9ln+W+qBknLW81kojYflgPK1I1MHIwnMvl/ZTHX4dRXKDrk7KcUq1KjqajNV66f1cakp03IijBiO0Xi1gXUZYLoCiNGUyyp9XloiIy9Xw2PiWnrw0+yZyvVssbehXXYJl4RihBjBWul9R4wMYLOUSJDe2WxcUBhJnxyNRs+P0xLSQX6B2n6nxoDko4p07s8ZKXQkeiZ2iwFdTxzRkGjthMUv704nzsVGBT0DCPtfSaO5KJZW1rCs3yiMthnBxq4qEDOQJFIl+/LD71KbB9vZcW5JuavzBFmkKGNro/6G1I7el46IR4wijTyNFCYUuD9dtignNmpWtN8OW+ptiL/jtTySWukjys0s+vLn83CVvjB0dJtVAIYOgXFdIuii66gczwwM/LGiOExJn0dTNzsJ/IYhpxL4FBEuP0pskY0o0aUlJ2LS2j+wSQTRKsBgMjyrUrekle2ODStStn3eabjIx0/FHlpFr0jNIm/oMP7kwjtUX4zaNe47QI4Gg==', ]); } From aef6eb81e4789f97c7ff23b87295e239c0aead14 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 17 Nov 2019 15:40:36 +0000 Subject: [PATCH 09/12] Added SAML singleLogoutService capabilities --- app/Auth/Access/Saml2Service.php | 73 +++++++++++++++++-- app/Http/Controllers/Auth/LoginController.php | 19 +++++ app/Http/Controllers/Auth/Saml2Controller.php | 19 ++++- routes/web.php | 3 +- 4 files changed, 103 insertions(+), 11 deletions(-) diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index a5ca54c8d..a9441dc40 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -4,10 +4,12 @@ use BookStack\Auth\User; use BookStack\Auth\UserRepo; use BookStack\Exceptions\JsonDebugException; use BookStack\Exceptions\SamlException; +use Exception; use Illuminate\Support\Str; use OneLogin\Saml2\Auth; use OneLogin\Saml2\Error; use OneLogin\Saml2\IdPMetadataParser; +use OneLogin\Saml2\ValidationError; /** * Class Saml2Service @@ -33,7 +35,7 @@ class Saml2Service extends ExternalAuthService /** * Initiate a login flow. - * @throws \OneLogin\Saml2\Error + * @throws Error */ public function login(): array { @@ -45,25 +47,50 @@ class Saml2Service extends ExternalAuthService ]; } + /** + * 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 \OneLogin\Saml2\ValidationError + * @throws ValidationError * @throws JsonDebugException */ public function processAcsResponse(?string $requestId): ?User { - $toolkit = $this->getToolkit(); - $toolkit->processResponse($requestId); - $errors = $toolkit->getErrors(); - if (is_null($requestId)) { throw new SamlException(trans('errors.saml_invalid_response_id')); } + $toolkit = $this->getToolkit(); + $toolkit->processResponse($requestId); + $errors = $toolkit->getErrors(); + if (!empty($errors)) { throw new Error( 'Invalid ACS Response: '.implode(', ', $errors) @@ -80,6 +107,36 @@ 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(); + $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 @@ -103,8 +160,8 @@ class Saml2Service extends ExternalAuthService /** * Load the underlying Onelogin SAML2 toolkit. - * @throws \OneLogin\Saml2\Error - * @throws \Exception + * @throws Error + * @throws Exception */ protected function getToolkit(): Auth { diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 477d3d26b..b1d22d57e 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -146,4 +146,23 @@ class LoginController extends Controller session()->put('social-callback', 'login'); return $this->socialAuthService->startLogIn($socialDriver); } + + /** + * Log the user out of the application. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\Response + */ + public function logout(Request $request) + { + if (config('saml2.enabled') && session()->get('last_login_type') === 'saml2') { + return redirect('/saml2/logout'); + } + + $this->guard()->logout(); + + $request->session()->invalidate(); + + return $this->loggedOut($request) ?: redirect('/'); + } } diff --git a/app/Http/Controllers/Auth/Saml2Controller.php b/app/Http/Controllers/Auth/Saml2Controller.php index d54e925bb..c32f19c5e 100644 --- a/app/Http/Controllers/Auth/Saml2Controller.php +++ b/app/Http/Controllers/Auth/Saml2Controller.php @@ -31,6 +31,20 @@ class Saml2Controller extends Controller return redirect($loginDetails['url']); } + /** + * Start the logout flow via SAML2. + */ + public function logout() + { + $logoutDetails = $this->samlService->logout(); + + if ($logoutDetails['id']) { + session()->flash('saml2_logout_request_id', $logoutDetails['id']); + } + + return redirect($logoutDetails['url']); + } + /* * Get the metadata for this SAML2 service provider. */ @@ -48,7 +62,9 @@ class Saml2Controller extends Controller */ public function sls() { - // TODO + $requestId = session()->pull('saml2_logout_request_id', null); + $redirect = $this->samlService->processSlsResponse($requestId) ?? '/'; + return redirect($redirect); } /** @@ -65,6 +81,7 @@ class Saml2Controller extends Controller return redirect('/login'); } + session()->put('last_login_type', 'saml2'); return redirect()->intended(); } diff --git a/routes/web.php b/routes/web.php index 461d3c1aa..0c554bf8e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -219,8 +219,7 @@ Route::post('/register', 'Auth\RegisterController@postRegister'); // SAML routes // TODO - Prevent access without SAML2 enabled via middleware Route::get('/saml2/login', 'Auth\Saml2Controller@login'); -// TODO - Handle logout? -// Route::get('/saml2/logout', 'Auth\Saml2Controller@logout'); +Route::get('/saml2/logout', 'Auth\Saml2Controller@logout'); Route::get('/saml2/metadata', 'Auth\Saml2Controller@metadata'); Route::get('/saml2/sls', 'Auth\Saml2Controller@sls'); Route::post('/saml2/acs', 'Auth\Saml2Controller@acs'); From 6d899f3b1759cfe14fb2ec9a3cf1eb69b31ce952 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 17 Nov 2019 16:07:06 +0000 Subject: [PATCH 10/12] Added icon for saml, added saml to register page, updated complete env --- .env.example.complete | 22 +++++++++++++++++++ .../Controllers/Auth/RegisterController.php | 6 ++++- resources/icons/saml2.svg | 4 ++++ resources/views/auth/login.blade.php | 2 +- resources/views/auth/register.blade.php | 10 +++++++++ 5 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 resources/icons/saml2.svg diff --git a/.env.example.complete b/.env.example.complete index c4c3f0b85..e8c212f39 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -201,6 +201,28 @@ LDAP_USER_TO_GROUPS=false LDAP_GROUP_ATTRIBUTE="memberOf" LDAP_REMOVE_FROM_GROUPS=false +# SAML authentication configuration +# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/ +SAML2_NAME=SSO +SAML2_ENABLED=false +SAML2_AUTO_REGISTER=true +SAML2_EMAIL_ATTRIBUTE=email +SAML2_DISPLAY_NAME_ATTRIBUTES=username +SAML2_EXTERNAL_ID_ATTRIBUTE=null +SAML2_IDP_ENTITYID=null +SAML2_IDP_SSO=null +SAML2_IDP_SLO=null +SAML2_IDP_x509=null +SAML2_ONELOGIN_OVERRIDES=null +SAML2_DUMP_USER_DETAILS=false +SAML2_AUTOLOAD_METADATA=false + +# SAML group sync configuration +# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/ +SAML2_USER_TO_GROUPS=false +SAML2_GROUP_ATTRIBUTE=group +SAML2_REMOVE_FROM_GROUPS=false + # Disable default third-party services such as Gravatar and Draw.IO # Service-specific options will override this option DISABLE_EXTERNAL_SERVICES=false diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index 304d3bed2..000833029 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -103,7 +103,11 @@ class RegisterController extends Controller { $this->checkRegistrationAllowed(); $socialDrivers = $this->socialAuthService->getActiveDrivers(); - return view('auth.register', ['socialDrivers' => $socialDrivers]); + $samlEnabled = (config('saml2.enabled') === true) && (config('saml2.auto_register') === true); + return view('auth.register', [ + 'socialDrivers' => $socialDrivers, + 'samlEnabled' => $samlEnabled, + ]); } /** diff --git a/resources/icons/saml2.svg b/resources/icons/saml2.svg new file mode 100644 index 000000000..a9a2994a7 --- /dev/null +++ b/resources/icons/saml2.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 4ac7a50e0..836150d69 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -49,7 +49,7 @@
diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index 0e996a00d..8dd6592c1 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -49,6 +49,16 @@
@endforeach @endif + + @if($samlEnabled) +
+ + @endif
@stop From ebb37248923f85e6e1f186c721f4714bcc0168d4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 17 Nov 2019 17:00:42 +0000 Subject: [PATCH 11/12] Added onelogin attribution and tweaks after testing saml with onelogin --- app/Auth/Access/Saml2Service.php | 16 +++++++--------- readme.md | 3 ++- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index a9441dc40..c1038e730 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -83,10 +83,6 @@ class Saml2Service extends ExternalAuthService */ public function processAcsResponse(?string $requestId): ?User { - if (is_null($requestId)) { - throw new SamlException(trans('errors.saml_invalid_response_id')); - } - $toolkit = $this->getToolkit(); $toolkit->processResponse($requestId); $errors = $toolkit->getErrors(); @@ -251,17 +247,14 @@ class Saml2Service extends ExternalAuthService /** * Extract the details of a user from a SAML response. - * @throws SamlException */ public 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, @@ -372,11 +365,16 @@ class Saml2Service extends ExternalAuthService 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'); } diff --git a/readme.md b/readme.md index ca90be305..f86e661ea 100644 --- a/readme.md +++ b/readme.md @@ -174,4 +174,5 @@ These are the great open-source projects used to help build BookStack: * [Laravel IDE helper](https://github.com/barryvdh/laravel-ide-helper) * [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html) * [Draw.io](https://github.com/jgraph/drawio) -* [Laravel Stats](https://github.com/stefanzweifel/laravel-stats) \ No newline at end of file +* [Laravel Stats](https://github.com/stefanzweifel/laravel-stats) +* [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml) \ No newline at end of file From c33ef4b9b2fd49c1ca138d9df8879a8eb70ac5e3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 17 Nov 2019 19:15:37 +0000 Subject: [PATCH 12/12] Added tests to cover saml and added controller middleware --- app/Http/Controllers/Auth/Saml2Controller.php | 10 +- routes/web.php | 1 - tests/Auth/Saml2.php | 283 +++++++++++++++++- 3 files changed, 284 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Auth/Saml2Controller.php b/app/Http/Controllers/Auth/Saml2Controller.php index c32f19c5e..863894128 100644 --- a/app/Http/Controllers/Auth/Saml2Controller.php +++ b/app/Http/Controllers/Auth/Saml2Controller.php @@ -4,7 +4,6 @@ namespace BookStack\Http\Controllers\Auth; use BookStack\Auth\Access\Saml2Service; use BookStack\Http\Controllers\Controller; -use Illuminate\Http\Request; class Saml2Controller extends Controller { @@ -18,6 +17,15 @@ class Saml2Controller extends Controller { parent::__construct(); $this->samlService = $samlService; + + // SAML2 access middleware + $this->middleware(function ($request, $next) { + if (!config('saml2.enabled')) { + $this->showPermissionError(); + } + + return $next($request); + }); } /** diff --git a/routes/web.php b/routes/web.php index 0c554bf8e..839e5a256 100644 --- a/routes/web.php +++ b/routes/web.php @@ -217,7 +217,6 @@ Route::get('/register/confirm/{token}', 'Auth\ConfirmEmailController@confirm'); Route::post('/register', 'Auth\RegisterController@postRegister'); // SAML routes -// TODO - Prevent access without SAML2 enabled via middleware Route::get('/saml2/login', 'Auth\Saml2Controller@login'); Route::get('/saml2/logout', 'Auth\Saml2Controller@logout'); Route::get('/saml2/metadata', 'Auth\Saml2Controller@metadata'); diff --git a/tests/Auth/Saml2.php b/tests/Auth/Saml2.php index 90561896a..ef1ca8d13 100644 --- a/tests/Auth/Saml2.php +++ b/tests/Auth/Saml2.php @@ -1,5 +1,7 @@ set([ - 'saml2.name' => 'SingleSignOn', + 'saml2.name' => 'SingleSignOn-Testing', 'saml2.enabled' => true, 'saml2.auto_register' => true, 'saml2.email_attribute' => 'email', - 'saml2.display_name_attributes' => 'username', - 'saml2.external_id_attribute' => 'external_id', + 'saml2.display_name_attributes' => ['first_name', 'last_name'], + 'saml2.external_id_attribute' => 'uid', 'saml2.user_to_groups' => false, - 'saml2.group_attribute' => 'group', + 'saml2.group_attribute' => 'user_groups', 'saml2.remove_from_groups' => false, 'saml2.onelogin_overrides' => null, - 'saml2.onelogin.idp.entityId' => 'https://example.com/saml2/idp/metadata', - 'saml2.onelogin.idp.singleSignOnService.url' => 'https://example.com/saml2/idp/sso', - 'saml2.onelogin.idp.singleLogoutService.url' => 'https://example.com/saml2/idp/sls', + 'saml2.onelogin.idp.entityId' => 'http://saml.local/saml2/idp/metadata.php', + 'saml2.onelogin.idp.singleSignOnService.url' => 'http://saml.local/saml2/idp/SSOService.php', + 'saml2.onelogin.idp.singleLogoutService.url' => 'http://saml.local/saml2/idp/SingleLogoutService.php', 'saml2.autoload_from_metadata' => false, - 'saml2.onelogin.idp.x509cert' => 'MIIEazCCAtOgAwIBAgIUe7a088Cnr4izmrnBEnx5q3HTMvYwDQYJKoZIhvcNAQELBQAwRTELMAkGA1UEBhMCR0IxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0xOTExMTYxMjE3MTVaFw0yOTExMTUxMjE3MTVaMEUxCzAJBgNVBAYTAkdCMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQDzLe9FfdyplTxHp4SuQ9gQtZT3t+SDfvEL72ppCfFZw7+B5s5B/T73aXpoQ3S53pGI1RIWCge2iCUQ2tzm27aSNH0iu9aJYcUQZ/RITqd0ayyDks1NA2PT3TW6t3m7KV5re4P0Nb+YDeuyHdkz+jcMtpn8CmBoT0H+skha0hiqINkjkRPiHvLHVGp+tHUEA/I6mN4aB/UExSTLs79NsLUfteqqxe9+tvdUaToyDPrhPFjONs+9NKCkzIC6vcv7J6AtuKG6nET+zB9yOWgtGYQifXqQA2y5dL81BB0q5uMaBLS2pq3aPPjzU2F3+EysjySWTnCkfk7C5SsCXRu8Q+U95tunpNfwf5olE6Was48NMM+PwV7iCNMPkNzllq6PCiM+P8DrMSczzUZZQUSv6dSwPCo+YSVimEM0Og3XJTiNhQ5ANlaIn66Kw5gfoBfuiXmyIKiSDyAiDYmFaf4395wWwLkTR+cw8WfjaHswKZTomn1MR3OJsY2UJ0eRBYM+YSsCAwEAAaNTMFEwHQYDVR0OBBYEFImp2CYCGfcb7w91H/cShTCkXwR/MB8GA1UdIwQYMBaAFImp2CYCGfcb7w91H/cShTCkXwR/MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggGBAA+g/C7uL9ln+W+qBknLW81kojYflgPK1I1MHIwnMvl/ZTHX4dRXKDrk7KcUq1KjqajNV66f1cakp03IijBiO0Xi1gXUZYLoCiNGUyyp9XloiIy9Xw2PiWnrw0+yZyvVssbehXXYJl4RihBjBWul9R4wMYLOUSJDe2WxcUBhJnxyNRs+P0xLSQX6B2n6nxoDko4p07s8ZKXQkeiZ2iwFdTxzRkGjthMUv704nzsVGBT0DCPtfSaO5KJZW1rCs3yiMthnBxq4qEDOQJFIl+/LD71KbB9vZcW5JuavzBFmkKGNro/6G1I7el46IR4wijTyNFCYUuD9dtignNmpWtN8OW+ptiL/jtTySWukjys0s+vLn83CVvjB0dJtVAIYOgXFdIuii66gczwwM/LGiOExJn0dTNzsJ/IYhpxL4FBEuP0pskY0o0aUlJ2LS2j+wSQTRKsBgMjyrUrekle2ODStStn3eabjIx0/FHlpFr0jNIm/oMP7kwjtUX4zaNe47QI4Gg==', + 'saml2.onelogin.idp.x509cert' => $this->testCert, + 'saml2.onelogin.debug' => false, ]); } @@ -45,4 +48,268 @@ class Saml2 extends TestCase $req->assertSee('md:ContactPerson'); $req->assertSee('Barry Scott'); } + + public function test_login_option_shows_on_login_page() + { + $req = $this->get('/login'); + $req->assertSeeText('SingleSignOn-Testing'); + $req->assertElementExists('a[href$="/saml2/login"]'); + } + + public function test_login_option_shows_on_register_page_only_when_auto_register_enabled() + { + $this->setSettings(['app-public' => 'true', 'registration-enabled' => 'true']); + + $req = $this->get('/register'); + $req->assertSeeText('SingleSignOn-Testing'); + $req->assertElementExists('a[href$="/saml2/login"]'); + + config()->set(['saml2.auto_register' => false]); + + $req = $this->get('/register'); + $req->assertDontSeeText('SingleSignOn-Testing'); + $req->assertElementNotExists('a[href$="/saml2/login"]'); + } + + public function test_login() + { + $req = $this->get('/saml2/login'); + $redirect = $req->headers->get('location'); + $this->assertStringStartsWith('http://saml.local/saml2/idp/SSOService.php', $redirect, 'Login redirects to SSO location'); + + config()->set(['saml2.onelogin.strict' => false]); + $this->assertFalse($this->isAuthenticated()); + + $this->withPost(['SAMLResponse' => $this->acsPostData], function () { + + $acsPost = $this->post('/saml2/acs'); + $acsPost->assertRedirect('/'); + $this->assertTrue($this->isAuthenticated()); + $this->assertDatabaseHas('users', [ + 'email' => 'user@example.com', + 'external_auth_id' => 'user', + 'email_confirmed' => true, + 'name' => 'Barry Scott' + ]); + + }); + } + + public function test_group_role_sync_on_login() + { + config()->set([ + 'saml2.onelogin.strict' => false, + 'saml2.user_to_groups' => true, + 'saml2.remove_from_groups' => false, + ]); + + $memberRole = factory(Role::class)->create(['external_auth_id' => 'member']); + $adminRole = Role::getSystemRole('admin'); + + $this->withPost(['SAMLResponse' => $this->acsPostData], function () use ($memberRole, $adminRole) { + $acsPost = $this->post('/saml2/acs'); + $user = User::query()->where('external_auth_id', '=', 'user')->first(); + + $userRoleIds = $user->roles()->pluck('id'); + $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role'); + $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role'); + }); + } + + public function test_group_role_sync_removal_option_works_as_expected() + { + config()->set([ + 'saml2.onelogin.strict' => false, + 'saml2.user_to_groups' => true, + 'saml2.remove_from_groups' => true, + ]); + + $this->withPost(['SAMLResponse' => $this->acsPostData], function () { + $acsPost = $this->post('/saml2/acs'); + $user = User::query()->where('external_auth_id', '=', 'user')->first(); + + $randomRole = factory(Role::class)->create(['external_auth_id' => 'random']); + $user->attachRole($randomRole); + $this->assertContains($randomRole->id, $user->roles()->pluck('id')); + + auth()->logout(); + $acsPost = $this->post('/saml2/acs'); + $this->assertNotContains($randomRole->id, $user->roles()->pluck('id')); + }); + } + + public function test_logout_redirects_to_saml_logout_when_active_saml_session() + { + config()->set([ + 'saml2.onelogin.strict' => false, + ]); + + $this->withPost(['SAMLResponse' => $this->acsPostData], function () { + $acsPost = $this->post('/saml2/acs'); + $lastLoginType = session()->get('last_login_type'); + $this->assertEquals('saml2', $lastLoginType); + + $req = $this->get('/logout'); + $req->assertRedirect('/saml2/logout'); + }); + } + + public function test_logout_sls_flow() + { + config()->set([ + 'saml2.onelogin.strict' => false, + ]); + + $handleLogoutResponse = function () { + $this->assertTrue($this->isAuthenticated()); + + $req = $this->get('/saml2/sls'); + $req->assertRedirect('/'); + $this->assertFalse($this->isAuthenticated()); + }; + + $loginAndStartLogout = function () use ($handleLogoutResponse) { + $this->post('/saml2/acs'); + + $req = $this->get('/saml2/logout'); + $redirect = $req->headers->get('location'); + $this->assertStringStartsWith('http://saml.local/saml2/idp/SingleLogoutService.php', $redirect); + $this->withGet(['SAMLResponse' => $this->sloResponseData], $handleLogoutResponse); + }; + + $this->withPost(['SAMLResponse' => $this->acsPostData], $loginAndStartLogout); + } + + public function test_logout_sls_flow_when_sls_not_configured() + { + config()->set([ + 'saml2.onelogin.strict' => false, + 'saml2.onelogin.idp.singleLogoutService.url' => null, + ]); + + $loginAndStartLogout = function () { + $this->post('/saml2/acs'); + + $req = $this->get('/saml2/logout'); + $req->assertRedirect('/'); + $this->assertFalse($this->isAuthenticated()); + }; + + $this->withPost(['SAMLResponse' => $this->acsPostData], $loginAndStartLogout); + } + + public function test_dump_user_details_option_works() + { + config()->set([ + 'saml2.onelogin.strict' => false, + 'saml2.dump_user_details' => true, + ]); + + $this->withPost(['SAMLResponse' => $this->acsPostData], function () { + $acsPost = $this->post('/saml2/acs'); + $acsPost->assertJsonStructure([ + 'id_from_idp', + 'attrs_from_idp' => [], + 'attrs_after_parsing' => [], + ]); + }); + } + + public function test_user_registration_with_existing_email() + { + config()->set([ + 'saml2.onelogin.strict' => false, + ]); + + $viewer = $this->getViewer(); + $viewer->email = 'user@example.com'; + $viewer->save(); + + $this->withPost(['SAMLResponse' => $this->acsPostData], function () { + $acsPost = $this->post('/saml2/acs'); + $acsPost->assertRedirect('/'); + $errorMessage = session()->get('error'); + $this->assertEquals('Registration unsuccessful since a user already exists with email address "user@example.com"', $errorMessage); + }); + } + + public function test_saml_routes_are_only_active_if_saml_enabled() + { + config()->set(['saml2.enabled' => false]); + $getRoutes = ['/login', '/logout', '/metadata', '/sls']; + foreach ($getRoutes as $route) { + $req = $this->get('/saml2' . $route); + $req->assertRedirect('/'); + $error = session()->get('error'); + $this->assertStringStartsWith('You do not have permission to access', $error); + session()->flush(); + } + + $postRoutes = ['/acs']; + foreach ($postRoutes as $route) { + $req = $this->post('/saml2' . $route); + $req->assertRedirect('/'); + $error = session()->get('error'); + $this->assertStringStartsWith('You do not have permission to access', $error); + session()->flush(); + } + } + + protected function withGet(array $options, callable $callback) + { + return $this->withGlobal($_GET, $options, $callback); + } + + protected function withPost(array $options, callable $callback) + { + return $this->withGlobal($_POST, $options, $callback); + } + + protected function withGlobal(array &$global, array $options, callable $callback) + { + $original = []; + foreach ($options as $key => $val) { + $original[$key] = $global[$key] ?? null; + $global[$key] = $val; + } + + $callback(); + + foreach ($options as $key => $val) { + $val = $original[$key]; + if ($val) { + $global[$key] = $val; + } else { + unset($global[$key]); + } + } + } + + /** + * The post data for a callback for single-sign-in. + * Provides the following attributes: + * array:5 [ + "uid" => array:1 [ + 0 => "user" + ] + "first_name" => array:1 [ + 0 => "Barry" + ] + "last_name" => array:1 [ + 0 => "Scott" + ] + "email" => array:1 [ + 0 => "user@example.com" + ] + "user_groups" => array:2 [ + 0 => "member" + 1 => "admin" + ] + ] + */ + protected $acsPostData = 'PHNhbWxwOlJlc3BvbnNlIHhtbG5zOnNhbWxwPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6cHJvdG9jb2wiIHhtbG5zOnNhbWw9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphc3NlcnRpb24iIElEPSJfNGRkNDU2NGRjNzk0MDYxZWYxYmFhMDQ2N2Q3OTAyOGNlZDNjZTU0YmVlIiBWZXJzaW9uPSIyLjAiIElzc3VlSW5zdGFudD0iMjAxOS0xMS0xN1QxNzo1MzozOVoiIERlc3RpbmF0aW9uPSJodHRwOi8vYm9va3N0YWNrLmxvY2FsL3NhbWwyL2FjcyIgSW5SZXNwb25zZVRvPSJPTkVMT0dJTl82YTBmNGYzOTkzMDQwZjE5ODdmZDM3MDY4YjUyOTYyMjlhZDUzNjFjIj48c2FtbDpJc3N1ZXI+aHR0cDovL3NhbWwubG9jYWwvc2FtbDIvaWRwL21ldGFkYXRhLnBocDwvc2FtbDpJc3N1ZXI+PGRzOlNpZ25hdHVyZSB4bWxuczpkcz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnIyI+CiAgPGRzOlNpZ25lZEluZm8+PGRzOkNhbm9uaWNhbGl6YXRpb25NZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiLz4KICAgIDxkczpTaWduYXR1cmVNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNyc2Etc2hhMjU2Ii8+CiAgPGRzOlJlZmVyZW5jZSBVUkk9IiNfNGRkNDU2NGRjNzk0MDYxZWYxYmFhMDQ2N2Q3OTAyOGNlZDNjZTU0YmVlIj48ZHM6VHJhbnNmb3Jtcz48ZHM6VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnI2VudmVsb3BlZC1zaWduYXR1cmUiLz48ZHM6VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+PC9kczpUcmFuc2Zvcm1zPjxkczpEaWdlc3RNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyNzaGEyNTYiLz48ZHM6RGlnZXN0VmFsdWU+dm1oL1M3NU5mK2crZWNESkN6QWJaV0tKVmx1ZzdCZnNDKzlhV05lSXJlUT08L2RzOkRpZ2VzdFZhbHVlPjwvZHM6UmVmZXJlbmNlPjwvZHM6U2lnbmVkSW5mbz48ZHM6U2lnbmF0dXJlVmFsdWU+dnJhZ0tKWHNjVm5UNjJFaEk3bGk4MERUWHNOTGJOc3lwNWZ2QnU4WjFYSEtFUVA3QWpPNkcxcVBwaGpWQ2dRMzd6TldVVTZvUytQeFA3UDlHeG5xL3hKejRUT3lHcHJ5N1RoK2pIcHc0YWVzQTdrTmp6VU51UmU2c1ltWTlrRXh2VjMvTmJRZjROMlM2Y2RhRHIzWFRodllVVDcxYzQwNVVHOFJpQjJaY3liWHIxZU1yWCtXUDBnU2Qrc0F2RExqTjBJc3pVWlVUNThadFpEVE1ya1ZGL0pIbFBFQ04vVW1sYVBBeitTcUJ4c25xTndZK1oxYUt3MnlqeFRlNnUxM09Kb29OOVN1REowNE0rK2F3RlY3NkI4cXEyTzMxa3FBbDJibm1wTGxtTWdRNFEraUlnL3dCc09abTV1clphOWJObDNLVEhtTVBXbFpkbWhsLzgvMy9IT1RxN2thWGs3cnlWRHRLcFlsZ3FUajNhRUpuL0dwM2o4SFp5MUVialRiOTRRT1ZQMG5IQzB1V2hCaE13TjdzVjFrUSsxU2NjUlpUZXJKSGlSVUQvR0srTVg3M0YrbzJVTFRIL1Z6Tm9SM2o4N2hOLzZ1UC9JeG5aM1RudGR1MFZPZS9ucEdVWjBSMG9SWFhwa2JTL2poNWk1ZjU0RXN4eXZ1VEM5NHdKaEM8L2RzOlNpZ25hdHVyZVZhbHVlPgo8ZHM6S2V5SW5mbz48ZHM6WDUwOURhdGE+PGRzOlg1MDlDZXJ0aWZpY2F0ZT5NSUlFYXpDQ0F0T2dBd0lCQWdJVWU3YTA4OENucjRpem1ybkJFbng1cTNIVE12WXdEUVlKS29aSWh2Y05BUUVMQlFBd1JURUxNQWtHQTFVRUJoTUNSMEl4RXpBUkJnTlZCQWdNQ2xOdmJXVXRVM1JoZEdVeElUQWZCZ05WQkFvTUdFbHVkR1Z5Ym1WMElGZHBaR2RwZEhNZ1VIUjVJRXgwWkRBZUZ3MHhPVEV4TVRZeE1qRTNNVFZhRncweU9URXhNVFV4TWpFM01UVmFNRVV4Q3pBSkJnTlZCQVlUQWtkQ01STXdFUVlEVlFRSURBcFRiMjFsTFZOMFlYUmxNU0V3SHdZRFZRUUtEQmhKYm5SbGNtNWxkQ0JYYVdSbmFYUnpJRkIwZVNCTWRHUXdnZ0dpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElCandBd2dnR0tBb0lCZ1FEekxlOUZmZHlwbFR4SHA0U3VROWdRdFpUM3QrU0RmdkVMNzJwcENmRlp3NytCNXM1Qi9UNzNhWHBvUTNTNTNwR0kxUklXQ2dlMmlDVVEydHptMjdhU05IMGl1OWFKWWNVUVovUklUcWQwYXl5RGtzMU5BMlBUM1RXNnQzbTdLVjVyZTRQME5iK1lEZXV5SGRreitqY010cG44Q21Cb1QwSCtza2hhMGhpcUlOa2prUlBpSHZMSFZHcCt0SFVFQS9JNm1ONGFCL1VFeFNUTHM3OU5zTFVmdGVxcXhlOSt0dmRVYVRveURQcmhQRmpPTnMrOU5LQ2t6SUM2dmN2N0o2QXR1S0c2bkVUK3pCOXlPV2d0R1lRaWZYcVFBMnk1ZEw4MUJCMHE1dU1hQkxTMnBxM2FQUGp6VTJGMytFeXNqeVNXVG5Da2ZrN0M1U3NDWFJ1OFErVTk1dHVucE5md2Y1b2xFNldhczQ4Tk1NK1B3VjdpQ05NUGtOemxscTZQQ2lNK1A4RHJNU2N6elVaWlFVU3Y2ZFN3UENvK1lTVmltRU0wT2czWEpUaU5oUTVBTmxhSW42Nkt3NWdmb0JmdWlYbXlJS2lTRHlBaURZbUZhZjQzOTV3V3dMa1RSK2N3OFdmamFIc3dLWlRvbW4xTVIzT0pzWTJVSjBlUkJZTStZU3NDQXdFQUFhTlRNRkV3SFFZRFZSME9CQllFRkltcDJDWUNHZmNiN3c5MUgvY1NoVENrWHdSL01COEdBMVVkSXdRWU1CYUFGSW1wMkNZQ0dmY2I3dzkxSC9jU2hUQ2tYd1IvTUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3RFFZSktvWklodmNOQVFFTEJRQURnZ0dCQUErZy9DN3VMOWxuK1crcUJrbkxXODFrb2pZZmxnUEsxSTFNSEl3bk12bC9aVEhYNGRSWEtEcms3S2NVcTFLanFhak5WNjZmMWNha3AwM0lpakJpTzBYaTFnWFVaWUxvQ2lOR1V5eXA5WGxvaUl5OVh3MlBpV25ydzAreVp5dlZzc2JlaFhYWUpsNFJpaEJqQld1bDlSNHdNWUxPVVNKRGUyV3hjVUJoSm54eU5ScytQMHhMU1FYNkIybjZueG9Ea280cDA3czhaS1hRa2VpWjJpd0ZkVHh6UmtHanRoTVV2NzA0bnpzVkdCVDBEQ1B0ZlNhTzVLSlpXMXJDczN5aU10aG5CeHE0cUVET1FKRklsKy9MRDcxS2JCOXZaY1c1SnVhdnpCRm1rS0dOcm8vNkcxSTdlbDQ2SVI0d2lqVHlORkNZVXVEOWR0aWduTm1wV3ROOE9XK3B0aUwvanRUeVNXdWtqeXMwcyt2TG44M0NWdmpCMGRKdFZBSVlPZ1hGZEl1aWk2Nmdjend3TS9MR2lPRXhKbjBkVE56c0ovSVlocHhMNEZCRXVQMHBza1kwbzBhVWxKMkxTMmord1NRVFJLc0JnTWp5clVyZWtsZTJPRFN0U3RuM2VhYmpJeDAvRkhscEZyMGpOSW0vb01QN2t3anRVWDR6YU5lNDdRSTRHZz09PC9kczpYNTA5Q2VydGlmaWNhdGU+PC9kczpYNTA5RGF0YT48L2RzOktleUluZm8+PC9kczpTaWduYXR1cmU+PHNhbWxwOlN0YXR1cz48c2FtbHA6U3RhdHVzQ29kZSBWYWx1ZT0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnN0YXR1czpTdWNjZXNzIi8+PC9zYW1scDpTdGF0dXM+PHNhbWw6QXNzZXJ0aW9uIHhtbG5zOnhzaT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEtaW5zdGFuY2UiIHhtbG5zOnhzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYSIgSUQ9Il82ODQyZGY5YzY1OWYxM2ZlNTE5NmNkOWVmNmMyZjAyODM2NGFlOTQzYjEiIFZlcnNpb249IjIuMCIgSXNzdWVJbnN0YW50PSIyMDE5LTExLTE3VDE3OjUzOjM5WiI+PHNhbWw6SXNzdWVyPmh0dHA6Ly9zYW1sLmxvY2FsL3NhbWwyL2lkcC9tZXRhZGF0YS5waHA8L3NhbWw6SXNzdWVyPjxkczpTaWduYXR1cmUgeG1sbnM6ZHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyMiPgogIDxkczpTaWduZWRJbmZvPjxkczpDYW5vbmljYWxpemF0aW9uTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+CiAgICA8ZHM6U2lnbmF0dXJlTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxkc2lnLW1vcmUjcnNhLXNoYTI1NiIvPgogIDxkczpSZWZlcmVuY2UgVVJJPSIjXzY4NDJkZjljNjU5ZjEzZmU1MTk2Y2Q5ZWY2YzJmMDI4MzY0YWU5NDNiMSI+PGRzOlRyYW5zZm9ybXM+PGRzOlRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNlbnZlbG9wZWQtc2lnbmF0dXJlIi8+PGRzOlRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMTAveG1sLWV4Yy1jMTRuIyIvPjwvZHM6VHJhbnNmb3Jtcz48ZHM6RGlnZXN0TWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjc2hhMjU2Ii8+PGRzOkRpZ2VzdFZhbHVlPmtyYjV3NlM4dG9YYy9lU3daUFVPQnZRem4zb3M0SkFDdXh4ckpreHBnRnc9PC9kczpEaWdlc3RWYWx1ZT48L2RzOlJlZmVyZW5jZT48L2RzOlNpZ25lZEluZm8+PGRzOlNpZ25hdHVyZVZhbHVlPjJxcW1Ba3hucXhOa3N5eXh5dnFTVDUxTDg5VS9ZdHpja2t1ekF4ci9hQ1JTK1NPRzg1YkFNWm8vU3puc3d0TVlBYlFRQ0VGb0R1amdNdlpzSFl3NlR2dmFHanlXWUpRNVZyYWhlemZaSWlCVUU0NHBtWGFrOCswV0l0WTVndnBGSXhxWFZaRmdFUkt2VExmZVFCMzhkMVZQc0ZVZ0RYdXQ4VS9Qdm43dXZwdXZjVXorMUUyOUVKR2FZL0dndnhUN0tyWU9SQTh3SitNdVRzUVZtanNlUnhveVJTejA4TmJ3ZTJIOGpXQnpFWWNxWWwyK0ZnK2hwNWd0S216VmhLRnBkNXZBNjdBSXo1NXN0QmNHNSswNHJVaWpFSzRzci9xa0x5QmtKQjdLdkwzanZKcG8zQjhxYkxYeXhLb1dSSmRnazhKNHMvTVp1QWk3QWUxUXNTTjl2Z3ZTdVRlc0VCUjVpSHJuS1lrbEpRWXNrbUQzbSsremE4U1NRbnBlM0UzYUZBY3p6cElUdUQ4YkFCWmRqcUk2TkhrSmFRQXBmb0hWNVQrZ244ejdUTWsrSStUU2JlQURubUxCS3lnMHRabW10L0ZKbDV6eWowVmxwc1dzTVM2OVE2bUZJVStqcEhSanpOb2FLMVM1dlQ3ZW1HbUhKSUp0cWlOdXJRN0tkQlBJPC9kczpTaWduYXR1cmVWYWx1ZT4KPGRzOktleUluZm8+PGRzOlg1MDlEYXRhPjxkczpYNTA5Q2VydGlmaWNhdGU+TUlJRWF6Q0NBdE9nQXdJQkFnSVVlN2EwODhDbnI0aXptcm5CRW54NXEzSFRNdll3RFFZSktvWklodmNOQVFFTEJRQXdSVEVMTUFrR0ExVUVCaE1DUjBJeEV6QVJCZ05WQkFnTUNsTnZiV1V0VTNSaGRHVXhJVEFmQmdOVkJBb01HRWx1ZEdWeWJtVjBJRmRwWkdkcGRITWdVSFI1SUV4MFpEQWVGdzB4T1RFeE1UWXhNakUzTVRWYUZ3MHlPVEV4TVRVeE1qRTNNVFZhTUVVeEN6QUpCZ05WQkFZVEFrZENNUk13RVFZRFZRUUlEQXBUYjIxbExWTjBZWFJsTVNFd0h3WURWUVFLREJoSmJuUmxjbTVsZENCWGFXUm5hWFJ6SUZCMGVTQk1kR1F3Z2dHaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQmp3QXdnZ0dLQW9JQmdRRHpMZTlGZmR5cGxUeEhwNFN1UTlnUXRaVDN0K1NEZnZFTDcycHBDZkZadzcrQjVzNUIvVDczYVhwb1EzUzUzcEdJMVJJV0NnZTJpQ1VRMnR6bTI3YVNOSDBpdTlhSlljVVFaL1JJVHFkMGF5eURrczFOQTJQVDNUVzZ0M203S1Y1cmU0UDBOYitZRGV1eUhka3oramNNdHBuOENtQm9UMEgrc2toYTBoaXFJTmtqa1JQaUh2TEhWR3ArdEhVRUEvSTZtTjRhQi9VRXhTVExzNzlOc0xVZnRlcXF4ZTkrdHZkVWFUb3lEUHJoUEZqT05zKzlOS0NreklDNnZjdjdKNkF0dUtHNm5FVCt6Qjl5T1dndEdZUWlmWHFRQTJ5NWRMODFCQjBxNXVNYUJMUzJwcTNhUFBqelUyRjMrRXlzanlTV1RuQ2tmazdDNVNzQ1hSdThRK1U5NXR1bnBOZndmNW9sRTZXYXM0OE5NTStQd1Y3aUNOTVBrTnpsbHE2UENpTStQOERyTVNjenpVWlpRVVN2NmRTd1BDbytZU1ZpbUVNME9nM1hKVGlOaFE1QU5sYUluNjZLdzVnZm9CZnVpWG15SUtpU0R5QWlEWW1GYWY0Mzk1d1d3TGtUUitjdzhXZmphSHN3S1pUb21uMU1SM09Kc1kyVUowZVJCWU0rWVNzQ0F3RUFBYU5UTUZFd0hRWURWUjBPQkJZRUZJbXAyQ1lDR2ZjYjd3OTFIL2NTaFRDa1h3Ui9NQjhHQTFVZEl3UVlNQmFBRkltcDJDWUNHZmNiN3c5MUgvY1NoVENrWHdSL01BOEdBMVVkRXdFQi93UUZNQU1CQWY4d0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dHQkFBK2cvQzd1TDlsbitXK3FCa25MVzgxa29qWWZsZ1BLMUkxTUhJd25NdmwvWlRIWDRkUlhLRHJrN0tjVXExS2pxYWpOVjY2ZjFjYWtwMDNJaWpCaU8wWGkxZ1hVWllMb0NpTkdVeXlwOVhsb2lJeTlYdzJQaVducncwK3laeXZWc3NiZWhYWFlKbDRSaWhCakJXdWw5UjR3TVlMT1VTSkRlMld4Y1VCaEpueHlOUnMrUDB4TFNRWDZCMm42bnhvRGtvNHAwN3M4WktYUWtlaVoyaXdGZFR4elJrR2p0aE1VdjcwNG56c1ZHQlQwRENQdGZTYU81S0paVzFyQ3MzeWlNdGhuQnhxNHFFRE9RSkZJbCsvTEQ3MUtiQjl2WmNXNUp1YXZ6QkZta0tHTnJvLzZHMUk3ZWw0NklSNHdpalR5TkZDWVV1RDlkdGlnbk5tcFd0TjhPVytwdGlML2p0VHlTV3VranlzMHMrdkxuODNDVnZqQjBkSnRWQUlZT2dYRmRJdWlpNjZnY3p3d00vTEdpT0V4Sm4wZFROenNKL0lZaHB4TDRGQkV1UDBwc2tZMG8wYVVsSjJMUzJqK3dTUVRSS3NCZ01qeXJVcmVrbGUyT0RTdFN0bjNlYWJqSXgwL0ZIbHBGcjBqTkltL29NUDdrd2p0VVg0emFOZTQ3UUk0R2c9PTwvZHM6WDUwOUNlcnRpZmljYXRlPjwvZHM6WDUwOURhdGE+PC9kczpLZXlJbmZvPjwvZHM6U2lnbmF0dXJlPjxzYW1sOlN1YmplY3Q+PHNhbWw6TmFtZUlEIFNQTmFtZVF1YWxpZmllcj0iaHR0cDovL2Jvb2tzdGFjay5sb2NhbC9zYW1sMi9tZXRhZGF0YSIgRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6bmFtZWlkLWZvcm1hdDp0cmFuc2llbnQiPl8yYzdhYjg2ZWI4ZjFkMTA2MzQ0M2YyMTljYzU4NjhmZjY2NzA4OTEyZTM8L3NhbWw6TmFtZUlEPjxzYW1sOlN1YmplY3RDb25maXJtYXRpb24gTWV0aG9kPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6Y206YmVhcmVyIj48c2FtbDpTdWJqZWN0Q29uZmlybWF0aW9uRGF0YSBOb3RPbk9yQWZ0ZXI9IjIwMTktMTEtMTdUMTc6NTg6MzlaIiBSZWNpcGllbnQ9Imh0dHA6Ly9ib29rc3RhY2subG9jYWwvc2FtbDIvYWNzIiBJblJlc3BvbnNlVG89Ik9ORUxPR0lOXzZhMGY0ZjM5OTMwNDBmMTk4N2ZkMzcwNjhiNTI5NjIyOWFkNTM2MWMiLz48L3NhbWw6U3ViamVjdENvbmZpcm1hdGlvbj48L3NhbWw6U3ViamVjdD48c2FtbDpDb25kaXRpb25zIE5vdEJlZm9yZT0iMjAxOS0xMS0xN1QxNzo1MzowOVoiIE5vdE9uT3JBZnRlcj0iMjAxOS0xMS0xN1QxNzo1ODozOVoiPjxzYW1sOkF1ZGllbmNlUmVzdHJpY3Rpb24+PHNhbWw6QXVkaWVuY2U+aHR0cDovL2Jvb2tzdGFjay5sb2NhbC9zYW1sMi9tZXRhZGF0YTwvc2FtbDpBdWRpZW5jZT48L3NhbWw6QXVkaWVuY2VSZXN0cmljdGlvbj48L3NhbWw6Q29uZGl0aW9ucz48c2FtbDpBdXRoblN0YXRlbWVudCBBdXRobkluc3RhbnQ9IjIwMTktMTEtMTdUMTc6NTM6MzlaIiBTZXNzaW9uTm90T25PckFmdGVyPSIyMDE5LTExLTE4VDAxOjUzOjM5WiIgU2Vzc2lvbkluZGV4PSJfNGZlN2MwZDE1NzJkNjRiMjdmOTMwYWE2ZjIzNmE2ZjQyZTkzMDkwMWNjIj48c2FtbDpBdXRobkNvbnRleHQ+PHNhbWw6QXV0aG5Db250ZXh0Q2xhc3NSZWY+dXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFjOmNsYXNzZXM6UGFzc3dvcmQ8L3NhbWw6QXV0aG5Db250ZXh0Q2xhc3NSZWY+PC9zYW1sOkF1dGhuQ29udGV4dD48L3NhbWw6QXV0aG5TdGF0ZW1lbnQ+PHNhbWw6QXR0cmlidXRlU3RhdGVtZW50PjxzYW1sOkF0dHJpYnV0ZSBOYW1lPSJ1aWQiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6YmFzaWMiPjxzYW1sOkF0dHJpYnV0ZVZhbHVlIHhzaTp0eXBlPSJ4czpzdHJpbmciPnVzZXI8L3NhbWw6QXR0cmlidXRlVmFsdWU+PC9zYW1sOkF0dHJpYnV0ZT48c2FtbDpBdHRyaWJ1dGUgTmFtZT0iZmlyc3RfbmFtZSIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDpiYXNpYyI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeHNpOnR5cGU9InhzOnN0cmluZyI+QmFycnk8L3NhbWw6QXR0cmlidXRlVmFsdWU+PC9zYW1sOkF0dHJpYnV0ZT48c2FtbDpBdHRyaWJ1dGUgTmFtZT0ibGFzdF9uYW1lIiBOYW1lRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXR0cm5hbWUtZm9ybWF0OmJhc2ljIj48c2FtbDpBdHRyaWJ1dGVWYWx1ZSB4c2k6dHlwZT0ieHM6c3RyaW5nIj5TY290dDwvc2FtbDpBdHRyaWJ1dGVWYWx1ZT48L3NhbWw6QXR0cmlidXRlPjxzYW1sOkF0dHJpYnV0ZSBOYW1lPSJlbWFpbCIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDpiYXNpYyI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeHNpOnR5cGU9InhzOnN0cmluZyI+dXNlckBleGFtcGxlLmNvbTwvc2FtbDpBdHRyaWJ1dGVWYWx1ZT48L3NhbWw6QXR0cmlidXRlPjxzYW1sOkF0dHJpYnV0ZSBOYW1lPSJ1c2VyX2dyb3VwcyIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDpiYXNpYyI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeHNpOnR5cGU9InhzOnN0cmluZyI+bWVtYmVyPC9zYW1sOkF0dHJpYnV0ZVZhbHVlPjxzYW1sOkF0dHJpYnV0ZVZhbHVlIHhzaTp0eXBlPSJ4czpzdHJpbmciPmFkbWluPC9zYW1sOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDpBdHRyaWJ1dGU+PC9zYW1sOkF0dHJpYnV0ZVN0YXRlbWVudD48L3NhbWw6QXNzZXJ0aW9uPjwvc2FtbHA6UmVzcG9uc2U+'; + + protected $sloResponseData = 'fZHRa8IwEMb/lZJ3bdJa04a2MOYYglOY4sNe5JKms9gmpZfC/vxF3ZjC8OXgLvl938ddjtC1vVjZTzu6d429NaiDr641KC5PBRkHIyxgg8JAp1E4JbZPbysRTanoB+ussi25QR4TgKgH11hDguWiIIeawTxOaK1iPYt5XcczHUlJeVRlMklBJjOuM1qDVCTY6wE9WRAv5HHEUS8NOjDOjyjLJoxNGN+xVESpSNgHCRYaXWPAXaijc70IQ2ntyUPqNG2tgjY8Z45CbNFLmt8V7GxBNuuX1eZ1uT7EcZJKAE4TJhXPaMxlVlFffPKKJnXE5ryusoiU+VlMXJIN5Y/feXRn1VR92GkHFTiY9sc+D2+p/HqRrQM34n33bCsd7KEd9eMd4+W32I5KaUQSlleHP9Hwv6uX3w=='; + + protected $testCert = 'MIIEazCCAtOgAwIBAgIUe7a088Cnr4izmrnBEnx5q3HTMvYwDQYJKoZIhvcNAQELBQAwRTELMAkGA1UEBhMCR0IxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0xOTExMTYxMjE3MTVaFw0yOTExMTUxMjE3MTVaMEUxCzAJBgNVBAYTAkdCMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQDzLe9FfdyplTxHp4SuQ9gQtZT3t+SDfvEL72ppCfFZw7+B5s5B/T73aXpoQ3S53pGI1RIWCge2iCUQ2tzm27aSNH0iu9aJYcUQZ/RITqd0ayyDks1NA2PT3TW6t3m7KV5re4P0Nb+YDeuyHdkz+jcMtpn8CmBoT0H+skha0hiqINkjkRPiHvLHVGp+tHUEA/I6mN4aB/UExSTLs79NsLUfteqqxe9+tvdUaToyDPrhPFjONs+9NKCkzIC6vcv7J6AtuKG6nET+zB9yOWgtGYQifXqQA2y5dL81BB0q5uMaBLS2pq3aPPjzU2F3+EysjySWTnCkfk7C5SsCXRu8Q+U95tunpNfwf5olE6Was48NMM+PwV7iCNMPkNzllq6PCiM+P8DrMSczzUZZQUSv6dSwPCo+YSVimEM0Og3XJTiNhQ5ANlaIn66Kw5gfoBfuiXmyIKiSDyAiDYmFaf4395wWwLkTR+cw8WfjaHswKZTomn1MR3OJsY2UJ0eRBYM+YSsCAwEAAaNTMFEwHQYDVR0OBBYEFImp2CYCGfcb7w91H/cShTCkXwR/MB8GA1UdIwQYMBaAFImp2CYCGfcb7w91H/cShTCkXwR/MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggGBAA+g/C7uL9ln+W+qBknLW81kojYflgPK1I1MHIwnMvl/ZTHX4dRXKDrk7KcUq1KjqajNV66f1cakp03IijBiO0Xi1gXUZYLoCiNGUyyp9XloiIy9Xw2PiWnrw0+yZyvVssbehXXYJl4RihBjBWul9R4wMYLOUSJDe2WxcUBhJnxyNRs+P0xLSQX6B2n6nxoDko4p07s8ZKXQkeiZ2iwFdTxzRkGjthMUv704nzsVGBT0DCPtfSaO5KJZW1rCs3yiMthnBxq4qEDOQJFIl+/LD71KbB9vZcW5JuavzBFmkKGNro/6G1I7el46IR4wijTyNFCYUuD9dtignNmpWtN8OW+ptiL/jtTySWukjys0s+vLn83CVvjB0dJtVAIYOgXFdIuii66gczwwM/LGiOExJn0dTNzsJ/IYhpxL4FBEuP0pskY0o0aUlJ2LS2j+wSQTRKsBgMjyrUrekle2ODStStn3eabjIx0/FHlpFr0jNIm/oMP7kwjtUX4zaNe47QI4Gg=='; }