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