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'); + } +}