From 07a6d7655fd77b9c33360b855a0c08d922b2f3ed Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Wed, 1 Jul 2020 23:27:50 +0200 Subject: [PATCH 01/24] First basic OpenID Connect implementation --- .env.example.complete | 8 + app/Auth/Access/Guards/OpenIdSessionGuard.php | 39 +++ app/Auth/Access/OpenIdService.php | 235 ++++++++++++++++++ app/Config/auth.php | 4 + app/Config/openid.php | 43 ++++ app/Exceptions/OpenIdException.php | 6 + app/Http/Controllers/Auth/LoginController.php | 3 +- .../Controllers/Auth/OpenIdController.php | 70 ++++++ app/Http/Controllers/UserController.php | 4 +- app/Http/Middleware/VerifyCsrfToken.php | 3 +- app/Providers/AuthServiceProvider.php | 11 + composer.json | 3 +- composer.lock | 177 ++++++++++++- resources/lang/en/errors.php | 4 + .../views/auth/forms/login/openid.blade.php | 11 + resources/views/common/header.blade.php | 2 + resources/views/settings/index.blade.php | 2 +- resources/views/settings/roles/form.blade.php | 2 +- resources/views/users/form.blade.php | 2 +- routes/web.php | 5 + tests/Auth/AuthTest.php | 2 + 21 files changed, 626 insertions(+), 10 deletions(-) create mode 100644 app/Auth/Access/Guards/OpenIdSessionGuard.php create mode 100644 app/Auth/Access/OpenIdService.php create mode 100644 app/Config/openid.php create mode 100644 app/Exceptions/OpenIdException.php create mode 100644 app/Http/Controllers/Auth/OpenIdController.php create mode 100644 resources/views/auth/forms/login/openid.blade.php diff --git a/.env.example.complete b/.env.example.complete index 472ca051b..b211ad939 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -228,6 +228,14 @@ SAML2_USER_TO_GROUPS=false SAML2_GROUP_ATTRIBUTE=group SAML2_REMOVE_FROM_GROUPS=false +# OpenID Connect authentication configuration +OPENID_CLIENT_ID=null +OPENID_CLIENT_SECRET=null +OPENID_ISSUER=https://example.com +OPENID_PUBLIC_KEY=file:///my/public.key +OPENID_URL_AUTHORIZE=https://example.com/authorize +OPENID_URL_TOKEN=https://example.com/token + # 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/Auth/Access/Guards/OpenIdSessionGuard.php b/app/Auth/Access/Guards/OpenIdSessionGuard.php new file mode 100644 index 000000000..0dfbb67a3 --- /dev/null +++ b/app/Auth/Access/Guards/OpenIdSessionGuard.php @@ -0,0 +1,39 @@ +config = config('openid'); + $this->registrationService = $registrationService; + $this->user = $user; + } + + /** + * Initiate a authorization flow. + * @throws Error + */ + public function login(): array + { + $provider = $this->getProvider(); + return [ + 'url' => $provider->getAuthorizationUrl(), + 'state' => $provider->getState(), + ]; + } + + /** + * Initiate a logout flow. + * @throws Error + */ + public function logout(): array + { + $this->actionLogout(); + $url = '/'; + $id = null; + + return ['url' => $url, 'id' => $id]; + } + + /** + * Process the Authorization response from the authorization server and + * return the matching, or new if registration active, user matched to + * the authorization server. + * Returns null if not authenticated. + * @throws Error + * @throws OpenIdException + * @throws ValidationError + * @throws JsonDebugException + * @throws UserRegistrationException + */ + public function processAuthorizeResponse(?string $authorizationCode): ?User + { + $provider = $this->getProvider(); + + // Try to exchange authorization code for access token + $accessToken = $provider->getAccessToken('authorization_code', [ + 'code' => $authorizationCode, + ]); + + return $this->processAccessTokenCallback($accessToken); + } + + /** + * Do the required actions to log a user out. + */ + protected function actionLogout() + { + auth()->logout(); + session()->invalidate(); + } + + /** + * Load the underlying Onelogin SAML2 toolkit. + * @throws Error + * @throws Exception + */ + protected function getProvider(): OpenIDConnectProvider + { + $settings = $this->config['openid']; + $overrides = $this->config['openid_overrides'] ?? []; + + if ($overrides && is_string($overrides)) { + $overrides = json_decode($overrides, true); + } + + $openIdSettings = $this->loadOpenIdDetails(); + $settings = array_replace_recursive($settings, $openIdSettings, $overrides); + + $signer = new \Lcobucci\JWT\Signer\Rsa\Sha256(); + return new OpenIDConnectProvider($settings, ['signer' => $signer]); + } + + /** + * Load dynamic service provider options required by the onelogin toolkit. + */ + protected function loadOpenIdDetails(): array + { + return [ + 'redirectUri' => url('/openid/redirect'), + ]; + } + + /** + * Calculate the display name + */ + protected function getUserDisplayName(Token $token, string $defaultValue): string + { + $displayNameAttr = $this->config['display_name_attributes']; + + $displayName = []; + foreach ($displayNameAttr as $dnAttr) { + $dnComponent = $token->getClaim($dnAttr, ''); + if ($dnComponent !== '') { + $displayName[] = $dnComponent; + } + } + + if (count($displayName) == 0) { + $displayName = $defaultValue; + } else { + $displayName = implode(' ', $displayName); + } + + return $displayName; + } + + /** + * 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(Token $token, string $defaultValue) + { + $userNameAttr = $this->config['external_id_attribute']; + if ($userNameAttr === null) { + return $defaultValue; + } + + return $token->getClaim($userNameAttr, $defaultValue); + } + + /** + * Extract the details of a user from a SAML response. + */ + protected function getUserDetails(Token $token): array + { + $email = null; + $emailAttr = $this->config['email_attribute']; + if ($token->hasClaim($emailAttr)) { + $email = $token->getClaim($emailAttr); + } + + return [ + 'external_id' => $token->getClaim('sub'), + 'email' => $email, + 'name' => $this->getUserDisplayName($token, $email), + ]; + } + + /** + * Get the user from the database for the specified details. + * @throws OpenIdException + * @throws UserRegistrationException + */ + protected function getOrRegisterUser(array $userDetails): ?User + { + $user = $this->user->newQuery() + ->where('external_auth_id', '=', $userDetails['external_id']) + ->first(); + + if (is_null($user)) { + $userData = [ + 'name' => $userDetails['name'], + 'email' => $userDetails['email'], + 'password' => Str::random(32), + 'external_auth_id' => $userDetails['external_id'], + ]; + + $user = $this->registrationService->registerUser($userData, null, false); + } + + return $user; + } + + /** + * Processes a received access token for a user. Login the user when + * they exist, optionally registering them automatically. + * @throws OpenIdException + * @throws JsonDebugException + * @throws UserRegistrationException + */ + public function processAccessTokenCallback(AccessToken $accessToken): User + { + $userDetails = $this->getUserDetails($accessToken->getIdToken()); + $isLoggedIn = auth()->check(); + + if ($this->config['dump_user_details']) { + throw new JsonDebugException($accessToken->jsonSerialize()); + } + + if ($userDetails['email'] === null) { + throw new OpenIdException(trans('errors.openid_no_email_address')); + } + + if ($isLoggedIn) { + throw new OpenIdException(trans('errors.openid_already_logged_in'), '/login'); + } + + $user = $this->getOrRegisterUser($userDetails); + if ($user === null) { + throw new OpenIdException(trans('errors.openid_user_not_registered', ['name' => $userDetails['external_id']]), '/login'); + } + + auth()->login($user); + return $user; + } +} diff --git a/app/Config/auth.php b/app/Config/auth.php index 51b152ff1..a1824bc78 100644 --- a/app/Config/auth.php +++ b/app/Config/auth.php @@ -40,6 +40,10 @@ return [ 'driver' => 'saml2-session', 'provider' => 'external', ], + 'openid' => [ + 'driver' => 'openid-session', + 'provider' => 'external', + ], 'api' => [ 'driver' => 'api-token', ], diff --git a/app/Config/openid.php b/app/Config/openid.php new file mode 100644 index 000000000..2232ba7b2 --- /dev/null +++ b/app/Config/openid.php @@ -0,0 +1,43 @@ + env('OPENID_NAME', 'SSO'), + + // Dump user details after a login request for debugging purposes + 'dump_user_details' => env('OPENID_DUMP_USER_DETAILS', false), + + // Attribute, within a OpenId token, to find the user's email address + 'email_attribute' => env('OPENID_EMAIL_ATTRIBUTE', 'email'), + // Attribute, within a OpenId token, to find the user's display name + 'display_name_attributes' => explode('|', env('OPENID_DISPLAY_NAME_ATTRIBUTES', 'username')), + // Attribute, within a OpenId token, to use to connect a BookStack user to the OpenId user. + 'external_id_attribute' => env('OPENID_EXTERNAL_ID_ATTRIBUTE', null), + + // Overrides, in JSON format, to the configuration passed to underlying OpenIDConnectProvider library. + 'openid_overrides' => env('OPENID_OVERRIDES', null), + + 'openid' => [ + // OAuth2/OpenId client id, as configured in your Authorization server. + 'clientId' => env('OPENID_CLIENT_ID', ''), + + // OAuth2/OpenId client secret, as configured in your Authorization server. + 'clientSecret' => env('OPENID_CLIENT_SECRET', ''), + + // OAuth2 scopes that are request, by default the OpenId-native profile and email scopes. + 'scopes' => 'profile email', + + // The issuer of the identity token (id_token) this will be compared with what is returned in the token. + 'idTokenIssuer' => env('OPENID_ISSUER', ''), + + // Public key that's used to verify the JWT token with. + 'publicKey' => env('OPENID_PUBLIC_KEY', ''), + + // OAuth2 endpoints. + 'urlAuthorize' => env('OPENID_URL_AUTHORIZE', ''), + 'urlAccessToken' => env('OPENID_URL_TOKEN', ''), + 'urlResourceOwnerDetails' => env('OPENID_URL_RESOURCE', ''), + ], + +]; diff --git a/app/Exceptions/OpenIdException.php b/app/Exceptions/OpenIdException.php new file mode 100644 index 000000000..056f95c56 --- /dev/null +++ b/app/Exceptions/OpenIdException.php @@ -0,0 +1,6 @@ +can('users-manage') && $user->can('user-roles-manage')) { - $guards = ['standard', 'ldap', 'saml2']; + $guards = ['standard', 'ldap', 'saml2', 'openid']; foreach ($guards as $guard) { auth($guard)->login($user); } @@ -186,5 +186,4 @@ class LoginController extends Controller return redirect('/login'); } - } diff --git a/app/Http/Controllers/Auth/OpenIdController.php b/app/Http/Controllers/Auth/OpenIdController.php new file mode 100644 index 000000000..8e475ffdb --- /dev/null +++ b/app/Http/Controllers/Auth/OpenIdController.php @@ -0,0 +1,70 @@ +openidService = $openidService; + $this->middleware('guard:openid'); + } + + /** + * Start the authorization login flow via OpenId Connect. + */ + public function login() + { + $loginDetails = $this->openidService->login(); + session()->flash('openid_state', $loginDetails['state']); + + return redirect($loginDetails['url']); + } + + /** + * Start the logout flow via OpenId Connect. + */ + public function logout() + { + $logoutDetails = $this->openidService->logout(); + + if ($logoutDetails['id']) { + session()->flash('saml2_logout_request_id', $logoutDetails['id']); + } + + return redirect($logoutDetails['url']); + } + + /** + * Authorization flow Redirect. + * Processes authorization response from the OpenId Connect Authorization Server. + */ + public function redirect() + { + $storedState = session()->pull('openid_state'); + $responseState = request()->query('state'); + + if ($storedState !== $responseState) { + $this->showErrorNotification(trans('errors.openid_fail_authed', ['system' => config('saml2.name')])); + return redirect('/login'); + } + + $user = $this->openidService->processAuthorizeResponse(request()->query('code')); + if ($user === null) { + $this->showErrorNotification(trans('errors.openid_fail_authed', ['system' => config('saml2.name')])); + return redirect('/login'); + } + + return redirect()->intended(); + } +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 97ec31dcc..5db3c59bd 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -76,7 +76,7 @@ class UserController extends Controller if ($authMethod === 'standard' && !$sendInvite) { $validationRules['password'] = 'required|min:6'; $validationRules['password-confirm'] = 'required|same:password'; - } elseif ($authMethod === 'ldap' || $authMethod === 'saml2') { + } elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') { $validationRules['external_auth_id'] = 'required'; } $this->validate($request, $validationRules); @@ -85,7 +85,7 @@ class UserController extends Controller if ($authMethod === 'standard') { $user->password = bcrypt($request->get('password', Str::random(32))); - } elseif ($authMethod === 'ldap' || $authMethod === 'saml2') { + } elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') { $user->external_auth_id = $request->get('external_auth_id'); } diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index bdeb26554..007564eb3 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -19,6 +19,7 @@ class VerifyCsrfToken extends Middleware * @var array */ protected $except = [ - 'saml2/*' + 'saml2/*', + 'openid/*' ]; } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index fe52df168..1b3f169ae 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -7,6 +7,7 @@ use BookStack\Api\ApiTokenGuard; use BookStack\Auth\Access\ExternalBaseUserProvider; use BookStack\Auth\Access\Guards\LdapSessionGuard; use BookStack\Auth\Access\Guards\Saml2SessionGuard; +use BookStack\Auth\Access\Guards\OpenIdSessionGuard; use BookStack\Auth\Access\LdapService; use BookStack\Auth\Access\RegistrationService; use BookStack\Auth\UserRepo; @@ -45,6 +46,16 @@ class AuthServiceProvider extends ServiceProvider $app[RegistrationService::class] ); }); + + Auth::extend('openid-session', function ($app, $name, array $config) { + $provider = Auth::createUserProvider($config['provider']); + return new OpenIdSessionGuard( + $name, + $provider, + $this->app['session.store'], + $app[RegistrationService::class] + ); + }); } /** diff --git a/composer.json b/composer.json index 59fc909d6..7b1a3d592 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,8 @@ "socialiteproviders/microsoft-azure": "^3.0", "socialiteproviders/okta": "^1.0", "socialiteproviders/slack": "^3.0", - "socialiteproviders/twitch": "^5.0" + "socialiteproviders/twitch": "^5.0", + "steverhoades/oauth2-openid-connect-client": "^0.3.0" }, "require-dev": { "barryvdh/laravel-debugbar": "^3.2.8", diff --git a/composer.lock b/composer.lock index a8c3b1e50..0f5e29792 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "34390536dd685e0bc49b179babaa06ec", + "content-hash": "f9d604c2456771f9b939f04492dde182", "packages": [ { "name": "aws/aws-sdk-php", @@ -1814,6 +1814,71 @@ ], "time": "2020-02-04T15:30:01+00:00" }, + { + "name": "lcobucci/jwt", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "56f10808089e38623345e28af2f2d5e4eb579455" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/56f10808089e38623345e28af2f2d5e4eb579455", + "reference": "56f10808089e38623345e28af2f2d5e4eb579455", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-openssl": "*", + "php": "^5.6 || ^7.0" + }, + "require-dev": { + "mikey179/vfsstream": "~1.5", + "phpmd/phpmd": "~2.2", + "phpunit/php-invoker": "~1.1", + "phpunit/phpunit": "^5.7 || ^7.3", + "squizlabs/php_codesniffer": "~2.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Otávio Cobucci Oblonczyk", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2020-05-22T08:21:12+00:00" + }, { "name": "league/commonmark", "version": "1.4.3", @@ -2114,6 +2179,73 @@ ], "time": "2016-08-17T00:36:58+00:00" }, + { + "name": "league/oauth2-client", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-client.git", + "reference": "cc114abc622a53af969e8664722e84ca36257530" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/cc114abc622a53af969e8664722e84ca36257530", + "reference": "cc114abc622a53af969e8664722e84ca36257530", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.0", + "paragonie/random_compat": "^1|^2|^9.99", + "php": "^5.6|^7.0" + }, + "require-dev": { + "eloquent/liberator": "^2.0", + "eloquent/phony-phpunit": "^1.0|^3.0", + "jakub-onderka/php-parallel-lint": "^0.9.2", + "phpunit/phpunit": "^5.7|^6.0", + "squizlabs/php_codesniffer": "^2.3|^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Woody Gilk", + "homepage": "https://github.com/shadowhand", + "role": "Contributor" + } + ], + "description": "OAuth 2.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "identity", + "idp", + "oauth", + "oauth2", + "single sign on" + ], + "time": "2018-11-22T18:33:57+00:00" + }, { "name": "monolog/monolog", "version": "2.1.0", @@ -3502,6 +3634,49 @@ "description": "Twitch OAuth2 Provider for Laravel Socialite", "time": "2020-05-06T22:51:30+00:00" }, + { + "name": "steverhoades/oauth2-openid-connect-client", + "version": "v0.3.0", + "source": { + "type": "git", + "url": "https://github.com/steverhoades/oauth2-openid-connect-client.git", + "reference": "0159471487540a4620b8d0b693f5f215503a8d75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/steverhoades/oauth2-openid-connect-client/zipball/0159471487540a4620b8d0b693f5f215503a8d75", + "reference": "0159471487540a4620b8d0b693f5f215503a8d75", + "shasum": "" + }, + "require": { + "lcobucci/jwt": "^3.2", + "league/oauth2-client": "^2.0" + }, + "require-dev": { + "phpmd/phpmd": "~2.2", + "phpunit/php-invoker": "~1.1", + "phpunit/phpunit": "~4.5", + "squizlabs/php_codesniffer": "~2.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "OpenIDConnectClient\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Steve Rhoades", + "email": "sedonami@gmail.com" + } + ], + "description": "OAuth2 OpenID Connect Client that utilizes the PHP Leagues OAuth2 Client", + "time": "2020-05-19T23:06:36+00:00" + }, { "name": "swiftmailer/swiftmailer", "version": "v6.2.3", diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index 06a5285f5..ec4ce813e 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -23,6 +23,10 @@ return [ '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', + 'openid_already_logged_in' => 'Already logged in', + 'openid_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'openid_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'openid_fail_authed' => 'Login using :system failed, system did not provide successful authorization', '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/forms/login/openid.blade.php b/resources/views/auth/forms/login/openid.blade.php new file mode 100644 index 000000000..b1ef51d13 --- /dev/null +++ b/resources/views/auth/forms/login/openid.blade.php @@ -0,0 +1,11 @@ +
+ {!! csrf_field() !!} + +
+ +
+ +
\ No newline at end of file diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index 827abcac6..996d44d27 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -66,6 +66,8 @@
  • @if(config('auth.method') === 'saml2') @icon('logout'){{ trans('auth.logout') }} + @elseif(config('auth.method') === 'openid') + @icon('logout'){{ trans('auth.logout') }} @else @icon('logout'){{ trans('auth.logout') }} @endif diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php index 6ccb8d8f9..500db64e6 100644 --- a/resources/views/settings/index.blade.php +++ b/resources/views/settings/index.blade.php @@ -224,7 +224,7 @@ 'label' => trans('settings.reg_enable_toggle') ]) - @if(in_array(config('auth.method'), ['ldap', 'saml2'])) + @if(in_array(config('auth.method'), ['ldap', 'saml2', 'openid']))
    {{ trans('settings.reg_enable_external_warning') }}
    @endif diff --git a/resources/views/settings/roles/form.blade.php b/resources/views/settings/roles/form.blade.php index ed57ad944..b5aa96911 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('auth.method') === 'saml2') + @if(config('auth.method') === 'ldap' || config('auth.method') === 'saml2' || config('auth.method') === 'openid')
    @include('form.text', ['name' => 'external_auth_id']) diff --git a/resources/views/users/form.blade.php b/resources/views/users/form.blade.php index df3d06c2f..f3e8cedff 100644 --- a/resources/views/users/form.blade.php +++ b/resources/views/users/form.blade.php @@ -25,7 +25,7 @@
    -@if(($authMethod === 'ldap' || $authMethod === 'saml2') && userCan('users-manage')) +@if(($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') && userCan('users-manage'))
    diff --git a/routes/web.php b/routes/web.php index 6b7911825..a47080e8e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -234,6 +234,11 @@ Route::get('/saml2/metadata', 'Auth\Saml2Controller@metadata'); Route::get('/saml2/sls', 'Auth\Saml2Controller@sls'); Route::post('/saml2/acs', 'Auth\Saml2Controller@acs'); +// OpenId routes +Route::post('/openid/login', 'Auth\OpenIdController@login'); +Route::get('/openid/logout', 'Auth\OpenIdController@logout'); +Route::get('/openid/redirect', 'Auth\OpenIdController@redirect'); + // User invitation routes Route::get('/register/invite/{token}', 'Auth\UserInviteController@showSetPassword'); Route::post('/register/invite/{token}', 'Auth\UserInviteController@setPassword'); diff --git a/tests/Auth/AuthTest.php b/tests/Auth/AuthTest.php index f1f476966..779d5e70f 100644 --- a/tests/Auth/AuthTest.php +++ b/tests/Auth/AuthTest.php @@ -387,6 +387,7 @@ class AuthTest extends BrowserKitTest $this->assertTrue(auth()->check()); $this->assertTrue(auth('ldap')->check()); $this->assertTrue(auth('saml2')->check()); + $this->assertTrue(auth('openid')->check()); } public function test_login_authenticates_nonadmins_on_default_guard_only() @@ -399,6 +400,7 @@ class AuthTest extends BrowserKitTest $this->assertTrue(auth()->check()); $this->assertFalse(auth('ldap')->check()); $this->assertFalse(auth('saml2')->check()); + $this->assertFalse(auth('openid')->check()); } /** From 25144a13c7150c75a023cb039972a3f784bee8cf Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Mon, 6 Jul 2020 18:14:43 +0200 Subject: [PATCH 02/24] Deduplicated getOrRegisterUser method --- app/Auth/Access/ExternalAuthService.php | 37 +++++++++++++++++++++++++ app/Auth/Access/OpenIdService.php | 32 ++------------------- app/Auth/Access/Saml2Service.php | 32 ++------------------- 3 files changed, 41 insertions(+), 60 deletions(-) diff --git a/app/Auth/Access/ExternalAuthService.php b/app/Auth/Access/ExternalAuthService.php index db8bd2dfb..7f15307ae 100644 --- a/app/Auth/Access/ExternalAuthService.php +++ b/app/Auth/Access/ExternalAuthService.php @@ -3,9 +3,46 @@ use BookStack\Auth\Role; use BookStack\Auth\User; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Str; class ExternalAuthService { + protected $registrationService; + protected $user; + + /** + * ExternalAuthService base constructor. + */ + public function __construct(RegistrationService $registrationService, User $user) + { + $this->registrationService = $registrationService; + $this->user = $user; + } + + /** + * Get the user from the database for the specified details. + * @throws UserRegistrationException + */ + protected function getOrRegisterUser(array $userDetails): ?User + { + $user = $this->user->newQuery() + ->where('external_auth_id', '=', $userDetails['external_id']) + ->first(); + + if (is_null($user)) { + $userData = [ + 'name' => $userDetails['name'], + 'email' => $userDetails['email'], + 'password' => Str::random(32), + 'external_auth_id' => $userDetails['external_id'], + ]; + + $user = $this->registrationService->registerUser($userData, null, false); + } + + return $user; + } + /** * 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. diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 870299a57..084adfb13 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -5,7 +5,6 @@ use BookStack\Exceptions\JsonDebugException; use BookStack\Exceptions\OpenIdException; use BookStack\Exceptions\UserRegistrationException; use Exception; -use Illuminate\Support\Str; use Lcobucci\JWT\Token; use OpenIDConnectClient\AccessToken; use OpenIDConnectClient\OpenIDConnectProvider; @@ -17,17 +16,15 @@ use OpenIDConnectClient\OpenIDConnectProvider; class OpenIdService extends ExternalAuthService { protected $config; - protected $registrationService; - protected $user; /** * OpenIdService constructor. */ public function __construct(RegistrationService $registrationService, User $user) { + parent::__construct($registrationService, $user); + $this->config = config('openid'); - $this->registrationService = $registrationService; - $this->user = $user; } /** @@ -175,31 +172,6 @@ class OpenIdService extends ExternalAuthService ]; } - /** - * Get the user from the database for the specified details. - * @throws OpenIdException - * @throws UserRegistrationException - */ - protected function getOrRegisterUser(array $userDetails): ?User - { - $user = $this->user->newQuery() - ->where('external_auth_id', '=', $userDetails['external_id']) - ->first(); - - if (is_null($user)) { - $userData = [ - 'name' => $userDetails['name'], - 'email' => $userDetails['email'], - 'password' => Str::random(32), - 'external_auth_id' => $userDetails['external_id'], - ]; - - $user = $this->registrationService->registerUser($userData, null, false); - } - - return $user; - } - /** * Processes a received access token for a user. Login the user when * they exist, optionally registering them automatically. diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index 8f9a24cde..4c1fce864 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -5,7 +5,6 @@ use BookStack\Exceptions\JsonDebugException; use BookStack\Exceptions\SamlException; use BookStack\Exceptions\UserRegistrationException; use Exception; -use Illuminate\Support\Str; use OneLogin\Saml2\Auth; use OneLogin\Saml2\Error; use OneLogin\Saml2\IdPMetadataParser; @@ -18,17 +17,15 @@ use OneLogin\Saml2\ValidationError; class Saml2Service extends ExternalAuthService { protected $config; - protected $registrationService; - protected $user; /** * Saml2Service constructor. */ public function __construct(RegistrationService $registrationService, User $user) { + parent::__construct($registrationService, $user); + $this->config = config('saml2'); - $this->registrationService = $registrationService; - $this->user = $user; } /** @@ -309,31 +306,6 @@ class Saml2Service extends ExternalAuthService return $defaultValue; } - /** - * Get the user from the database for the specified details. - * @throws SamlException - * @throws UserRegistrationException - */ - protected function getOrRegisterUser(array $userDetails): ?User - { - $user = $this->user->newQuery() - ->where('external_auth_id', '=', $userDetails['external_id']) - ->first(); - - if (is_null($user)) { - $userData = [ - 'name' => $userDetails['name'], - 'email' => $userDetails['email'], - 'password' => Str::random(32), - 'external_auth_id' => $userDetails['external_id'], - ]; - - $user = $this->registrationService->registerUser($userData, null, false); - } - - return $user; - } - /** * Process the SAML response for a user. Login the user when * they exist, optionally registering them automatically. From 10c890947f9ea5661729f88e9e85464522498dd7 Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Tue, 7 Jul 2020 02:26:00 +0200 Subject: [PATCH 03/24] Token expiration and refreshing using the refresh_token flow --- app/Auth/Access/Guards/OpenIdSessionGuard.php | 40 ++++++++++++++++ app/Auth/Access/OpenIdService.php | 46 ++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/app/Auth/Access/Guards/OpenIdSessionGuard.php b/app/Auth/Access/Guards/OpenIdSessionGuard.php index 0dfbb67a3..634464493 100644 --- a/app/Auth/Access/Guards/OpenIdSessionGuard.php +++ b/app/Auth/Access/Guards/OpenIdSessionGuard.php @@ -2,6 +2,11 @@ namespace BookStack\Auth\Access\Guards; +use BookStack\Auth\Access\OpenIdService; +use BookStack\Auth\Access\RegistrationService; +use Illuminate\Contracts\Auth\UserProvider; +use Illuminate\Contracts\Session\Session; + /** * OpenId Session Guard * @@ -14,6 +19,41 @@ namespace BookStack\Auth\Access\Guards; */ class OpenIdSessionGuard extends ExternalBaseSessionGuard { + + protected $openidService; + + /** + * OpenIdSessionGuard constructor. + */ + public function __construct( + $name, + UserProvider $provider, + Session $session, + OpenIdService $openidService, + RegistrationService $registrationService + ) { + $this->openidService = $openidService; + parent::__construct($name, $provider, $session, $registrationService); + } + + /** + * Get the currently authenticated user. + * + * @return \Illuminate\Contracts\Auth\Authenticatable|null + */ + public function user() + { + // retrieve the current user + $user = parent::user(); + + // refresh the current user + if ($user && !$this->openidService->refresh()) { + $this->user = null; + } + + return $this->user; + } + /** * Validate a user's credentials. * diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 084adfb13..377925d61 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -6,6 +6,7 @@ use BookStack\Exceptions\OpenIdException; use BookStack\Exceptions\UserRegistrationException; use Exception; use Lcobucci\JWT\Token; +use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use OpenIDConnectClient\AccessToken; use OpenIDConnectClient\OpenIDConnectProvider; @@ -53,6 +54,46 @@ class OpenIdService extends ExternalAuthService return ['url' => $url, 'id' => $id]; } + /** + * Refresh the currently logged in user. + * @throws Error + */ + public function refresh(): bool + { + // Retrieve access token for current session + $json = session()->get('openid_token'); + $accessToken = new AccessToken(json_decode($json, true)); + + // Check whether the access token or ID token is expired + if (!$accessToken->getIdToken()->isExpired() && !$accessToken->hasExpired()) { + return true; + } + + // If no refresh token available, logout + if ($accessToken->getRefreshToken() === null) { + $this->actionLogout(); + return false; + } + + // ID token or access token is expired, we refresh it using the refresh token + try { + $provider = $this->getProvider(); + + $accessToken = $provider->getAccessToken('refresh_token', [ + 'refresh_token' => $accessToken->getRefreshToken(), + ]); + } catch (IdentityProviderException $e) { + // Refreshing failed, logout + $this->actionLogout(); + return false; + } + + // A valid token was obtained, we update the access token + session()->put('openid_token', json_encode($accessToken)); + + return true; + } + /** * Process the Authorization response from the authorization server and * return the matching, or new if registration active, user matched to @@ -86,7 +127,7 @@ class OpenIdService extends ExternalAuthService } /** - * Load the underlying Onelogin SAML2 toolkit. + * Load the underlying OpenID Connect Provider. * @throws Error * @throws Exception */ @@ -155,7 +196,7 @@ class OpenIdService extends ExternalAuthService } /** - * Extract the details of a user from a SAML response. + * Extract the details of a user from an ID token. */ protected function getUserDetails(Token $token): array { @@ -202,6 +243,7 @@ class OpenIdService extends ExternalAuthService } auth()->login($user); + session()->put('openid_token', json_encode($accessToken)); return $user; } } From 5df7db510524a156a0a1f0d659a06a02dd5d3644 Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Tue, 7 Jul 2020 02:51:33 +0200 Subject: [PATCH 04/24] Ignore ID token expiry if unavailable --- app/Auth/Access/OpenIdService.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 377925d61..7b651c3de 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -8,6 +8,7 @@ use Exception; use Lcobucci\JWT\Token; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use OpenIDConnectClient\AccessToken; +use OpenIDConnectClient\Exception\InvalidTokenException; use OpenIDConnectClient\OpenIDConnectProvider; /** @@ -64,8 +65,9 @@ class OpenIdService extends ExternalAuthService $json = session()->get('openid_token'); $accessToken = new AccessToken(json_decode($json, true)); - // Check whether the access token or ID token is expired - if (!$accessToken->getIdToken()->isExpired() && !$accessToken->hasExpired()) { + // Check if both the access token and the ID token (if present) are unexpired + $idToken = $accessToken->getIdToken(); + if (!$accessToken->hasExpired() && (!$idToken || !$idToken->isExpired())) { return true; } @@ -86,6 +88,9 @@ class OpenIdService extends ExternalAuthService // Refreshing failed, logout $this->actionLogout(); return false; + } catch (InvalidTokenException $e) { + // A refresh token doesn't necessarily contain + // an ID token, ignore this exception } // A valid token was obtained, we update the access token From 97cde9c56a3268da179c2701d209a9a1224bac85 Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Wed, 8 Jul 2020 17:02:52 +0200 Subject: [PATCH 05/24] Generalize refresh failure handling --- app/Auth/Access/OpenIdService.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 7b651c3de..14b6ac9a5 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -88,9 +88,10 @@ class OpenIdService extends ExternalAuthService // Refreshing failed, logout $this->actionLogout(); return false; - } catch (InvalidTokenException $e) { - // A refresh token doesn't necessarily contain - // an ID token, ignore this exception + } catch (\Exception $e) { + // Unknown error, logout and throw + $this->actionLogout(); + throw $e; } // A valid token was obtained, we update the access token From 13d0260cc97c5cce9399f44afa65b70857499da6 Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Thu, 9 Jul 2020 16:27:45 +0200 Subject: [PATCH 06/24] Configurable OpenID Connect services --- app/Auth/Access/OpenIdService.php | 22 +++++++++++++++++++--- app/Config/openid.php | 3 +++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 14b6ac9a5..fc0c00298 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -139,6 +139,7 @@ class OpenIdService extends ExternalAuthService */ protected function getProvider(): OpenIDConnectProvider { + // Setup settings $settings = $this->config['openid']; $overrides = $this->config['openid_overrides'] ?? []; @@ -149,12 +150,27 @@ class OpenIdService extends ExternalAuthService $openIdSettings = $this->loadOpenIdDetails(); $settings = array_replace_recursive($settings, $openIdSettings, $overrides); - $signer = new \Lcobucci\JWT\Signer\Rsa\Sha256(); - return new OpenIDConnectProvider($settings, ['signer' => $signer]); + // Setup services + $services = $this->loadOpenIdServices(); + $overrides = $this->config['openid_services'] ?? []; + + $services = array_replace_recursive($services, $overrides); + + return new OpenIDConnectProvider($settings, $services); } /** - * Load dynamic service provider options required by the onelogin toolkit. + * Load services utilized by the OpenID Connect provider. + */ + protected function loadOpenIdServices(): array + { + return [ + 'signer' => new \Lcobucci\JWT\Signer\Rsa\Sha256(), + ]; + } + + /** + * Load dynamic service provider options required by the OpenID Connect provider. */ protected function loadOpenIdDetails(): array { diff --git a/app/Config/openid.php b/app/Config/openid.php index 2232ba7b2..20089518b 100644 --- a/app/Config/openid.php +++ b/app/Config/openid.php @@ -18,6 +18,9 @@ return [ // Overrides, in JSON format, to the configuration passed to underlying OpenIDConnectProvider library. 'openid_overrides' => env('OPENID_OVERRIDES', null), + // Custom service instances, used by the underlying OpenIDConnectProvider library + 'openid_services' => [], + 'openid' => [ // OAuth2/OpenId client id, as configured in your Authorization server. 'clientId' => env('OPENID_CLIENT_ID', ''), From 75b4a05200ebf6b107b4448915f811f247bcba69 Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Thu, 9 Jul 2020 18:00:16 +0200 Subject: [PATCH 07/24] Add OpenIdService to OpenIdSessionGuard constructor call --- app/Providers/AuthServiceProvider.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 1b3f169ae..653a29248 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -9,6 +9,7 @@ use BookStack\Auth\Access\Guards\LdapSessionGuard; use BookStack\Auth\Access\Guards\Saml2SessionGuard; use BookStack\Auth\Access\Guards\OpenIdSessionGuard; use BookStack\Auth\Access\LdapService; +use BookStack\Auth\Access\OpenIdService; use BookStack\Auth\Access\RegistrationService; use BookStack\Auth\UserRepo; use Illuminate\Support\ServiceProvider; @@ -53,6 +54,7 @@ class AuthServiceProvider extends ServiceProvider $name, $provider, $this->app['session.store'], + $app[OpenIdService::class], $app[RegistrationService::class] ); }); From 46388a591b7cff9364dff2502419ffdafab0137c Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Thu, 9 Jul 2020 18:29:44 +0200 Subject: [PATCH 08/24] AccessToken empty array parameter on null --- app/Auth/Access/OpenIdService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index fc0c00298..3d8818fa5 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -63,7 +63,7 @@ class OpenIdService extends ExternalAuthService { // Retrieve access token for current session $json = session()->get('openid_token'); - $accessToken = new AccessToken(json_decode($json, true)); + $accessToken = new AccessToken(json_decode($json, true) ?? []); // Check if both the access token and the ID token (if present) are unexpired $idToken = $accessToken->getIdToken(); From 6feaf25c902d8cf1315ca0612e3f54387dbb55f4 Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Tue, 4 Aug 2020 21:29:11 +0200 Subject: [PATCH 09/24] Increase robustness of the refresh method --- app/Auth/Access/OpenIdService.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 3d8818fa5..2b536d492 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -8,7 +8,6 @@ use Exception; use Lcobucci\JWT\Token; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use OpenIDConnectClient\AccessToken; -use OpenIDConnectClient\Exception\InvalidTokenException; use OpenIDConnectClient\OpenIDConnectProvider; /** @@ -63,11 +62,20 @@ class OpenIdService extends ExternalAuthService { // Retrieve access token for current session $json = session()->get('openid_token'); + + // If no access token was found, reject the refresh + if (!$json) { + $this->actionLogout(); + return false; + } + $accessToken = new AccessToken(json_decode($json, true) ?? []); // Check if both the access token and the ID token (if present) are unexpired $idToken = $accessToken->getIdToken(); - if (!$accessToken->hasExpired() && (!$idToken || !$idToken->isExpired())) { + $accessTokenUnexpired = $accessToken->getExpires() && !$accessToken->hasExpired(); + $idTokenUnexpired = !$idToken || !$idToken->isExpired(); + if ($accessTokenUnexpired && $idTokenUnexpired) { return true; } From 23402ae81287bdfd0539d20a3a81c38d9efce1e5 Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Tue, 4 Aug 2020 21:30:17 +0200 Subject: [PATCH 10/24] Initial unit tests for OpenID --- tests/Auth/OpenIdTest.php | 112 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/Auth/OpenIdTest.php diff --git a/tests/Auth/OpenIdTest.php b/tests/Auth/OpenIdTest.php new file mode 100644 index 000000000..9ad90fb5b --- /dev/null +++ b/tests/Auth/OpenIdTest.php @@ -0,0 +1,112 @@ +set([ + 'auth.method' => 'openid', + 'auth.defaults.guard' => 'openid', + 'openid.name' => 'SingleSignOn-Testing', + 'openid.email_attribute' => 'email', + 'openid.display_name_attributes' => ['given_name', 'family_name'], + 'openid.external_id_attribute' => 'uid', + 'openid.openid_overrides' => null, + 'openid.openid.clientId' => 'testapp', + 'openid.openid.clientSecret' => 'testpass', + 'openid.openid.publicKey' => $this->testCert, + 'openid.openid.idTokenIssuer' => 'https://openid.local', + 'openid.openid.urlAuthorize' => 'https://openid.local/auth', + 'openid.openid.urlAccessToken' => 'https://openid.local/token', + ]); + } + + public function test_openid_overrides_functions_as_expected() + { + $json = '{"urlAuthorize": "https://openid.local/custom"}'; + config()->set(['openid.openid_overrides' => $json]); + + $req = $this->get('/openid/login'); + $redirect = $req->headers->get('location'); + $this->assertStringStartsWith('https://openid.local/custom', $redirect, 'Login redirects to SSO location'); + } + + public function test_login_option_shows_on_login_page() + { + $req = $this->get('/login'); + $req->assertSeeText('SingleSignOn-Testing'); + $req->assertElementExists('form[action$="/openid/login"][method=POST] button'); + } + + public function test_login() + { + $req = $this->post('/openid/login'); + $redirect = $req->headers->get('location'); + + $this->assertStringStartsWith('https://openid.local/auth', $redirect, 'Login redirects to SSO location'); + $this->assertFalse($this->isAuthenticated()); + } + + public function test_openid_routes_are_only_active_if_openid_enabled() + { + config()->set(['auth.method' => 'standard']); + $getRoutes = ['/logout', '/metadata', '/sls']; + foreach ($getRoutes as $route) { + $req = $this->get('/openid' . $route); + $this->assertPermissionError($req); + } + + $postRoutes = ['/login', '/acs']; + foreach ($postRoutes as $route) { + $req = $this->post('/openid' . $route); + $this->assertPermissionError($req); + } + } + + public function test_forgot_password_routes_inaccessible() + { + $resp = $this->get('/password/email'); + $this->assertPermissionError($resp); + + $resp = $this->post('/password/email'); + $this->assertPermissionError($resp); + + $resp = $this->get('/password/reset/abc123'); + $this->assertPermissionError($resp); + + $resp = $this->post('/password/reset'); + $this->assertPermissionError($resp); + } + + public function test_standard_login_routes_inaccessible() + { + $resp = $this->post('/login'); + $this->assertPermissionError($resp); + + $resp = $this->get('/logout'); + $this->assertPermissionError($resp); + } + + public function test_user_invite_routes_inaccessible() + { + $resp = $this->get('/register/invite/abc123'); + $this->assertPermissionError($resp); + + $resp = $this->post('/register/invite/abc123'); + $this->assertPermissionError($resp); + } + + public function test_user_register_routes_inaccessible() + { + $resp = $this->get('/register'); + $this->assertPermissionError($resp); + + $resp = $this->post('/register'); + $this->assertPermissionError($resp); + } +} From f2d320825a34b425457954b832bccd3a6ed56cfd Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Tue, 4 Aug 2020 22:09:53 +0200 Subject: [PATCH 11/24] Simplify refresh method --- app/Auth/Access/OpenIdService.php | 65 +++++++++++++++++++------------ 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 2b536d492..4eea3c252 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -71,41 +71,56 @@ class OpenIdService extends ExternalAuthService $accessToken = new AccessToken(json_decode($json, true) ?? []); - // Check if both the access token and the ID token (if present) are unexpired - $idToken = $accessToken->getIdToken(); - $accessTokenUnexpired = $accessToken->getExpires() && !$accessToken->hasExpired(); - $idTokenUnexpired = !$idToken || !$idToken->isExpired(); - if ($accessTokenUnexpired && $idTokenUnexpired) { + // If the token is not expired, refreshing isn't necessary + if ($this->isUnexpired($accessToken)) { return true; } - // If no refresh token available, logout - if ($accessToken->getRefreshToken() === null) { - $this->actionLogout(); - return false; - } - - // ID token or access token is expired, we refresh it using the refresh token + // Try to obtain refreshed access token try { - $provider = $this->getProvider(); - - $accessToken = $provider->getAccessToken('refresh_token', [ - 'refresh_token' => $accessToken->getRefreshToken(), - ]); - } catch (IdentityProviderException $e) { - // Refreshing failed, logout - $this->actionLogout(); - return false; + $newAccessToken = $this->refreshAccessToken($accessToken); } catch (\Exception $e) { - // Unknown error, logout and throw + // Log out if an unknown problem arises $this->actionLogout(); throw $e; } - // A valid token was obtained, we update the access token - session()->put('openid_token', json_encode($accessToken)); + // If a token was obtained, update the access token, otherwise log out + if ($newAccessToken !== null) { + session()->put('openid_token', json_encode($newAccessToken)); + return true; + } else { + $this->actionLogout(); + return false; + } + } - return true; + protected function isUnexpired(AccessToken $accessToken): bool + { + $idToken = $accessToken->getIdToken(); + + $accessTokenUnexpired = $accessToken->getExpires() && !$accessToken->hasExpired(); + $idTokenUnexpired = !$idToken || !$idToken->isExpired(); + + return $accessTokenUnexpired && $idTokenUnexpired; + } + + protected function refreshAccessToken(AccessToken $accessToken): ?AccessToken + { + // If no refresh token available, abort + if ($accessToken->getRefreshToken() === null) { + return null; + } + + // ID token or access token is expired, we refresh it using the refresh token + try { + return $this->getProvider()->getAccessToken('refresh_token', [ + 'refresh_token' => $accessToken->getRefreshToken(), + ]); + } catch (IdentityProviderException $e) { + // Refreshing failed + return null; + } } /** From 35c48b94163d8a17bcdd9a9fb360a3f43f1cd2b5 Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Wed, 5 Aug 2020 00:18:43 +0200 Subject: [PATCH 12/24] Method descriptions --- app/Auth/Access/OpenIdService.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 4eea3c252..70df963f5 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -95,6 +95,9 @@ class OpenIdService extends ExternalAuthService } } + /** + * Check whether an access token or OpenID token isn't expired. + */ protected function isUnexpired(AccessToken $accessToken): bool { $idToken = $accessToken->getIdToken(); @@ -105,6 +108,10 @@ class OpenIdService extends ExternalAuthService return $accessTokenUnexpired && $idTokenUnexpired; } + /** + * Generate an updated access token, through the associated refresh token. + * @throws Error + */ protected function refreshAccessToken(AccessToken $accessToken): ?AccessToken { // If no refresh token available, abort From 69a47319d51efb3d39bb36eff4df9ca2c31165ae Mon Sep 17 00:00:00 2001 From: Jasper Weyne Date: Wed, 5 Aug 2020 13:14:46 +0200 Subject: [PATCH 13/24] Default OpenID display name set to standard value --- app/Config/openid.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Config/openid.php b/app/Config/openid.php index 20089518b..90eb6d5db 100644 --- a/app/Config/openid.php +++ b/app/Config/openid.php @@ -11,7 +11,7 @@ return [ // Attribute, within a OpenId token, to find the user's email address 'email_attribute' => env('OPENID_EMAIL_ATTRIBUTE', 'email'), // Attribute, within a OpenId token, to find the user's display name - 'display_name_attributes' => explode('|', env('OPENID_DISPLAY_NAME_ATTRIBUTES', 'username')), + 'display_name_attributes' => explode('|', env('OPENID_DISPLAY_NAME_ATTRIBUTES', 'name')), // Attribute, within a OpenId token, to use to connect a BookStack user to the OpenId user. 'external_id_attribute' => env('OPENID_EXTERNAL_ID_ATTRIBUTE', null), From 2ec0aa85cab7c09f45589af6b05a053d44a8ca46 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 6 Oct 2021 17:12:01 +0100 Subject: [PATCH 14/24] Started refactor for merge of OIDC - Made oidc config more generic to not be overly reliant on the library based upon learnings from saml2 auth. - Removed any settings that are redundant or not deemed required for initial implementation. - Reduced some methods down where not needed. - Renamed OpenID to OIDC - Updated .env.example.complete to align with all options and their defaults Related to #2169 --- .env.example.complete | 15 +- app/Auth/Access/ExternalAuthService.php | 2 +- app/Auth/Access/OpenIdService.php | 108 ++---- app/Auth/Access/Saml2Service.php | 2 +- app/Config/oidc.php | 30 ++ app/Config/openid.php | 46 --- composer.lock | 418 ++++++++++++------------ 7 files changed, 283 insertions(+), 338 deletions(-) create mode 100644 app/Config/oidc.php delete mode 100644 app/Config/openid.php diff --git a/.env.example.complete b/.env.example.complete index 5a586d1d1..e92eb5099 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -240,12 +240,15 @@ SAML2_GROUP_ATTRIBUTE=group SAML2_REMOVE_FROM_GROUPS=false # OpenID Connect authentication configuration -OPENID_CLIENT_ID=null -OPENID_CLIENT_SECRET=null -OPENID_ISSUER=https://example.com -OPENID_PUBLIC_KEY=file:///my/public.key -OPENID_URL_AUTHORIZE=https://example.com/authorize -OPENID_URL_TOKEN=https://example.com/token +OIDC_NAME=SSO +OIDC_DISPLAY_NAME_CLAIMS=name +OIDC_CLIENT_ID=null +OIDC_CLIENT_SECRET=null +OIDC_ISSUER=null +OIDC_PUBLIC_KEY=null +OIDC_AUTH_ENDPOINT=null +OIDC_TOKEN_ENDPOINT=null +OIDC_DUMP_USER_DETAILS=false # Disable default third-party services such as Gravatar and Draw.IO # Service-specific options will override this option diff --git a/app/Auth/Access/ExternalAuthService.php b/app/Auth/Access/ExternalAuthService.php index b0c9e8e7b..b2b9302af 100644 --- a/app/Auth/Access/ExternalAuthService.php +++ b/app/Auth/Access/ExternalAuthService.php @@ -4,8 +4,8 @@ namespace BookStack\Auth\Access; use BookStack\Auth\Role; use BookStack\Auth\User; +use BookStack\Exceptions\UserRegistrationException; use Illuminate\Support\Collection; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Str; class ExternalAuthService diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php index 70df963f5..f56ff0b91 100644 --- a/app/Auth/Access/OpenIdService.php +++ b/app/Auth/Access/OpenIdService.php @@ -5,9 +5,11 @@ use BookStack\Exceptions\JsonDebugException; use BookStack\Exceptions\OpenIdException; use BookStack\Exceptions\UserRegistrationException; use Exception; +use Lcobucci\JWT\Signer\Rsa\Sha256; use Lcobucci\JWT\Token; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use OpenIDConnectClient\AccessToken; +use OpenIDConnectClient\Exception\InvalidTokenException; use OpenIDConnectClient\OpenIDConnectProvider; /** @@ -25,12 +27,12 @@ class OpenIdService extends ExternalAuthService { parent::__construct($registrationService, $user); - $this->config = config('openid'); + $this->config = config('oidc'); } /** - * Initiate a authorization flow. - * @throws Error + * Initiate an authorization flow. + * @throws Exception */ public function login(): array { @@ -43,7 +45,6 @@ class OpenIdService extends ExternalAuthService /** * Initiate a logout flow. - * @throws Error */ public function logout(): array { @@ -56,7 +57,7 @@ class OpenIdService extends ExternalAuthService /** * Refresh the currently logged in user. - * @throws Error + * @throws Exception */ public function refresh(): bool { @@ -79,7 +80,7 @@ class OpenIdService extends ExternalAuthService // Try to obtain refreshed access token try { $newAccessToken = $this->refreshAccessToken($accessToken); - } catch (\Exception $e) { + } catch (Exception $e) { // Log out if an unknown problem arises $this->actionLogout(); throw $e; @@ -110,7 +111,7 @@ class OpenIdService extends ExternalAuthService /** * Generate an updated access token, through the associated refresh token. - * @throws Error + * @throws Exception */ protected function refreshAccessToken(AccessToken $accessToken): ?AccessToken { @@ -135,11 +136,8 @@ class OpenIdService extends ExternalAuthService * return the matching, or new if registration active, user matched to * the authorization server. * Returns null if not authenticated. - * @throws Error - * @throws OpenIdException - * @throws ValidationError - * @throws JsonDebugException - * @throws UserRegistrationException + * @throws Exception + * @throws InvalidTokenException */ public function processAuthorizeResponse(?string $authorizationCode): ?User { @@ -164,87 +162,50 @@ class OpenIdService extends ExternalAuthService /** * Load the underlying OpenID Connect Provider. - * @throws Error - * @throws Exception */ protected function getProvider(): OpenIDConnectProvider { // Setup settings - $settings = $this->config['openid']; - $overrides = $this->config['openid_overrides'] ?? []; - - if ($overrides && is_string($overrides)) { - $overrides = json_decode($overrides, true); - } - - $openIdSettings = $this->loadOpenIdDetails(); - $settings = array_replace_recursive($settings, $openIdSettings, $overrides); + $settings = [ + 'clientId' => $this->config['client_id'], + 'clientSecret' => $this->config['client_secret'], + 'idTokenIssuer' => $this->config['issuer'], + 'redirectUri' => url('/openid/redirect'), + 'urlAuthorize' => $this->config['authorization_endpoint'], + 'urlAccessToken' => $this->config['token_endpoint'], + 'urlResourceOwnerDetails' => null, + 'publicKey' => $this->config['jwt_public_key'], + 'scopes' => 'profile email', + ]; // Setup services - $services = $this->loadOpenIdServices(); - $overrides = $this->config['openid_services'] ?? []; - - $services = array_replace_recursive($services, $overrides); + $services = [ + 'signer' => new Sha256(), + ]; return new OpenIDConnectProvider($settings, $services); } - /** - * Load services utilized by the OpenID Connect provider. - */ - protected function loadOpenIdServices(): array - { - return [ - 'signer' => new \Lcobucci\JWT\Signer\Rsa\Sha256(), - ]; - } - - /** - * Load dynamic service provider options required by the OpenID Connect provider. - */ - protected function loadOpenIdDetails(): array - { - return [ - 'redirectUri' => url('/openid/redirect'), - ]; - } - /** * Calculate the display name */ protected function getUserDisplayName(Token $token, string $defaultValue): string { - $displayNameAttr = $this->config['display_name_attributes']; + $displayNameAttr = $this->config['display_name_claims']; $displayName = []; foreach ($displayNameAttr as $dnAttr) { - $dnComponent = $token->getClaim($dnAttr, ''); + $dnComponent = $token->claims()->get($dnAttr, ''); if ($dnComponent !== '') { $displayName[] = $dnComponent; } } if (count($displayName) == 0) { - $displayName = $defaultValue; - } else { - $displayName = implode(' ', $displayName); + $displayName[] = $defaultValue; } - return $displayName; - } - - /** - * 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(Token $token, string $defaultValue) - { - $userNameAttr = $this->config['external_id_attribute']; - if ($userNameAttr === null) { - return $defaultValue; - } - - return $token->getClaim($userNameAttr, $defaultValue); + return implode(' ', $displayName);; } /** @@ -252,16 +213,11 @@ class OpenIdService extends ExternalAuthService */ protected function getUserDetails(Token $token): array { - $email = null; - $emailAttr = $this->config['email_attribute']; - if ($token->hasClaim($emailAttr)) { - $email = $token->getClaim($emailAttr); - } - + $id = $token->claims()->get('sub'); return [ - 'external_id' => $token->getClaim('sub'), - 'email' => $email, - 'name' => $this->getUserDisplayName($token, $email), + 'external_id' => $id, + 'email' => $token->claims()->get('email'), + 'name' => $this->getUserDisplayName($token, $id), ]; } diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index 74e8c7726..b1489fbce 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -26,7 +26,7 @@ class Saml2Service extends ExternalAuthService /** * Saml2Service constructor. */ - public function __construct(RegistrationService $registrationService, LoginService $loginService, User $user), + public function __construct(RegistrationService $registrationService, LoginService $loginService, User $user) { parent::__construct($registrationService, $user); diff --git a/app/Config/oidc.php b/app/Config/oidc.php new file mode 100644 index 000000000..43e8678ad --- /dev/null +++ b/app/Config/oidc.php @@ -0,0 +1,30 @@ + env('OIDC_NAME', 'SSO'), + + // Dump user details after a login request for debugging purposes + 'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false), + + // Attribute, within a OpenId token, to find the user's display name + 'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')), + + // OAuth2/OpenId client id, as configured in your Authorization server. + 'client_id' => env('OIDC_CLIENT_ID', null), + + // OAuth2/OpenId client secret, as configured in your Authorization server. + 'client_secret' => env('OIDC_CLIENT_SECRET', null), + + // The issuer of the identity token (id_token) this will be compared with what is returned in the token. + 'issuer' => env('OIDC_ISSUER', null), + + // Public key that's used to verify the JWT token with. + // Can be the key value itself or a local 'file://public.key' reference. + 'jwt_public_key' => env('OIDC_PUBLIC_KEY', null), + + // OAuth2 endpoints. + 'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null), + 'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null), +]; diff --git a/app/Config/openid.php b/app/Config/openid.php deleted file mode 100644 index 90eb6d5db..000000000 --- a/app/Config/openid.php +++ /dev/null @@ -1,46 +0,0 @@ - env('OPENID_NAME', 'SSO'), - - // Dump user details after a login request for debugging purposes - 'dump_user_details' => env('OPENID_DUMP_USER_DETAILS', false), - - // Attribute, within a OpenId token, to find the user's email address - 'email_attribute' => env('OPENID_EMAIL_ATTRIBUTE', 'email'), - // Attribute, within a OpenId token, to find the user's display name - 'display_name_attributes' => explode('|', env('OPENID_DISPLAY_NAME_ATTRIBUTES', 'name')), - // Attribute, within a OpenId token, to use to connect a BookStack user to the OpenId user. - 'external_id_attribute' => env('OPENID_EXTERNAL_ID_ATTRIBUTE', null), - - // Overrides, in JSON format, to the configuration passed to underlying OpenIDConnectProvider library. - 'openid_overrides' => env('OPENID_OVERRIDES', null), - - // Custom service instances, used by the underlying OpenIDConnectProvider library - 'openid_services' => [], - - 'openid' => [ - // OAuth2/OpenId client id, as configured in your Authorization server. - 'clientId' => env('OPENID_CLIENT_ID', ''), - - // OAuth2/OpenId client secret, as configured in your Authorization server. - 'clientSecret' => env('OPENID_CLIENT_SECRET', ''), - - // OAuth2 scopes that are request, by default the OpenId-native profile and email scopes. - 'scopes' => 'profile email', - - // The issuer of the identity token (id_token) this will be compared with what is returned in the token. - 'idTokenIssuer' => env('OPENID_ISSUER', ''), - - // Public key that's used to verify the JWT token with. - 'publicKey' => env('OPENID_PUBLIC_KEY', ''), - - // OAuth2 endpoints. - 'urlAuthorize' => env('OPENID_URL_AUTHORIZE', ''), - 'urlAccessToken' => env('OPENID_URL_TOKEN', ''), - 'urlResourceOwnerDetails' => env('OPENID_URL_RESOURCE', ''), - ], - -]; diff --git a/composer.lock b/composer.lock index a3cfe6e7e..9355deed3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "10825887b8f66d1d412b92bcc0ca864f", + "content-hash": "620412108a5d19ed91d9fe42418b63b5", "packages": [ { "name": "aws/aws-crt-php", @@ -58,16 +58,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.194.1", + "version": "3.197.0", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "67bdee05acef9e8ad60098090996690b49babd09" + "reference": "c5391ef7c979473b97d81329100bfa5fb018fa62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/67bdee05acef9e8ad60098090996690b49babd09", - "reference": "67bdee05acef9e8ad60098090996690b49babd09", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/c5391ef7c979473b97d81329100bfa5fb018fa62", + "reference": "c5391ef7c979473b97d81329100bfa5fb018fa62", "shasum": "" }, "require": { @@ -143,9 +143,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.194.1" + "source": "https://github.com/aws/aws-sdk-php/tree/3.197.0" }, - "time": "2021-09-17T18:15:42+00:00" + "time": "2021-10-05T18:14:34+00:00" }, { "name": "bacon/bacon-qr-code", @@ -479,16 +479,16 @@ }, { "name": "doctrine/dbal", - "version": "2.13.3", + "version": "2.13.4", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "0d7adf4cadfee6f70850e5b163e6cdd706417838" + "reference": "2411a55a2a628e6d8dd598388ab13474802c7b6e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/0d7adf4cadfee6f70850e5b163e6cdd706417838", - "reference": "0d7adf4cadfee6f70850e5b163e6cdd706417838", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/2411a55a2a628e6d8dd598388ab13474802c7b6e", + "reference": "2411a55a2a628e6d8dd598388ab13474802c7b6e", "shasum": "" }, "require": { @@ -501,8 +501,8 @@ "require-dev": { "doctrine/coding-standard": "9.0.0", "jetbrains/phpstorm-stubs": "2021.1", - "phpstan/phpstan": "0.12.96", - "phpunit/phpunit": "^7.5.20|^8.5|9.5.5", + "phpstan/phpstan": "0.12.99", + "phpunit/phpunit": "^7.5.20|^8.5|9.5.10", "psalm/plugin-phpunit": "0.16.1", "squizlabs/php_codesniffer": "3.6.0", "symfony/cache": "^4.4", @@ -568,7 +568,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/2.13.3" + "source": "https://github.com/doctrine/dbal/tree/2.13.4" }, "funding": [ { @@ -584,7 +584,7 @@ "type": "tidelift" } ], - "time": "2021-09-12T19:11:48+00:00" + "time": "2021-10-02T15:59:26+00:00" }, { "name": "doctrine/deprecations", @@ -1356,21 +1356,21 @@ }, { "name": "filp/whoops", - "version": "2.14.1", + "version": "2.14.4", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "15ead64e9828f0fc90932114429c4f7923570cb1" + "reference": "f056f1fe935d9ed86e698905a957334029899895" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/15ead64e9828f0fc90932114429c4f7923570cb1", - "reference": "15ead64e9828f0fc90932114429c4f7923570cb1", + "url": "https://api.github.com/repos/filp/whoops/zipball/f056f1fe935d9ed86e698905a957334029899895", + "reference": "f056f1fe935d9ed86e698905a957334029899895", "shasum": "" }, "require": { "php": "^5.5.9 || ^7.0 || ^8.0", - "psr/log": "^1.0.1" + "psr/log": "^1.0.1 || ^2.0 || ^3.0" }, "require-dev": { "mockery/mockery": "^0.9 || ^1.0", @@ -1415,7 +1415,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.14.1" + "source": "https://github.com/filp/whoops/tree/2.14.4" }, "funding": [ { @@ -1423,7 +1423,7 @@ "type": "github" } ], - "time": "2021-08-29T12:00:00+00:00" + "time": "2021-10-03T12:00:00+00:00" }, { "name": "guzzlehttp/guzzle", @@ -1585,16 +1585,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "1.8.2", + "version": "1.8.3", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "dc960a912984efb74d0a90222870c72c87f10c91" + "reference": "1afdd860a2566ed3c2b0b4a3de6e23434a79ec85" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/dc960a912984efb74d0a90222870c72c87f10c91", - "reference": "dc960a912984efb74d0a90222870c72c87f10c91", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/1afdd860a2566ed3c2b0b4a3de6e23434a79ec85", + "reference": "1afdd860a2566ed3c2b0b4a3de6e23434a79ec85", "shasum": "" }, "require": { @@ -1631,13 +1631,34 @@ "MIT" ], "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, { "name": "Michael Dowling", "email": "mtdowling@gmail.com", "homepage": "https://github.com/mtdowling" }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, { "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", "homepage": "https://github.com/Tobion" } ], @@ -1654,22 +1675,36 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/1.8.2" + "source": "https://github.com/guzzle/psr7/tree/1.8.3" }, - "time": "2021-04-26T09:17:50+00:00" + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2021-10-05T13:56:00+00:00" }, { "name": "intervention/image", - "version": "2.6.1", + "version": "2.7.0", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "0925f10b259679b5d8ca58f3a2add9255ffcda45" + "reference": "9a8cc99d30415ec0b3f7649e1647d03a55698545" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/0925f10b259679b5d8ca58f3a2add9255ffcda45", - "reference": "0925f10b259679b5d8ca58f3a2add9255ffcda45", + "url": "https://api.github.com/repos/Intervention/image/zipball/9a8cc99d30415ec0b3f7649e1647d03a55698545", + "reference": "9a8cc99d30415ec0b3f7649e1647d03a55698545", "shasum": "" }, "require": { @@ -1728,7 +1763,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/2.6.1" + "source": "https://github.com/Intervention/image/tree/2.7.0" }, "funding": [ { @@ -1740,7 +1775,7 @@ "type": "github" } ], - "time": "2021-07-22T14:31:53+00:00" + "time": "2021-10-03T14:17:12+00:00" }, { "name": "knplabs/knp-snappy", @@ -1814,16 +1849,16 @@ }, { "name": "laravel/framework", - "version": "v6.20.34", + "version": "v6.20.35", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "72a6da88c90cee793513b3fe49cf0fcb368eefa0" + "reference": "5e55aa4063b9f7cf3249bfebcc37a6fbad4f159a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/72a6da88c90cee793513b3fe49cf0fcb368eefa0", - "reference": "72a6da88c90cee793513b3fe49cf0fcb368eefa0", + "url": "https://api.github.com/repos/laravel/framework/zipball/5e55aa4063b9f7cf3249bfebcc37a6fbad4f159a", + "reference": "5e55aa4063b9f7cf3249bfebcc37a6fbad4f159a", "shasum": "" }, "require": { @@ -1963,7 +1998,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2021-09-07T13:28:55+00:00" + "time": "2021-10-05T14:05:19+00:00" }, { "name": "laravel/socialite", @@ -2036,16 +2071,16 @@ }, { "name": "lcobucci/jwt", - "version": "3.3.2", + "version": "3.4.6", "source": { "type": "git", "url": "https://github.com/lcobucci/jwt.git", - "reference": "56f10808089e38623345e28af2f2d5e4eb579455" + "reference": "3ef8657a78278dfeae7707d51747251db4176240" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lcobucci/jwt/zipball/56f10808089e38623345e28af2f2d5e4eb579455", - "reference": "56f10808089e38623345e28af2f2d5e4eb579455", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/3ef8657a78278dfeae7707d51747251db4176240", + "reference": "3ef8657a78278dfeae7707d51747251db4176240", "shasum": "" }, "require": { @@ -2060,6 +2095,9 @@ "phpunit/phpunit": "^5.7 || ^7.3", "squizlabs/php_codesniffer": "~2.3" }, + "suggest": { + "lcobucci/clock": "*" + }, "type": "library", "extra": { "branch-alias": { @@ -2069,7 +2107,12 @@ "autoload": { "psr-4": { "Lcobucci\\JWT\\": "src" - } + }, + "files": [ + "compat/class-aliases.php", + "compat/json-exception-polyfill.php", + "compat/lcobucci-clock-polyfill.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2087,6 +2130,10 @@ "JWS", "jwt" ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/3.4.6" + }, "funding": [ { "url": "https://github.com/lcobucci", @@ -2097,7 +2144,7 @@ "type": "patreon" } ], - "time": "2020-05-22T08:21:12+00:00" + "time": "2021-09-28T19:18:28+00:00" }, { "name": "league/commonmark", @@ -2436,16 +2483,16 @@ }, { "name": "league/mime-type-detection", - "version": "1.7.0", + "version": "1.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3" + "reference": "b38b25d7b372e9fddb00335400467b223349fd7e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3", - "reference": "3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/b38b25d7b372e9fddb00335400467b223349fd7e", + "reference": "b38b25d7b372e9fddb00335400467b223349fd7e", "shasum": "" }, "require": { @@ -2476,7 +2523,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.7.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.8.0" }, "funding": [ { @@ -2488,7 +2535,7 @@ "type": "tidelift" } ], - "time": "2021-01-18T20:58:21+00:00" + "time": "2021-09-25T08:23:19+00:00" }, { "name": "league/oauth1-client", @@ -2568,29 +2615,28 @@ }, { "name": "league/oauth2-client", - "version": "2.4.1", + "version": "2.6.0", "source": { "type": "git", "url": "https://github.com/thephpleague/oauth2-client.git", - "reference": "cc114abc622a53af969e8664722e84ca36257530" + "reference": "badb01e62383430706433191b82506b6df24ad98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/cc114abc622a53af969e8664722e84ca36257530", - "reference": "cc114abc622a53af969e8664722e84ca36257530", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/badb01e62383430706433191b82506b6df24ad98", + "reference": "badb01e62383430706433191b82506b6df24ad98", "shasum": "" }, "require": { - "guzzlehttp/guzzle": "^6.0", - "paragonie/random_compat": "^1|^2|^9.99", - "php": "^5.6|^7.0" + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "paragonie/random_compat": "^1 || ^2 || ^9.99", + "php": "^5.6 || ^7.0 || ^8.0" }, "require-dev": { - "eloquent/liberator": "^2.0", - "eloquent/phony-phpunit": "^1.0|^3.0", - "jakub-onderka/php-parallel-lint": "^0.9.2", - "phpunit/phpunit": "^5.7|^6.0", - "squizlabs/php_codesniffer": "^2.3|^3.0" + "mockery/mockery": "^1.3", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpunit/phpunit": "^5.7 || ^6.0 || ^9.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0" }, "type": "library", "extra": { @@ -2631,20 +2677,24 @@ "oauth2", "single sign on" ], - "time": "2018-11-22T18:33:57+00:00" + "support": { + "issues": "https://github.com/thephpleague/oauth2-client/issues", + "source": "https://github.com/thephpleague/oauth2-client/tree/2.6.0" + }, + "time": "2020-10-28T02:03:40+00:00" }, { "name": "monolog/monolog", - "version": "2.3.4", + "version": "2.3.5", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "437e7a1c50044b92773b361af77620efb76fff59" + "reference": "fd4380d6fc37626e2f799f29d91195040137eba9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/437e7a1c50044b92773b361af77620efb76fff59", - "reference": "437e7a1c50044b92773b361af77620efb76fff59", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/fd4380d6fc37626e2f799f29d91195040137eba9", + "reference": "fd4380d6fc37626e2f799f29d91195040137eba9", "shasum": "" }, "require": { @@ -2660,7 +2710,7 @@ "elasticsearch/elasticsearch": "^7", "graylog2/gelf-php": "^1.4.2", "mongodb/mongodb": "^1.8", - "php-amqplib/php-amqplib": "~2.4", + "php-amqplib/php-amqplib": "~2.4 || ^3", "php-console/php-console": "^3.1.3", "phpspec/prophecy": "^1.6.1", "phpstan/phpstan": "^0.12.91", @@ -2718,7 +2768,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/2.3.4" + "source": "https://github.com/Seldaek/monolog/tree/2.3.5" }, "funding": [ { @@ -2730,7 +2780,7 @@ "type": "tidelift" } ], - "time": "2021-09-15T11:27:21+00:00" + "time": "2021-10-01T21:08:31+00:00" }, { "name": "mtdowling/jmespath.php", @@ -3515,16 +3565,16 @@ }, { "name": "predis/predis", - "version": "v1.1.7", + "version": "v1.1.9", "source": { "type": "git", "url": "https://github.com/predis/predis.git", - "reference": "b240daa106d4e02f0c5b7079b41e31ddf66fddf8" + "reference": "c50c3393bb9f47fa012d0cdfb727a266b0818259" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/predis/predis/zipball/b240daa106d4e02f0c5b7079b41e31ddf66fddf8", - "reference": "b240daa106d4e02f0c5b7079b41e31ddf66fddf8", + "url": "https://api.github.com/repos/predis/predis/zipball/c50c3393bb9f47fa012d0cdfb727a266b0818259", + "reference": "c50c3393bb9f47fa012d0cdfb727a266b0818259", "shasum": "" }, "require": { @@ -3569,7 +3619,7 @@ ], "support": { "issues": "https://github.com/predis/predis/issues", - "source": "https://github.com/predis/predis/tree/v1.1.7" + "source": "https://github.com/predis/predis/tree/v1.1.9" }, "funding": [ { @@ -3577,7 +3627,7 @@ "type": "github" } ], - "time": "2021-04-04T19:34:46+00:00" + "time": "2021-10-05T19:02:38+00:00" }, { "name": "psr/container", @@ -3879,22 +3929,22 @@ }, { "name": "ramsey/uuid", - "version": "3.9.4", + "version": "3.9.6", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "be2451bef8147b7352a28fb4cddb08adc497ada3" + "reference": "ffa80ab953edd85d5b6c004f96181a538aad35a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/be2451bef8147b7352a28fb4cddb08adc497ada3", - "reference": "be2451bef8147b7352a28fb4cddb08adc497ada3", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/ffa80ab953edd85d5b6c004f96181a538aad35a3", + "reference": "ffa80ab953edd85d5b6c004f96181a538aad35a3", "shasum": "" }, "require": { "ext-json": "*", "paragonie/random_compat": "^1 | ^2 | ^9.99.99", - "php": "^5.4 | ^7 | ^8", + "php": "^5.4 | ^7.0 | ^8.0", "symfony/polyfill-ctype": "^1.8" }, "replace": { @@ -3903,14 +3953,16 @@ "require-dev": { "codeception/aspect-mock": "^1 | ^2", "doctrine/annotations": "^1.2", - "goaop/framework": "1.0.0-alpha.2 | ^1 | ^2.1", - "jakub-onderka/php-parallel-lint": "^1", + "goaop/framework": "1.0.0-alpha.2 | ^1 | >=2.1.0 <=2.3.2", "mockery/mockery": "^0.9.11 | ^1", "moontoast/math": "^1.1", + "nikic/php-parser": "<=4.5.0", "paragonie/random-lib": "^2", - "php-mock/php-mock-phpunit": "^0.3 | ^1.1", - "phpunit/phpunit": "^4.8 | ^5.4 | ^6.5", - "squizlabs/php_codesniffer": "^3.5" + "php-mock/php-mock-phpunit": "^0.3 | ^1.1 | ^2.6", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpunit/phpunit": ">=4.8.36 <9.0.0 | >=9.3.0", + "squizlabs/php_codesniffer": "^3.5", + "yoast/phpunit-polyfills": "^1.0" }, "suggest": { "ext-ctype": "Provides support for PHP Ctype functions", @@ -3978,7 +4030,7 @@ "type": "tidelift" } ], - "time": "2021-08-06T20:32:15+00:00" + "time": "2021-09-25T23:07:42+00:00" }, { "name": "robrichards/xmlseclibs", @@ -4553,59 +4605,11 @@ } ], "description": "OAuth2 OpenID Connect Client that utilizes the PHP Leagues OAuth2 Client", - "time": "2020-05-19T23:06:36+00:00" - }, - { - "name": "swiftmailer/swiftmailer", - "version": "v6.2.3", - "source": { - "type": "git", - "url": "https://github.com/ssddanbrown/HtmlDiff.git", - "reference": "f60d5cc278b60305ab980a6665f46117c5b589c0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ssddanbrown/HtmlDiff/zipball/f60d5cc278b60305ab980a6665f46117c5b589c0", - "reference": "f60d5cc278b60305ab980a6665f46117c5b589c0", - "shasum": "" - }, - "require": { - "ext-mbstring": "*", - "php": ">=7.2" - }, - "require-dev": { - "phpunit/phpunit": "^8.5|^9.4.3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Ssddanbrown\\HtmlDiff\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Dan Brown", - "email": "ssddanbrown@googlemail.com", - "role": "Developer" - } - ], - "description": "HTML Content Diff Generator", - "homepage": "https://github.com/ssddanbrown/htmldiff", "support": { - "issues": "https://github.com/ssddanbrown/HtmlDiff/issues", - "source": "https://github.com/ssddanbrown/HtmlDiff/tree/v1.0.1" + "issues": "https://github.com/steverhoades/oauth2-openid-connect-client/issues", + "source": "https://github.com/steverhoades/oauth2-openid-connect-client/tree/master" }, - "funding": [ - { - "url": "https://github.com/ssddanbrown", - "type": "github" - } - ], - "time": "2021-01-24T18:51:30+00:00" + "time": "2020-05-19T23:06:36+00:00" }, { "name": "swiftmailer/swiftmailer", @@ -4840,16 +4844,16 @@ }, { "name": "symfony/debug", - "version": "v4.4.27", + "version": "v4.4.31", "source": { "type": "git", "url": "https://github.com/symfony/debug.git", - "reference": "2f9160e92eb64c95da7368c867b663a8e34e980c" + "reference": "43ede438d4cb52cd589ae5dc070e9323866ba8e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/2f9160e92eb64c95da7368c867b663a8e34e980c", - "reference": "2f9160e92eb64c95da7368c867b663a8e34e980c", + "url": "https://api.github.com/repos/symfony/debug/zipball/43ede438d4cb52cd589ae5dc070e9323866ba8e0", + "reference": "43ede438d4cb52cd589ae5dc070e9323866ba8e0", "shasum": "" }, "require": { @@ -4888,7 +4892,7 @@ "description": "Provides tools to ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/debug/tree/v4.4.27" + "source": "https://github.com/symfony/debug/tree/v4.4.31" }, "funding": [ { @@ -4904,7 +4908,7 @@ "type": "tidelift" } ], - "time": "2021-07-22T07:21:39+00:00" + "time": "2021-09-24T13:30:14+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5414,16 +5418,16 @@ }, { "name": "symfony/http-kernel", - "version": "v4.4.30", + "version": "v4.4.32", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "87f7ea4a8a7a30c967e26001de99f12943bf57ae" + "reference": "f7bda3ea8f05ae90627400e58af5179b25ce0f38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/87f7ea4a8a7a30c967e26001de99f12943bf57ae", - "reference": "87f7ea4a8a7a30c967e26001de99f12943bf57ae", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f7bda3ea8f05ae90627400e58af5179b25ce0f38", + "reference": "f7bda3ea8f05ae90627400e58af5179b25ce0f38", "shasum": "" }, "require": { @@ -5498,7 +5502,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v4.4.30" + "source": "https://github.com/symfony/http-kernel/tree/v4.4.32" }, "funding": [ { @@ -5514,20 +5518,20 @@ "type": "tidelift" } ], - "time": "2021-08-30T12:27:20+00:00" + "time": "2021-09-28T10:20:04+00:00" }, { "name": "symfony/mime", - "version": "v5.3.7", + "version": "v5.3.8", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "ae887cb3b044658676129f5e97aeb7e9eb69c2d8" + "reference": "a756033d0a7e53db389618653ae991eba5a19a11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/ae887cb3b044658676129f5e97aeb7e9eb69c2d8", - "reference": "ae887cb3b044658676129f5e97aeb7e9eb69c2d8", + "url": "https://api.github.com/repos/symfony/mime/zipball/a756033d0a7e53db389618653ae991eba5a19a11", + "reference": "a756033d0a7e53db389618653ae991eba5a19a11", "shasum": "" }, "require": { @@ -5581,7 +5585,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v5.3.7" + "source": "https://github.com/symfony/mime/tree/v5.3.8" }, "funding": [ { @@ -5597,7 +5601,7 @@ "type": "tidelift" } ], - "time": "2021-08-20T11:40:01+00:00" + "time": "2021-09-10T12:30:38+00:00" }, { "name": "symfony/polyfill-ctype", @@ -6479,7 +6483,7 @@ }, { "name": "symfony/translation", - "version": "v4.4.30", + "version": "v4.4.32", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", @@ -6548,7 +6552,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v4.4.30" + "source": "https://github.com/symfony/translation/tree/v4.4.32" }, "funding": [ { @@ -6646,16 +6650,16 @@ }, { "name": "symfony/var-dumper", - "version": "v4.4.30", + "version": "v4.4.31", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "7f65c44c2ce80d3a0fcdb6385ee0ad535e45660c" + "reference": "1f12cc0c2e880a5f39575c19af81438464717839" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7f65c44c2ce80d3a0fcdb6385ee0ad535e45660c", - "reference": "7f65c44c2ce80d3a0fcdb6385ee0ad535e45660c", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/1f12cc0c2e880a5f39575c19af81438464717839", + "reference": "1f12cc0c2e880a5f39575c19af81438464717839", "shasum": "" }, "require": { @@ -6715,7 +6719,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v4.4.30" + "source": "https://github.com/symfony/var-dumper/tree/v4.4.31" }, "funding": [ { @@ -6731,7 +6735,7 @@ "type": "tidelift" } ], - "time": "2021-08-04T20:31:23+00:00" + "time": "2021-09-24T15:30:11+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6788,16 +6792,16 @@ }, { "name": "vlucas/phpdotenv", - "version": "v3.6.8", + "version": "v3.6.9", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "5e679f7616db829358341e2d5cccbd18773bdab8" + "reference": "a1bf4c9853d90ade427b4efe35355fc41b3d6988" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/5e679f7616db829358341e2d5cccbd18773bdab8", - "reference": "5e679f7616db829358341e2d5cccbd18773bdab8", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/a1bf4c9853d90ade427b4efe35355fc41b3d6988", + "reference": "a1bf4c9853d90ade427b4efe35355fc41b3d6988", "shasum": "" }, "require": { @@ -6808,7 +6812,7 @@ "require-dev": { "ext-filter": "*", "ext-pcre": "*", - "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20" + "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.21" }, "suggest": { "ext-filter": "Required to use the boolean validator.", @@ -6832,13 +6836,11 @@ "authors": [ { "name": "Graham Campbell", - "email": "graham@alt-three.com", - "homepage": "https://gjcampbell.co.uk/" + "email": "hello@gjcampbell.co.uk" }, { "name": "Vance Lucas", - "email": "vance@vancelucas.com", - "homepage": "https://vancelucas.com/" + "email": "vance@vancelucas.com" } ], "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", @@ -6849,7 +6851,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v3.6.8" + "source": "https://github.com/vlucas/phpdotenv/tree/v3.6.9" }, "funding": [ { @@ -6861,7 +6863,7 @@ "type": "tidelift" } ], - "time": "2021-01-20T14:39:46+00:00" + "time": "2021-10-02T19:07:56+00:00" } ], "packages-dev": [ @@ -7090,16 +7092,16 @@ }, { "name": "composer/ca-bundle", - "version": "1.2.10", + "version": "1.2.11", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "9fdb22c2e97a614657716178093cd1da90a64aa8" + "reference": "0b072d51c5a9c6f3412f7ea3ab043d6603cb2582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/9fdb22c2e97a614657716178093cd1da90a64aa8", - "reference": "9fdb22c2e97a614657716178093cd1da90a64aa8", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/0b072d51c5a9c6f3412f7ea3ab043d6603cb2582", + "reference": "0b072d51c5a9c6f3412f7ea3ab043d6603cb2582", "shasum": "" }, "require": { @@ -7111,7 +7113,7 @@ "phpstan/phpstan": "^0.12.55", "psr/log": "^1.0", "symfony/phpunit-bridge": "^4.2 || ^5", - "symfony/process": "^2.5 || ^3.0 || ^4.0 || ^5.0" + "symfony/process": "^2.5 || ^3.0 || ^4.0 || ^5.0 || ^6.0" }, "type": "library", "extra": { @@ -7146,7 +7148,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.2.10" + "source": "https://github.com/composer/ca-bundle/tree/1.2.11" }, "funding": [ { @@ -7162,20 +7164,20 @@ "type": "tidelift" } ], - "time": "2021-06-07T13:58:28+00:00" + "time": "2021-09-25T20:32:43+00:00" }, { "name": "composer/composer", - "version": "2.1.8", + "version": "2.1.9", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "24d38e9686092de05214cafa187dc282a5d89497" + "reference": "e558c88f28d102d497adec4852802c0dc14c7077" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/24d38e9686092de05214cafa187dc282a5d89497", - "reference": "24d38e9686092de05214cafa187dc282a5d89497", + "url": "https://api.github.com/repos/composer/composer/zipball/e558c88f28d102d497adec4852802c0dc14c7077", + "reference": "e558c88f28d102d497adec4852802c0dc14c7077", "shasum": "" }, "require": { @@ -7244,7 +7246,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", - "source": "https://github.com/composer/composer/tree/2.1.8" + "source": "https://github.com/composer/composer/tree/2.1.9" }, "funding": [ { @@ -7260,7 +7262,7 @@ "type": "tidelift" } ], - "time": "2021-09-15T11:55:15+00:00" + "time": "2021-10-05T07:47:38+00:00" }, { "name": "composer/metadata-minifier", @@ -8007,16 +8009,16 @@ }, { "name": "nikic/php-parser", - "version": "v4.12.0", + "version": "v4.13.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "6608f01670c3cc5079e18c1dab1104e002579143" + "reference": "50953a2691a922aa1769461637869a0a2faa3f53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/6608f01670c3cc5079e18c1dab1104e002579143", - "reference": "6608f01670c3cc5079e18c1dab1104e002579143", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/50953a2691a922aa1769461637869a0a2faa3f53", + "reference": "50953a2691a922aa1769461637869a0a2faa3f53", "shasum": "" }, "require": { @@ -8057,9 +8059,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.12.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.13.0" }, - "time": "2021-07-21T10:44:31+00:00" + "time": "2021-09-20T12:20:58+00:00" }, { "name": "phar-io/manifest", @@ -8283,16 +8285,16 @@ }, { "name": "phpdocumentor/type-resolver", - "version": "1.5.0", + "version": "1.5.1", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "30f38bffc6f24293dadd1823936372dfa9e86e2f" + "reference": "a12f7e301eb7258bb68acd89d4aefa05c2906cae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/30f38bffc6f24293dadd1823936372dfa9e86e2f", - "reference": "30f38bffc6f24293dadd1823936372dfa9e86e2f", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/a12f7e301eb7258bb68acd89d4aefa05c2906cae", + "reference": "a12f7e301eb7258bb68acd89d4aefa05c2906cae", "shasum": "" }, "require": { @@ -8327,9 +8329,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.5.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.5.1" }, - "time": "2021-09-17T15:28:14+00:00" + "time": "2021-10-02T14:08:47+00:00" }, { "name": "phpspec/prophecy", @@ -8718,16 +8720,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.5.9", + "version": "9.5.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "ea8c2dfb1065eb35a79b3681eee6e6fb0a6f273b" + "reference": "c814a05837f2edb0d1471d6e3f4ab3501ca3899a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ea8c2dfb1065eb35a79b3681eee6e6fb0a6f273b", - "reference": "ea8c2dfb1065eb35a79b3681eee6e6fb0a6f273b", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c814a05837f2edb0d1471d6e3f4ab3501ca3899a", + "reference": "c814a05837f2edb0d1471d6e3f4ab3501ca3899a", "shasum": "" }, "require": { @@ -8743,7 +8745,7 @@ "phar-io/version": "^3.0.2", "php": ">=7.3", "phpspec/prophecy": "^1.12.1", - "phpunit/php-code-coverage": "^9.2.3", + "phpunit/php-code-coverage": "^9.2.7", "phpunit/php-file-iterator": "^3.0.5", "phpunit/php-invoker": "^3.1.1", "phpunit/php-text-template": "^2.0.3", @@ -8805,7 +8807,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.9" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.10" }, "funding": [ { @@ -8817,7 +8819,7 @@ "type": "github" } ], - "time": "2021-08-31T06:47:40+00:00" + "time": "2021-09-25T07:38:51+00:00" }, { "name": "react/promise", From 41438adbd1dbe8688ff8ff7d3dbb835d1e9650e1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 6 Oct 2021 23:05:26 +0100 Subject: [PATCH 15/24] Continued review of #2169 - Removed uneeded custom refresh or logout actions for OIDC. - Restructured how the services and guards are setup for external auth systems. SAML2 and OIDC now directly share a lot more logic. - Renamed any OpenId references to OIDC or OpenIdConnect - Removed non-required CSRF excemption for OIDC Not tested, Come to roadblock due to lack of PHP8 support in upstream dependancies. Certificate was deemed to be non-valid on every test attempt due to changes in PHP8. --- ...alAuthService.php => GroupSyncService.php} | 46 +--- ....php => AsyncExternalBaseSessionGuard.php} | 2 +- app/Auth/Access/Guards/OpenIdSessionGuard.php | 79 ------ app/Auth/Access/LdapService.php | 16 +- app/Auth/Access/OpenIdConnectService.php | 164 +++++++++++ app/Auth/Access/OpenIdService.php | 257 ------------------ app/Auth/Access/RegistrationService.php | 26 ++ app/Auth/Access/Saml2Service.php | 36 ++- app/Config/auth.php | 10 +- app/Exceptions/OpenIdConnectException.php | 6 + app/Exceptions/OpenIdException.php | 6 - .../Auth/OpenIdConnectController.php | 56 ++++ .../Controllers/Auth/OpenIdController.php | 70 ----- app/Http/Middleware/VerifyCsrfToken.php | 1 - app/Providers/AuthServiceProvider.php | 19 +- resources/icons/oidc.svg | 4 + resources/lang/en/errors.php | 8 +- .../auth/parts/login-form-oidc.blade.php | 11 + .../auth/parts/login-form-openid.blade.php | 11 - resources/views/common/header.blade.php | 2 - resources/views/settings/index.blade.php | 2 +- .../views/settings/roles/parts/form.blade.php | 2 +- resources/views/users/parts/form.blade.php | 2 +- routes/web.php | 7 +- 24 files changed, 319 insertions(+), 524 deletions(-) rename app/Auth/Access/{ExternalAuthService.php => GroupSyncService.php} (63%) rename app/Auth/Access/Guards/{Saml2SessionGuard.php => AsyncExternalBaseSessionGuard.php} (92%) delete mode 100644 app/Auth/Access/Guards/OpenIdSessionGuard.php create mode 100644 app/Auth/Access/OpenIdConnectService.php delete mode 100644 app/Auth/Access/OpenIdService.php create mode 100644 app/Exceptions/OpenIdConnectException.php delete mode 100644 app/Exceptions/OpenIdException.php create mode 100644 app/Http/Controllers/Auth/OpenIdConnectController.php delete mode 100644 app/Http/Controllers/Auth/OpenIdController.php create mode 100644 resources/icons/oidc.svg create mode 100644 resources/views/auth/parts/login-form-oidc.blade.php delete mode 100644 resources/views/auth/parts/login-form-openid.blade.php diff --git a/app/Auth/Access/ExternalAuthService.php b/app/Auth/Access/GroupSyncService.php similarity index 63% rename from app/Auth/Access/ExternalAuthService.php rename to app/Auth/Access/GroupSyncService.php index b2b9302af..ddd539b77 100644 --- a/app/Auth/Access/ExternalAuthService.php +++ b/app/Auth/Access/GroupSyncService.php @@ -4,48 +4,10 @@ namespace BookStack\Auth\Access; use BookStack\Auth\Role; use BookStack\Auth\User; -use BookStack\Exceptions\UserRegistrationException; use Illuminate\Support\Collection; -use Illuminate\Support\Str; -class ExternalAuthService +class GroupSyncService { - protected $registrationService; - protected $user; - - /** - * ExternalAuthService base constructor. - */ - public function __construct(RegistrationService $registrationService, User $user) - { - $this->registrationService = $registrationService; - $this->user = $user; - } - - /** - * Get the user from the database for the specified details. - * @throws UserRegistrationException - */ - protected function getOrRegisterUser(array $userDetails): ?User - { - $user = User::query() - ->where('external_auth_id', '=', $userDetails['external_id']) - ->first(); - - if (is_null($user)) { - $userData = [ - 'name' => $userDetails['name'], - 'email' => $userDetails['email'], - 'password' => Str::random(32), - 'external_auth_id' => $userDetails['external_id'], - ]; - - $user = $this->registrationService->registerUser($userData, null, false); - } - - return $user; - } - /** * 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. @@ -98,17 +60,17 @@ class ExternalAuthService /** * Sync the groups to the user roles for the current user. */ - public function syncWithGroups(User $user, array $userGroups): void + public function syncUserWithFoundGroups(User $user, array $userGroups, bool $detachExisting): void { // Get the ids for the roles from the names $groupsAsRoles = $this->matchGroupsToSystemsRoles($userGroups); // Sync groups - if ($this->config['remove_from_groups']) { + if ($detachExisting) { $user->roles()->sync($groupsAsRoles); $user->attachDefaultRole(); } else { $user->roles()->syncWithoutDetaching($groupsAsRoles); } } -} +} \ No newline at end of file diff --git a/app/Auth/Access/Guards/Saml2SessionGuard.php b/app/Auth/Access/Guards/AsyncExternalBaseSessionGuard.php similarity index 92% rename from app/Auth/Access/Guards/Saml2SessionGuard.php rename to app/Auth/Access/Guards/AsyncExternalBaseSessionGuard.php index eacd5d21e..6677f5b10 100644 --- a/app/Auth/Access/Guards/Saml2SessionGuard.php +++ b/app/Auth/Access/Guards/AsyncExternalBaseSessionGuard.php @@ -10,7 +10,7 @@ namespace BookStack\Auth\Access\Guards; * via the Saml2 controller & Saml2Service. This class provides a safer, thin * version of SessionGuard. */ -class Saml2SessionGuard extends ExternalBaseSessionGuard +class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard { /** * Validate a user's credentials. diff --git a/app/Auth/Access/Guards/OpenIdSessionGuard.php b/app/Auth/Access/Guards/OpenIdSessionGuard.php deleted file mode 100644 index 634464493..000000000 --- a/app/Auth/Access/Guards/OpenIdSessionGuard.php +++ /dev/null @@ -1,79 +0,0 @@ -openidService = $openidService; - parent::__construct($name, $provider, $session, $registrationService); - } - - /** - * Get the currently authenticated user. - * - * @return \Illuminate\Contracts\Auth\Authenticatable|null - */ - public function user() - { - // retrieve the current user - $user = parent::user(); - - // refresh the current user - if ($user && !$this->openidService->refresh()) { - $this->user = null; - } - - return $this->user; - } - - /** - * Validate a user's credentials. - * - * @param array $credentials - * @return bool - */ - public function validate(array $credentials = []) - { - return false; - } - - /** - * Attempt to authenticate a user using the given credentials. - * - * @param array $credentials - * @param bool $remember - * @return bool - */ - public function attempt(array $credentials = [], $remember = false) - { - return false; - } -} diff --git a/app/Auth/Access/LdapService.php b/app/Auth/Access/LdapService.php index 7bfdb5328..ddd6ada97 100644 --- a/app/Auth/Access/LdapService.php +++ b/app/Auth/Access/LdapService.php @@ -13,9 +13,10 @@ use Illuminate\Support\Facades\Log; * Class LdapService * Handles any app-specific LDAP tasks. */ -class LdapService extends ExternalAuthService +class LdapService { protected $ldap; + protected $groupSyncService; protected $ldapConnection; protected $userAvatars; protected $config; @@ -24,20 +25,19 @@ class LdapService extends ExternalAuthService /** * LdapService constructor. */ - public function __construct(Ldap $ldap, UserAvatars $userAvatars) + public function __construct(Ldap $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService) { $this->ldap = $ldap; $this->userAvatars = $userAvatars; + $this->groupSyncService = $groupSyncService; $this->config = config('services.ldap'); $this->enabled = config('auth.method') === 'ldap'; } /** * Check if groups should be synced. - * - * @return bool */ - public function shouldSyncGroups() + public function shouldSyncGroups(): bool { return $this->enabled && $this->config['user_to_groups'] !== false; } @@ -285,9 +285,7 @@ class LdapService extends ExternalAuthService } $userGroups = $this->groupFilter($user); - $userGroups = $this->getGroupsRecursive($userGroups, []); - - return $userGroups; + return $this->getGroupsRecursive($userGroups, []); } /** @@ -374,7 +372,7 @@ class LdapService extends ExternalAuthService public function syncGroups(User $user, string $username) { $userLdapGroups = $this->getUserGroups($username); - $this->syncWithGroups($user, $userLdapGroups); + $this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']); } /** diff --git a/app/Auth/Access/OpenIdConnectService.php b/app/Auth/Access/OpenIdConnectService.php new file mode 100644 index 000000000..2548aee6e --- /dev/null +++ b/app/Auth/Access/OpenIdConnectService.php @@ -0,0 +1,164 @@ +config = config('oidc'); + $this->registrationService = $registrationService; + $this->loginService = $loginService; + } + + /** + * Initiate an authorization flow. + * @return array{url: string, state: string} + */ + public function login(): array + { + $provider = $this->getProvider(); + return [ + 'url' => $provider->getAuthorizationUrl(), + 'state' => $provider->getState(), + ]; + } + + /** + * Process the Authorization response from the authorization server and + * return the matching, or new if registration active, user matched to + * the authorization server. + * Returns null if not authenticated. + * @throws Exception + */ + public function processAuthorizeResponse(?string $authorizationCode): ?User + { + $provider = $this->getProvider(); + + // Try to exchange authorization code for access token + $accessToken = $provider->getAccessToken('authorization_code', [ + 'code' => $authorizationCode, + ]); + + return $this->processAccessTokenCallback($accessToken); + } + + /** + * Load the underlying OpenID Connect Provider. + */ + protected function getProvider(): OpenIDConnectProvider + { + // Setup settings + $settings = [ + 'clientId' => $this->config['client_id'], + 'clientSecret' => $this->config['client_secret'], + 'idTokenIssuer' => $this->config['issuer'], + 'redirectUri' => url('/oidc/redirect'), + 'urlAuthorize' => $this->config['authorization_endpoint'], + 'urlAccessToken' => $this->config['token_endpoint'], + 'urlResourceOwnerDetails' => null, + 'publicKey' => $this->config['jwt_public_key'], + 'scopes' => 'profile email', + ]; + + // Setup services + $services = [ + 'signer' => new Sha256(), + ]; + + return new OpenIDConnectProvider($settings, $services); + } + + /** + * Calculate the display name + */ + protected function getUserDisplayName(Token $token, string $defaultValue): string + { + $displayNameAttr = $this->config['display_name_claims']; + + $displayName = []; + foreach ($displayNameAttr as $dnAttr) { + $dnComponent = $token->claims()->get($dnAttr, ''); + if ($dnComponent !== '') { + $displayName[] = $dnComponent; + } + } + + if (count($displayName) == 0) { + $displayName[] = $defaultValue; + } + + return implode(' ', $displayName); + } + + /** + * Extract the details of a user from an ID token. + * @return array{name: string, email: string, external_id: string} + */ + protected function getUserDetails(Token $token): array + { + $id = $token->claims()->get('sub'); + return [ + 'external_id' => $id, + 'email' => $token->claims()->get('email'), + 'name' => $this->getUserDisplayName($token, $id), + ]; + } + + /** + * Processes a received access token for a user. Login the user when + * they exist, optionally registering them automatically. + * @throws OpenIdConnectException + * @throws JsonDebugException + * @throws UserRegistrationException + * @throws StoppedAuthenticationException + */ + protected function processAccessTokenCallback(AccessToken $accessToken): User + { + $userDetails = $this->getUserDetails($accessToken->getIdToken()); + $isLoggedIn = auth()->check(); + + if ($this->config['dump_user_details']) { + throw new JsonDebugException($accessToken->jsonSerialize()); + } + + if ($userDetails['email'] === null) { + throw new OpenIdConnectException(trans('errors.oidc_no_email_address')); + } + + if ($isLoggedIn) { + throw new OpenIdConnectException(trans('errors.oidc_already_logged_in'), '/login'); + } + + $user = $this->registrationService->findOrRegister( + $userDetails['name'], $userDetails['email'], $userDetails['external_id'] + ); + + if ($user === null) { + throw new OpenIdConnectException(trans('errors.oidc_user_not_registered', ['name' => $userDetails['external_id']]), '/login'); + } + + $this->loginService->login($user, 'oidc'); + return $user; + } +} diff --git a/app/Auth/Access/OpenIdService.php b/app/Auth/Access/OpenIdService.php deleted file mode 100644 index f56ff0b91..000000000 --- a/app/Auth/Access/OpenIdService.php +++ /dev/null @@ -1,257 +0,0 @@ -config = config('oidc'); - } - - /** - * Initiate an authorization flow. - * @throws Exception - */ - public function login(): array - { - $provider = $this->getProvider(); - return [ - 'url' => $provider->getAuthorizationUrl(), - 'state' => $provider->getState(), - ]; - } - - /** - * Initiate a logout flow. - */ - public function logout(): array - { - $this->actionLogout(); - $url = '/'; - $id = null; - - return ['url' => $url, 'id' => $id]; - } - - /** - * Refresh the currently logged in user. - * @throws Exception - */ - public function refresh(): bool - { - // Retrieve access token for current session - $json = session()->get('openid_token'); - - // If no access token was found, reject the refresh - if (!$json) { - $this->actionLogout(); - return false; - } - - $accessToken = new AccessToken(json_decode($json, true) ?? []); - - // If the token is not expired, refreshing isn't necessary - if ($this->isUnexpired($accessToken)) { - return true; - } - - // Try to obtain refreshed access token - try { - $newAccessToken = $this->refreshAccessToken($accessToken); - } catch (Exception $e) { - // Log out if an unknown problem arises - $this->actionLogout(); - throw $e; - } - - // If a token was obtained, update the access token, otherwise log out - if ($newAccessToken !== null) { - session()->put('openid_token', json_encode($newAccessToken)); - return true; - } else { - $this->actionLogout(); - return false; - } - } - - /** - * Check whether an access token or OpenID token isn't expired. - */ - protected function isUnexpired(AccessToken $accessToken): bool - { - $idToken = $accessToken->getIdToken(); - - $accessTokenUnexpired = $accessToken->getExpires() && !$accessToken->hasExpired(); - $idTokenUnexpired = !$idToken || !$idToken->isExpired(); - - return $accessTokenUnexpired && $idTokenUnexpired; - } - - /** - * Generate an updated access token, through the associated refresh token. - * @throws Exception - */ - protected function refreshAccessToken(AccessToken $accessToken): ?AccessToken - { - // If no refresh token available, abort - if ($accessToken->getRefreshToken() === null) { - return null; - } - - // ID token or access token is expired, we refresh it using the refresh token - try { - return $this->getProvider()->getAccessToken('refresh_token', [ - 'refresh_token' => $accessToken->getRefreshToken(), - ]); - } catch (IdentityProviderException $e) { - // Refreshing failed - return null; - } - } - - /** - * Process the Authorization response from the authorization server and - * return the matching, or new if registration active, user matched to - * the authorization server. - * Returns null if not authenticated. - * @throws Exception - * @throws InvalidTokenException - */ - public function processAuthorizeResponse(?string $authorizationCode): ?User - { - $provider = $this->getProvider(); - - // Try to exchange authorization code for access token - $accessToken = $provider->getAccessToken('authorization_code', [ - 'code' => $authorizationCode, - ]); - - return $this->processAccessTokenCallback($accessToken); - } - - /** - * Do the required actions to log a user out. - */ - protected function actionLogout() - { - auth()->logout(); - session()->invalidate(); - } - - /** - * Load the underlying OpenID Connect Provider. - */ - protected function getProvider(): OpenIDConnectProvider - { - // Setup settings - $settings = [ - 'clientId' => $this->config['client_id'], - 'clientSecret' => $this->config['client_secret'], - 'idTokenIssuer' => $this->config['issuer'], - 'redirectUri' => url('/openid/redirect'), - 'urlAuthorize' => $this->config['authorization_endpoint'], - 'urlAccessToken' => $this->config['token_endpoint'], - 'urlResourceOwnerDetails' => null, - 'publicKey' => $this->config['jwt_public_key'], - 'scopes' => 'profile email', - ]; - - // Setup services - $services = [ - 'signer' => new Sha256(), - ]; - - return new OpenIDConnectProvider($settings, $services); - } - - /** - * Calculate the display name - */ - protected function getUserDisplayName(Token $token, string $defaultValue): string - { - $displayNameAttr = $this->config['display_name_claims']; - - $displayName = []; - foreach ($displayNameAttr as $dnAttr) { - $dnComponent = $token->claims()->get($dnAttr, ''); - if ($dnComponent !== '') { - $displayName[] = $dnComponent; - } - } - - if (count($displayName) == 0) { - $displayName[] = $defaultValue; - } - - return implode(' ', $displayName);; - } - - /** - * Extract the details of a user from an ID token. - */ - protected function getUserDetails(Token $token): array - { - $id = $token->claims()->get('sub'); - return [ - 'external_id' => $id, - 'email' => $token->claims()->get('email'), - 'name' => $this->getUserDisplayName($token, $id), - ]; - } - - /** - * Processes a received access token for a user. Login the user when - * they exist, optionally registering them automatically. - * @throws OpenIdException - * @throws JsonDebugException - * @throws UserRegistrationException - */ - public function processAccessTokenCallback(AccessToken $accessToken): User - { - $userDetails = $this->getUserDetails($accessToken->getIdToken()); - $isLoggedIn = auth()->check(); - - if ($this->config['dump_user_details']) { - throw new JsonDebugException($accessToken->jsonSerialize()); - } - - if ($userDetails['email'] === null) { - throw new OpenIdException(trans('errors.openid_no_email_address')); - } - - if ($isLoggedIn) { - throw new OpenIdException(trans('errors.openid_already_logged_in'), '/login'); - } - - $user = $this->getOrRegisterUser($userDetails); - if ($user === null) { - throw new OpenIdException(trans('errors.openid_user_not_registered', ['name' => $userDetails['external_id']]), '/login'); - } - - auth()->login($user); - session()->put('openid_token', json_encode($accessToken)); - return $user; - } -} diff --git a/app/Auth/Access/RegistrationService.php b/app/Auth/Access/RegistrationService.php index 16e3edbb4..48970bd2e 100644 --- a/app/Auth/Access/RegistrationService.php +++ b/app/Auth/Access/RegistrationService.php @@ -11,6 +11,7 @@ use BookStack\Facades\Activity; use BookStack\Facades\Theme; use BookStack\Theming\ThemeEvents; use Exception; +use Illuminate\Support\Str; class RegistrationService { @@ -50,6 +51,31 @@ class RegistrationService return in_array($authMethod, $authMethodsWithRegistration) && setting('registration-enabled'); } + /** + * Attempt to find a user in the system otherwise register them as a new + * user. For use with external auth systems since password is auto-generated. + * @throws UserRegistrationException + */ + public function findOrRegister(string $name, string $email, string $externalId): User + { + $user = User::query() + ->where('external_auth_id', '=', $externalId) + ->first(); + + if (is_null($user)) { + $userData = [ + 'name' => $name, + 'email' => $email, + 'password' => Str::random(32), + 'external_auth_id' => $externalId, + ]; + + $user = $this->registerUser($userData, null, false); + } + + return $user; + } + /** * The registrations flow for all users. * diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php index b1489fbce..8e076f86c 100644 --- a/app/Auth/Access/Saml2Service.php +++ b/app/Auth/Access/Saml2Service.php @@ -17,22 +17,26 @@ use OneLogin\Saml2\ValidationError; * Class Saml2Service * Handles any app-specific SAML tasks. */ -class Saml2Service extends ExternalAuthService +class Saml2Service { protected $config; protected $registrationService; protected $loginService; + protected $groupSyncService; /** * Saml2Service constructor. */ - public function __construct(RegistrationService $registrationService, LoginService $loginService, User $user) + public function __construct( + RegistrationService $registrationService, + LoginService $loginService, + GroupSyncService $groupSyncService + ) { - parent::__construct($registrationService, $user); - $this->config = config('saml2'); $this->registrationService = $registrationService; $this->loginService = $loginService; + $this->groupSyncService = $groupSyncService; } /** @@ -47,7 +51,7 @@ class Saml2Service extends ExternalAuthService return [ 'url' => $toolKit->login($returnRoute, [], false, false, true), - 'id' => $toolKit->getLastRequestID(), + 'id' => $toolKit->getLastRequestID(), ]; } @@ -196,7 +200,7 @@ class Saml2Service extends ExternalAuthService protected function loadOneloginServiceProviderDetails(): array { $spDetails = [ - 'entityId' => url('/saml2/metadata'), + 'entityId' => url('/saml2/metadata'), 'assertionConsumerService' => [ 'url' => url('/saml2/acs'), ], @@ -207,7 +211,7 @@ class Saml2Service extends ExternalAuthService return [ 'baseurl' => url('/saml2'), - 'sp' => $spDetails, + 'sp' => $spDetails, ]; } @@ -259,6 +263,7 @@ class Saml2Service extends ExternalAuthService /** * Extract the details of a user from a SAML response. + * @return array{external_id: string, name: string, email: string, saml_id: string} */ protected function getUserDetails(string $samlID, $samlAttributes): array { @@ -270,9 +275,9 @@ class Saml2Service extends ExternalAuthService return [ 'external_id' => $externalId, - 'name' => $this->getUserDisplayName($samlAttributes, $externalId), - 'email' => $email, - 'saml_id' => $samlID, + 'name' => $this->getUserDisplayName($samlAttributes, $externalId), + 'email' => $email, + 'saml_id' => $samlID, ]; } @@ -339,8 +344,8 @@ class Saml2Service extends ExternalAuthService if ($this->config['dump_user_details']) { throw new JsonDebugException([ - 'id_from_idp' => $samlID, - 'attrs_from_idp' => $samlAttributes, + 'id_from_idp' => $samlID, + 'attrs_from_idp' => $samlAttributes, 'attrs_after_parsing' => $userDetails, ]); } @@ -353,14 +358,17 @@ class Saml2Service extends ExternalAuthService throw new SamlException(trans('errors.saml_already_logged_in'), '/login'); } - $user = $this->getOrRegisterUser($userDetails); + $user = $this->registrationService->findOrRegister( + $userDetails['name'], $userDetails['email'], $userDetails['external_id'] + ); + if ($user === null) { throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['external_id']]), '/login'); } if ($this->shouldSyncGroups()) { $groups = $this->getUserGroups($samlAttributes); - $this->syncWithGroups($user, $groups); + $this->groupSyncService->syncUserWithFoundGroups($user, $groups, $this->config['remove_from_groups']); } $this->loginService->login($user, 'saml2'); diff --git a/app/Config/auth.php b/app/Config/auth.php index 5b39bafed..5a5e727e5 100644 --- a/app/Config/auth.php +++ b/app/Config/auth.php @@ -11,7 +11,7 @@ return [ // Method of authentication to use - // Options: standard, ldap, saml2 + // Options: standard, ldap, saml2, oidc 'method' => env('AUTH_METHOD', 'standard'), // Authentication Defaults @@ -26,7 +26,7 @@ return [ // All authentication drivers have a user provider. This defines how the // users are actually retrieved out of your database or other storage // mechanisms used by this application to persist your user's data. - // Supported drivers: "session", "api-token", "ldap-session" + // Supported drivers: "session", "api-token", "ldap-session", "async-external-session" 'guards' => [ 'standard' => [ 'driver' => 'session', @@ -37,11 +37,11 @@ return [ 'provider' => 'external', ], 'saml2' => [ - 'driver' => 'saml2-session', + 'driver' => 'async-external-session', 'provider' => 'external', ], - 'openid' => [ - 'driver' => 'openid-session', + 'oidc' => [ + 'driver' => 'async-external-session', 'provider' => 'external', ], 'api' => [ diff --git a/app/Exceptions/OpenIdConnectException.php b/app/Exceptions/OpenIdConnectException.php new file mode 100644 index 000000000..d58585732 --- /dev/null +++ b/app/Exceptions/OpenIdConnectException.php @@ -0,0 +1,6 @@ +oidcService = $oidcService; + $this->middleware('guard:oidc'); + } + + /** + * Start the authorization login flow via OIDC. + */ + public function login() + { + $loginDetails = $this->oidcService->login(); + session()->flash('oidc_state', $loginDetails['state']); + + return redirect($loginDetails['url']); + } + + /** + * Authorization flow redirect. + * Processes authorization response from the OIDC Authorization Server. + */ + public function redirect(Request $request) + { + $storedState = session()->pull('oidc_state'); + $responseState = $request->query('state'); + + if ($storedState !== $responseState) { + $this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')])); + return redirect('/login'); + } + + $user = $this->oidcService->processAuthorizeResponse($request->query('code')); + if ($user === null) { + $this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')])); + return redirect('/login'); + } + + return redirect()->intended(); + } +} diff --git a/app/Http/Controllers/Auth/OpenIdController.php b/app/Http/Controllers/Auth/OpenIdController.php deleted file mode 100644 index 8e475ffdb..000000000 --- a/app/Http/Controllers/Auth/OpenIdController.php +++ /dev/null @@ -1,70 +0,0 @@ -openidService = $openidService; - $this->middleware('guard:openid'); - } - - /** - * Start the authorization login flow via OpenId Connect. - */ - public function login() - { - $loginDetails = $this->openidService->login(); - session()->flash('openid_state', $loginDetails['state']); - - return redirect($loginDetails['url']); - } - - /** - * Start the logout flow via OpenId Connect. - */ - public function logout() - { - $logoutDetails = $this->openidService->logout(); - - if ($logoutDetails['id']) { - session()->flash('saml2_logout_request_id', $logoutDetails['id']); - } - - return redirect($logoutDetails['url']); - } - - /** - * Authorization flow Redirect. - * Processes authorization response from the OpenId Connect Authorization Server. - */ - public function redirect() - { - $storedState = session()->pull('openid_state'); - $responseState = request()->query('state'); - - if ($storedState !== $responseState) { - $this->showErrorNotification(trans('errors.openid_fail_authed', ['system' => config('saml2.name')])); - return redirect('/login'); - } - - $user = $this->openidService->processAuthorizeResponse(request()->query('code')); - if ($user === null) { - $this->showErrorNotification(trans('errors.openid_fail_authed', ['system' => config('saml2.name')])); - return redirect('/login'); - } - - return redirect()->intended(); - } -} diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index a2e7f1dc1..804a22bc0 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -20,6 +20,5 @@ class VerifyCsrfToken extends Middleware */ protected $except = [ 'saml2/*', - 'openid/*', ]; } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index cd90cc849..bc7caa195 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -5,11 +5,9 @@ namespace BookStack\Providers; use BookStack\Api\ApiTokenGuard; use BookStack\Auth\Access\ExternalBaseUserProvider; use BookStack\Auth\Access\Guards\LdapSessionGuard; -use BookStack\Auth\Access\Guards\Saml2SessionGuard; -use BookStack\Auth\Access\Guards\OpenIdSessionGuard; +use BookStack\Auth\Access\Guards\AsyncExternalBaseSessionGuard; use BookStack\Auth\Access\LdapService; use BookStack\Auth\Access\LoginService; -use BookStack\Auth\Access\OpenIdService; use BookStack\Auth\Access\RegistrationService; use Illuminate\Support\Facades\Auth; use Illuminate\Support\ServiceProvider; @@ -39,27 +37,16 @@ class AuthServiceProvider extends ServiceProvider ); }); - Auth::extend('saml2-session', function ($app, $name, array $config) { + Auth::extend('async-external-session', function ($app, $name, array $config) { $provider = Auth::createUserProvider($config['provider']); - return new Saml2SessionGuard( + return new AsyncExternalBaseSessionGuard( $name, $provider, $app['session.store'], $app[RegistrationService::class] ); }); - - Auth::extend('openid-session', function ($app, $name, array $config) { - $provider = Auth::createUserProvider($config['provider']); - return new OpenIdSessionGuard( - $name, - $provider, - $this->app['session.store'], - $app[OpenIdService::class], - $app[RegistrationService::class] - ); - }); } /** diff --git a/resources/icons/oidc.svg b/resources/icons/oidc.svg new file mode 100644 index 000000000..a9a2994a7 --- /dev/null +++ b/resources/icons/oidc.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index 44f0c25a0..f023b6bdf 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -23,10 +23,10 @@ return [ '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', - 'openid_already_logged_in' => 'Already logged in', - 'openid_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', - 'openid_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', - 'openid_fail_authed' => 'Login using :system failed, system did not provide successful authorization', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', '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/parts/login-form-oidc.blade.php b/resources/views/auth/parts/login-form-oidc.blade.php new file mode 100644 index 000000000..e5e1b70fc --- /dev/null +++ b/resources/views/auth/parts/login-form-oidc.blade.php @@ -0,0 +1,11 @@ +
    + {!! csrf_field() !!} + +
    + +
    + +
    diff --git a/resources/views/auth/parts/login-form-openid.blade.php b/resources/views/auth/parts/login-form-openid.blade.php deleted file mode 100644 index ba975ebf4..000000000 --- a/resources/views/auth/parts/login-form-openid.blade.php +++ /dev/null @@ -1,11 +0,0 @@ -
    - {!! csrf_field() !!} - -
    - -
    - -
    diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index cac585a65..2311ce3e0 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -73,8 +73,6 @@
  • @if(config('auth.method') === 'saml2') @icon('logout'){{ trans('auth.logout') }} - @elseif(config('auth.method') === 'openid') - @icon('logout'){{ trans('auth.logout') }} @else @icon('logout'){{ trans('auth.logout') }} @endif diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php index 8d63244e1..5fe5f3685 100644 --- a/resources/views/settings/index.blade.php +++ b/resources/views/settings/index.blade.php @@ -221,7 +221,7 @@ 'label' => trans('settings.reg_enable_toggle') ]) - @if(in_array(config('auth.method'), ['ldap', 'saml2', 'openid'])) + @if(in_array(config('auth.method'), ['ldap', 'saml2', 'oidc']))
    {{ trans('settings.reg_enable_external_warning') }}
    @endif diff --git a/resources/views/settings/roles/parts/form.blade.php b/resources/views/settings/roles/parts/form.blade.php index 3f7f8fd1f..9cea9e1fb 100644 --- a/resources/views/settings/roles/parts/form.blade.php +++ b/resources/views/settings/roles/parts/form.blade.php @@ -22,7 +22,7 @@ @include('form.checkbox', ['name' => 'mfa_enforced', 'label' => trans('settings.role_mfa_enforced') ]) - @if(in_array(config('auth.method'), ['ldap', 'saml2', 'openid'])) + @if(in_array(config('auth.method'), ['ldap', 'saml2', 'oidc']))
    @include('form.text', ['name' => 'external_auth_id']) diff --git a/resources/views/users/parts/form.blade.php b/resources/views/users/parts/form.blade.php index ef8c611ef..2a5002c3b 100644 --- a/resources/views/users/parts/form.blade.php +++ b/resources/views/users/parts/form.blade.php @@ -25,7 +25,7 @@
    -@if(in_array($authMethod, ['ldap', 'saml2', 'openid']) && userCan('users-manage')) +@if(in_array($authMethod, ['ldap', 'saml2', 'oidc']) && userCan('users-manage'))
    diff --git a/routes/web.php b/routes/web.php index fb4282539..72e0392cc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -267,10 +267,9 @@ Route::get('/saml2/metadata', 'Auth\Saml2Controller@metadata'); Route::get('/saml2/sls', 'Auth\Saml2Controller@sls'); Route::post('/saml2/acs', 'Auth\Saml2Controller@acs'); -// OpenId routes -Route::post('/openid/login', 'Auth\OpenIdController@login'); -Route::get('/openid/logout', 'Auth\OpenIdController@logout'); -Route::get('/openid/redirect', 'Auth\OpenIdController@redirect'); +// OIDC routes +Route::post('/oidc/login', 'Auth\OpenIdConnectController@login'); +Route::get('/oidc/redirect', 'Auth\OpenIdConnectController@redirect'); // User invitation routes Route::get('/register/invite/{token}', 'Auth\UserInviteController@showSetPassword'); From 8ce696dff62afc9a366f95231fe03a25078b4ce1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 10 Oct 2021 19:14:08 +0100 Subject: [PATCH 16/24] Started on a custom oidc oauth provider --- .../Access/OpenIdConnectOAuthProvider.php | 109 ++++++++++++++++++ app/Auth/Access/OpenIdConnectService.php | 31 +++-- 2 files changed, 124 insertions(+), 16 deletions(-) create mode 100644 app/Auth/Access/OpenIdConnectOAuthProvider.php diff --git a/app/Auth/Access/OpenIdConnectOAuthProvider.php b/app/Auth/Access/OpenIdConnectOAuthProvider.php new file mode 100644 index 000000000..60ae2aa09 --- /dev/null +++ b/app/Auth/Access/OpenIdConnectOAuthProvider.php @@ -0,0 +1,109 @@ +authorizationEndpoint; + } + + /** + * Returns the base URL for requesting an access token. + */ + public function getBaseAccessTokenUrl(array $params): string + { + return $this->tokenEndpoint; + } + + /** + * Returns the URL for requesting the resource owner's details. + */ + public function getResourceOwnerDetailsUrl(AccessToken $token): string + { + return ''; + } + + /** + * Returns the default scopes used by this provider. + * + * This should only be the scopes that are required to request the details + * of the resource owner, rather than all the available scopes. + */ + protected function getDefaultScopes(): array + { + return ['openid', 'profile', 'email']; + } + + + /** + * Returns the string that should be used to separate scopes when building + * the URL for requesting an access token. + */ + protected function getScopeSeparator(): string + { + return ' '; + } + + /** + * Checks a provider response for errors. + * + * @param ResponseInterface $response + * @param array|string $data Parsed response data + * @return void + * @throws IdentityProviderException + */ + protected function checkResponse(ResponseInterface $response, $data) + { + if ($response->getStatusCode() >= 400 || isset($data['error'])) { + throw new IdentityProviderException( + $data['error'] ?? $response->getReasonPhrase(), + $response->getStatusCode(), + (string) $response->getBody() + ); + } + } + + /** + * Generates a resource owner object from a successful resource owner + * details request. + * + * @param array $response + * @param AccessToken $token + * @return ResourceOwnerInterface + */ + protected function createResourceOwner(array $response, AccessToken $token) + { + return new GenericResourceOwner($response, ''); + } +} \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnectService.php b/app/Auth/Access/OpenIdConnectService.php index 2548aee6e..01050b5e5 100644 --- a/app/Auth/Access/OpenIdConnectService.php +++ b/app/Auth/Access/OpenIdConnectService.php @@ -6,10 +6,8 @@ use BookStack\Exceptions\OpenIdConnectException; use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\UserRegistrationException; use Exception; -use Lcobucci\JWT\Signer\Rsa\Sha256; use Lcobucci\JWT\Token; -use OpenIDConnectClient\AccessToken; -use OpenIDConnectClient\OpenIDConnectProvider; +use League\OAuth2\Client\Token\AccessToken; /** * Class OpenIdConnectService @@ -66,27 +64,18 @@ class OpenIdConnectService /** * Load the underlying OpenID Connect Provider. */ - protected function getProvider(): OpenIDConnectProvider + protected function getProvider(): OpenIdConnectOAuthProvider { // Setup settings $settings = [ 'clientId' => $this->config['client_id'], 'clientSecret' => $this->config['client_secret'], - 'idTokenIssuer' => $this->config['issuer'], 'redirectUri' => url('/oidc/redirect'), - 'urlAuthorize' => $this->config['authorization_endpoint'], - 'urlAccessToken' => $this->config['token_endpoint'], - 'urlResourceOwnerDetails' => null, - 'publicKey' => $this->config['jwt_public_key'], - 'scopes' => 'profile email', + 'authorizationEndpoint' => $this->config['authorization_endpoint'], + 'tokenEndpoint' => $this->config['token_endpoint'], ]; - // Setup services - $services = [ - 'signer' => new Sha256(), - ]; - - return new OpenIDConnectProvider($settings, $services); + return new OpenIdConnectOAuthProvider($settings); } /** @@ -135,6 +124,16 @@ class OpenIdConnectService */ protected function processAccessTokenCallback(AccessToken $accessToken): User { + dd($accessToken->getValues()); + // TODO - Create a class to manage token parsing and validation on this + // Using the config params: + // $this->config['jwt_public_key'] + // $this->config['issuer'] + // + // Ensure ID token validation is done: + // https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation + // To full affect and tested + $userDetails = $this->getUserDetails($accessToken->getIdToken()); $isLoggedIn = auth()->check(); From 8c01c55684e64dbcafd71b6bb9af12ef77b0e39f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 11 Oct 2021 19:05:16 +0100 Subject: [PATCH 17/24] Added token and key handling elements for oidc jwt - Got basic signing support and structure checking done. - Need to run through actual claim checking before providing details back to app. --- app/Auth/Access/LoginService.php | 2 +- .../OpenIdConnect/InvalidKeyException.php | 8 + .../OpenIdConnect/InvalidTokenException.php | 10 ++ .../Access/OpenIdConnect/JwtSigningKey.php | 76 ++++++++++ .../OpenIdConnectAccessToken.php | 54 +++++++ .../OpenIdConnect/OpenIdConnectIdToken.php | 143 ++++++++++++++++++ .../OpenIdConnectOAuthProvider.php | 20 ++- .../OpenIdConnectService.php | 26 +++- .../Auth/OpenIdConnectController.php | 2 +- composer.json | 1 + composer.lock | 113 +++++++++++++- 11 files changed, 444 insertions(+), 11 deletions(-) create mode 100644 app/Auth/Access/OpenIdConnect/InvalidKeyException.php create mode 100644 app/Auth/Access/OpenIdConnect/InvalidTokenException.php create mode 100644 app/Auth/Access/OpenIdConnect/JwtSigningKey.php create mode 100644 app/Auth/Access/OpenIdConnect/OpenIdConnectAccessToken.php create mode 100644 app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php rename app/Auth/Access/{ => OpenIdConnect}/OpenIdConnectOAuthProvider.php (84%) rename app/Auth/Access/{ => OpenIdConnect}/OpenIdConnectService.php (87%) diff --git a/app/Auth/Access/LoginService.php b/app/Auth/Access/LoginService.php index b36adb522..f41570417 100644 --- a/app/Auth/Access/LoginService.php +++ b/app/Auth/Access/LoginService.php @@ -47,7 +47,7 @@ class LoginService // Authenticate on all session guards if a likely admin if ($user->can('users-manage') && $user->can('user-roles-manage')) { - $guards = ['standard', 'ldap', 'saml2', 'openid']; + $guards = ['standard', 'ldap', 'saml2', 'oidc']; foreach ($guards as $guard) { auth($guard)->login($user); } diff --git a/app/Auth/Access/OpenIdConnect/InvalidKeyException.php b/app/Auth/Access/OpenIdConnect/InvalidKeyException.php new file mode 100644 index 000000000..85746cb6a --- /dev/null +++ b/app/Auth/Access/OpenIdConnect/InvalidKeyException.php @@ -0,0 +1,8 @@ + 'RSA', 'alg' => 'RS256', 'n' => 'abc123...'] + * @param array|string $jwkOrKeyPath + * @throws InvalidKeyException + */ + public function __construct($jwkOrKeyPath) + { + if (is_array($jwkOrKeyPath)) { + $this->loadFromJwkArray($jwkOrKeyPath); + } + } + + /** + * @throws InvalidKeyException + */ + protected function loadFromJwkArray(array $jwk) + { + if ($jwk['alg'] !== 'RS256') { + throw new InvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}"); + } + + if ($jwk['use'] !== 'sig') { + throw new InvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['sig']}"); + } + + if (empty($jwk['e'] ?? '')) { + throw new InvalidKeyException('An "e" parameter on the provided key is expected'); + } + + if (empty($jwk['n'] ?? '')) { + throw new InvalidKeyException('A "n" parameter on the provided key is expected'); + } + + $n = strtr($jwk['n'] ?? '', '-_', '+/'); + + try { + /** @var RSA $key */ + $key = PublicKeyLoader::load([ + 'e' => new BigInteger(base64_decode($jwk['e']), 256), + 'n' => new BigInteger(base64_decode($n), 256), + ])->withPadding(RSA::SIGNATURE_PKCS1); + + $this->key = $key; + } catch (\Exception $exception) { + throw new InvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}"); + } + } + + /** + * Use this key to sign the given content and return the signature. + */ + public function verify(string $content, string $signature): bool + { + return $this->key->verify($content, $signature); + } + +} \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectAccessToken.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectAccessToken.php new file mode 100644 index 000000000..6731ec4be --- /dev/null +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectAccessToken.php @@ -0,0 +1,54 @@ +validate($options); + } + + + /** + * Validate this access token response for OIDC. + * As per https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK. + */ + private function validate(array $options): void + { + // access_token: REQUIRED. Access Token for the UserInfo Endpoint. + // Performed on the extended class + + // token_type: REQUIRED. OAuth 2.0 Token Type value. The value MUST be Bearer, as specified in OAuth 2.0 + // Bearer Token Usage [RFC6750], for Clients using this subset. + // Note that the token_type value is case-insensitive. + if (strtolower(($options['token_type'] ?? '')) !== 'bearer') { + throw new InvalidArgumentException('The response token type MUST be "Bearer"'); + } + + // id_token: REQUIRED. ID Token. + if (empty($options['id_token'])) { + throw new InvalidArgumentException('An "id_token" property must be provided'); + } + } + + /** + * Get the id token value from this access token response. + */ + public function getIdToken(): string + { + return $this->getValues()['id_token']; + } + +} \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php new file mode 100644 index 000000000..09527c3ed --- /dev/null +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php @@ -0,0 +1,143 @@ +keys = $keys; + $this->issuer = $issuer; + $this->parse($token); + } + + /** + * Parse the token content into its components. + */ + protected function parse(string $token): void + { + $this->tokenParts = explode('.', $token); + $this->header = $this->parseEncodedTokenPart($this->tokenParts[0]); + $this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? ''); + $this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: ''; + } + + /** + * Parse a Base64-JSON encoded token part. + * Returns the data as a key-value array or empty array upon error. + */ + protected function parseEncodedTokenPart(string $part): array + { + $json = $this->base64UrlDecode($part) ?: '{}'; + $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : []; + } + + /** + * Base64URL decode. Needs some character conversions to be compatible + * with PHP's default base64 handling. + */ + protected function base64UrlDecode(string $encoded): string + { + return base64_decode(strtr($encoded, '-_', '+/')); + } + + /** + * Validate all possible parts of the id token. + * @throws InvalidTokenException + */ + public function validate() + { + $this->validateTokenStructure(); + $this->validateTokenSignature(); + $this->validateTokenClaims(); + } + + /** + * Validate the structure of the given token and ensure we have the required pieces. + * As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2 + * @throws InvalidTokenException + */ + protected function validateTokenStructure(): void + { + foreach (['header', 'payload'] as $prop) { + if (empty($this->$prop) || !is_array($this->$prop)) { + throw new InvalidTokenException("Could not parse out a valid {$prop} within the provided token"); + } + } + + if (empty($this->signature) || !is_string($this->signature)) { + throw new InvalidTokenException("Could not parse out a valid signature within the provided token"); + } + } + + /** + * Validate the signature of the given token and ensure it validates against the provided key. + * @throws InvalidTokenException + */ + protected function validateTokenSignature(): void + { + if ($this->header['alg'] !== 'RS256') { + throw new InvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}"); + } + + $parsedKeys = array_map(function($key) { + try { + return new JwtSigningKey($key); + } catch (InvalidKeyException $e) { + return null; + } + }, $this->keys); + + $parsedKeys = array_filter($parsedKeys); + + $contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1]; + foreach ($parsedKeys as $parsedKey) { + if ($parsedKey->verify($contentToSign, $this->signature)) { + return; + } + } + + throw new InvalidTokenException('Token signature could not be validated using the provided keys.'); + } + + /** + * Validate the claims of the token. + * As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation + */ + protected function validateTokenClaims(): void + { + // TODO + } + +} \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnectOAuthProvider.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectOAuthProvider.php similarity index 84% rename from app/Auth/Access/OpenIdConnectOAuthProvider.php rename to app/Auth/Access/OpenIdConnect/OpenIdConnectOAuthProvider.php index 60ae2aa09..074f463cc 100644 --- a/app/Auth/Access/OpenIdConnectOAuthProvider.php +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectOAuthProvider.php @@ -1,7 +1,8 @@ getValues()); + $idTokenText = $accessToken->getIdToken(); + $idToken = new OpenIdConnectIdToken( + $idTokenText, + $this->config['issuer'], + [$this->config['jwt_public_key']] + ); + // TODO - Create a class to manage token parsing and validation on this - // Using the config params: - // $this->config['jwt_public_key'] - // $this->config['issuer'] - // // Ensure ID token validation is done: // https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation // To full affect and tested + // JWT signature algorthims: + // https://datatracker.ietf.org/doc/html/rfc7518#section-3 $userDetails = $this->getUserDetails($accessToken->getIdToken()); $isLoggedIn = auth()->check(); diff --git a/app/Http/Controllers/Auth/OpenIdConnectController.php b/app/Http/Controllers/Auth/OpenIdConnectController.php index 23cfbbcbe..8156773b4 100644 --- a/app/Http/Controllers/Auth/OpenIdConnectController.php +++ b/app/Http/Controllers/Auth/OpenIdConnectController.php @@ -2,7 +2,7 @@ namespace BookStack\Http\Controllers\Auth; -use BookStack\Auth\Access\OpenIdConnectService; +use BookStack\Auth\Access\OpenIdConnect\OpenIdConnectService; use BookStack\Http\Controllers\Controller; use Illuminate\Http\Request; diff --git a/composer.json b/composer.json index 288f55991..e53d9d25a 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "league/html-to-markdown": "^5.0.0", "nunomaduro/collision": "^3.1", "onelogin/php-saml": "^4.0", + "phpseclib/phpseclib": "~3.0", "pragmarx/google2fa": "^8.0", "predis/predis": "^1.1.6", "socialiteproviders/discord": "^4.1", diff --git a/composer.lock b/composer.lock index 9355deed3..62b2aa621 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "620412108a5d19ed91d9fe42418b63b5", + "content-hash": "5cbbf417bd19cd2164f91b9b2d38600c", "packages": [ { "name": "aws/aws-crt-php", @@ -3511,6 +3511,117 @@ ], "time": "2021-08-28T21:27:29+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.10", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "62fcc5a94ac83b1506f52d7558d828617fac9187" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/62fcc5a94ac83b1506f52d7558d828617fac9187", + "reference": "62fcc5a94ac83b1506f52d7558d828617fac9187", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phing/phing": "~2.7", + "phpunit/phpunit": "^5.7|^6.0|^9.4", + "squizlabs/php_codesniffer": "~2.0" + }, + "suggest": { + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.10" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2021-08-16T04:24:45+00:00" + }, { "name": "pragmarx/google2fa", "version": "8.0.0", From 6b182a435a072666be3277a4f5da905f386217c1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 11 Oct 2021 23:00:45 +0100 Subject: [PATCH 18/24] Got OIDC custom solution to a functional state - Validation of all key/token elements now in place. - Signing key system updated to work with jwk-style array or with file:// path to pem key. --- .../Access/OpenIdConnect/JwtSigningKey.php | 34 ++- .../OpenIdConnect/OpenIdConnectIdToken.php | 98 ++++++++- .../OpenIdConnect/OpenIdConnectService.php | 35 ++-- app/Http/Controllers/Auth/LoginController.php | 3 +- composer.json | 3 +- composer.lock | 196 +----------------- 6 files changed, 143 insertions(+), 226 deletions(-) diff --git a/app/Auth/Access/OpenIdConnect/JwtSigningKey.php b/app/Auth/Access/OpenIdConnect/JwtSigningKey.php index c835c04c3..1687069a9 100644 --- a/app/Auth/Access/OpenIdConnect/JwtSigningKey.php +++ b/app/Auth/Access/OpenIdConnect/JwtSigningKey.php @@ -26,6 +26,28 @@ class JwtSigningKey { if (is_array($jwkOrKeyPath)) { $this->loadFromJwkArray($jwkOrKeyPath); + } else if (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) { + $this->loadFromPath($jwkOrKeyPath); + } else { + throw new InvalidKeyException('Unexpected type of key value provided'); + } + } + + /** + * @throws InvalidKeyException + */ + protected function loadFromPath(string $path) + { + try { + $this->key = PublicKeyLoader::load( + file_get_contents($path) + )->withPadding(RSA::SIGNATURE_PKCS1); + } catch (\Exception $exception) { + throw new InvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}"); + } + + if (!($this->key instanceof RSA)) { + throw new InvalidKeyException("Key loaded from file path is not an RSA key as expected"); } } @@ -54,12 +76,10 @@ class JwtSigningKey try { /** @var RSA $key */ - $key = PublicKeyLoader::load([ + $this->key = PublicKeyLoader::load([ 'e' => new BigInteger(base64_decode($jwk['e']), 256), 'n' => new BigInteger(base64_decode($n), 256), ])->withPadding(RSA::SIGNATURE_PKCS1); - - $this->key = $key; } catch (\Exception $exception) { throw new InvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}"); } @@ -73,4 +93,12 @@ class JwtSigningKey return $this->key->verify($content, $signature); } + /** + * Convert the key to a PEM encoded key string. + */ + public function toPem(): string + { + return $this->key->toString('PKCS8'); + } + } \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php index 09527c3ed..b5cef1772 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php @@ -76,11 +76,29 @@ class OpenIdConnectIdToken * Validate all possible parts of the id token. * @throws InvalidTokenException */ - public function validate() + public function validate(string $clientId) { $this->validateTokenStructure(); $this->validateTokenSignature(); - $this->validateTokenClaims(); + $this->validateTokenClaims($clientId); + } + + /** + * Fetch a specific claim from this token. + * Returns null if it is null or does not exist. + * @return mixed|null + */ + public function getClaim(string $claim) + { + return $this->payload[$claim] ?? null; + } + + /** + * Get all returned claims within the token. + */ + public function claims(): array + { + return $this->payload; } /** @@ -122,22 +140,92 @@ class OpenIdConnectIdToken $parsedKeys = array_filter($parsedKeys); $contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1]; + /** @var JwtSigningKey $parsedKey */ foreach ($parsedKeys as $parsedKey) { if ($parsedKey->verify($contentToSign, $this->signature)) { return; } } - throw new InvalidTokenException('Token signature could not be validated using the provided keys.'); + throw new InvalidTokenException('Token signature could not be validated using the provided keys'); } /** * Validate the claims of the token. * As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation + * @throws InvalidTokenException */ - protected function validateTokenClaims(): void + protected function validateTokenClaims(string $clientId): void { - // TODO + // 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) + // MUST exactly match the value of the iss (issuer) Claim. + if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) { + throw new InvalidTokenException('Missing or non-matching token issuer value'); + } + + // 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered + // at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected + // if the ID Token does not list the Client as a valid audience, or if it contains additional + // audiences not trusted by the Client. + if (empty($this->payload['aud'])) { + throw new InvalidTokenException('Missing token audience value'); + } + + $aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud']; + if (count($aud) !== 1) { + throw new InvalidTokenException('Token audience value has ' . count($aud) . ' values. Expected 1.'); + } + + if ($aud[0] !== $clientId) { + throw new InvalidTokenException('Token audience value did not match the expected client_id'); + } + + // 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present. + // NOTE: Addressed by enforcing a count of 1 above. + + // 4. If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id + // is the Claim Value. + if (isset($this->payload['azp']) && $this->payload['azp'] !== $clientId) { + throw new InvalidTokenException('Token authorized party exists but does not match the expected client_id'); + } + + // 5. The current time MUST be before the time represented by the exp Claim + // (possibly allowing for some small leeway to account for clock skew). + if (empty($this->payload['exp'])) { + throw new InvalidTokenException('Missing token expiration time value'); + } + + $skewSeconds = 120; + $now = time(); + if ($now >= (intval($this->payload['exp']) + $skewSeconds)) { + throw new InvalidTokenException('Token has expired'); + } + + // 6. The iat Claim can be used to reject tokens that were issued too far away from the current time, + // limiting the amount of time that nonces need to be stored to prevent attacks. + // The acceptable range is Client specific. + if (empty($this->payload['iat'])) { + throw new InvalidTokenException('Missing token issued at time value'); + } + + $dayAgo = time() - 86400; + $iat = intval($this->payload['iat']); + if ($iat > ($now + $skewSeconds) || $iat < $dayAgo) { + throw new InvalidTokenException('Token issue at time is not recent or is invalid'); + } + + // 7. If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate. + // The meaning and processing of acr Claim Values is out of scope for this document. + // NOTE: Not used for our case here. acr is not requested. + + // 8. When a max_age request is made, the Client SHOULD check the auth_time Claim value and request + // re-authentication if it determines too much time has elapsed since the last End-User authentication. + // NOTE: Not used for our case here. A max_age request is not made. + + // Custom: Ensure the "sub" (Subject) Claim exists and has a value. + if (empty($this->payload['sub'])) { + throw new InvalidTokenException('Missing token subject value'); + } } } \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php index 40369dee7..0f9fed006 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php @@ -1,7 +1,6 @@ config['display_name_claims']; $displayName = []; foreach ($displayNameAttr as $dnAttr) { - $dnComponent = $token->claims()->get($dnAttr, ''); + $dnComponent = $token->getClaim($dnAttr) ?? ''; if ($dnComponent !== '') { $displayName[] = $dnComponent; } @@ -112,12 +108,12 @@ class OpenIdConnectService * Extract the details of a user from an ID token. * @return array{name: string, email: string, external_id: string} */ - protected function getUserDetails(Token $token): array + protected function getUserDetails(OpenIdConnectIdToken $token): array { - $id = $token->claims()->get('sub'); + $id = $token->getClaim('sub'); return [ 'external_id' => $id, - 'email' => $token->claims()->get('email'), + 'email' => $token->getClaim('email'), 'name' => $this->getUserDisplayName($token, $id), ]; } @@ -139,20 +135,19 @@ class OpenIdConnectService [$this->config['jwt_public_key']] ); - // TODO - Create a class to manage token parsing and validation on this - // Ensure ID token validation is done: - // https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation - // To full affect and tested - // JWT signature algorthims: - // https://datatracker.ietf.org/doc/html/rfc7518#section-3 - - $userDetails = $this->getUserDetails($accessToken->getIdToken()); - $isLoggedIn = auth()->check(); - if ($this->config['dump_user_details']) { - throw new JsonDebugException($accessToken->jsonSerialize()); + throw new JsonDebugException($idToken->claims()); } + try { + $idToken->validate($this->config['client_id']); + } catch (InvalidTokenException $exception) { + throw new OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}"); + } + + $userDetails = $this->getUserDetails($idToken); + $isLoggedIn = auth()->check(); + if ($userDetails['email'] === null) { throw new OpenIdConnectException(trans('errors.oidc_no_email_address')); } diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 7c8eb2c86..d12d7c9bc 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -43,7 +43,8 @@ class LoginController extends Controller public function __construct(SocialAuthService $socialAuthService, LoginService $loginService) { $this->middleware('guest', ['only' => ['getLogin', 'login']]); - $this->middleware('guard:standard,ldap', ['only' => ['login', 'logout']]); + $this->middleware('guard:standard,ldap', ['only' => ['login']]); + $this->middleware('guard:standard,ldap,oidc', ['only' => ['logout']]); $this->socialAuthService = $socialAuthService; $this->loginService = $loginService; diff --git a/composer.json b/composer.json index e53d9d25a..066e67c4f 100644 --- a/composer.json +++ b/composer.json @@ -36,8 +36,7 @@ "socialiteproviders/okta": "^4.1", "socialiteproviders/slack": "^4.1", "socialiteproviders/twitch": "^5.3", - "ssddanbrown/htmldiff": "^v1.0.1", - "steverhoades/oauth2-openid-connect-client": "^0.3.0" + "ssddanbrown/htmldiff": "^v1.0.1" }, "require-dev": { "barryvdh/laravel-debugbar": "^3.5.1", diff --git a/composer.lock b/composer.lock index 62b2aa621..d64d8d640 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5cbbf417bd19cd2164f91b9b2d38600c", + "content-hash": "ef6a8bb7bc6e99c70eeabc7695fc56eb", "packages": [ { "name": "aws/aws-crt-php", @@ -2069,83 +2069,6 @@ }, "time": "2021-08-31T15:16:26+00:00" }, - { - "name": "lcobucci/jwt", - "version": "3.4.6", - "source": { - "type": "git", - "url": "https://github.com/lcobucci/jwt.git", - "reference": "3ef8657a78278dfeae7707d51747251db4176240" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/lcobucci/jwt/zipball/3ef8657a78278dfeae7707d51747251db4176240", - "reference": "3ef8657a78278dfeae7707d51747251db4176240", - "shasum": "" - }, - "require": { - "ext-mbstring": "*", - "ext-openssl": "*", - "php": "^5.6 || ^7.0" - }, - "require-dev": { - "mikey179/vfsstream": "~1.5", - "phpmd/phpmd": "~2.2", - "phpunit/php-invoker": "~1.1", - "phpunit/phpunit": "^5.7 || ^7.3", - "squizlabs/php_codesniffer": "~2.3" - }, - "suggest": { - "lcobucci/clock": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.1-dev" - } - }, - "autoload": { - "psr-4": { - "Lcobucci\\JWT\\": "src" - }, - "files": [ - "compat/class-aliases.php", - "compat/json-exception-polyfill.php", - "compat/lcobucci-clock-polyfill.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Luís Otávio Cobucci Oblonczyk", - "email": "lcobucci@gmail.com", - "role": "Developer" - } - ], - "description": "A simple library to work with JSON Web Token and JSON Web Signature", - "keywords": [ - "JWS", - "jwt" - ], - "support": { - "issues": "https://github.com/lcobucci/jwt/issues", - "source": "https://github.com/lcobucci/jwt/tree/3.4.6" - }, - "funding": [ - { - "url": "https://github.com/lcobucci", - "type": "github" - }, - { - "url": "https://www.patreon.com/lcobucci", - "type": "patreon" - } - ], - "time": "2021-09-28T19:18:28+00:00" - }, { "name": "league/commonmark", "version": "1.6.6", @@ -2613,76 +2536,6 @@ }, "time": "2021-08-15T23:05:49+00:00" }, - { - "name": "league/oauth2-client", - "version": "2.6.0", - "source": { - "type": "git", - "url": "https://github.com/thephpleague/oauth2-client.git", - "reference": "badb01e62383430706433191b82506b6df24ad98" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/badb01e62383430706433191b82506b6df24ad98", - "reference": "badb01e62383430706433191b82506b6df24ad98", - "shasum": "" - }, - "require": { - "guzzlehttp/guzzle": "^6.0 || ^7.0", - "paragonie/random_compat": "^1 || ^2 || ^9.99", - "php": "^5.6 || ^7.0 || ^8.0" - }, - "require-dev": { - "mockery/mockery": "^1.3", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpunit/phpunit": "^5.7 || ^6.0 || ^9.3", - "squizlabs/php_codesniffer": "^2.3 || ^3.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-2.x": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "League\\OAuth2\\Client\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Alex Bilbie", - "email": "hello@alexbilbie.com", - "homepage": "http://www.alexbilbie.com", - "role": "Developer" - }, - { - "name": "Woody Gilk", - "homepage": "https://github.com/shadowhand", - "role": "Contributor" - } - ], - "description": "OAuth 2.0 Client Library", - "keywords": [ - "Authentication", - "SSO", - "authorization", - "identity", - "idp", - "oauth", - "oauth2", - "single sign on" - ], - "support": { - "issues": "https://github.com/thephpleague/oauth2-client/issues", - "source": "https://github.com/thephpleague/oauth2-client/tree/2.6.0" - }, - "time": "2020-10-28T02:03:40+00:00" - }, { "name": "monolog/monolog", "version": "2.3.5", @@ -4675,53 +4528,6 @@ ], "time": "2021-01-24T18:51:30+00:00" }, - { - "name": "steverhoades/oauth2-openid-connect-client", - "version": "v0.3.0", - "source": { - "type": "git", - "url": "https://github.com/steverhoades/oauth2-openid-connect-client.git", - "reference": "0159471487540a4620b8d0b693f5f215503a8d75" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/steverhoades/oauth2-openid-connect-client/zipball/0159471487540a4620b8d0b693f5f215503a8d75", - "reference": "0159471487540a4620b8d0b693f5f215503a8d75", - "shasum": "" - }, - "require": { - "lcobucci/jwt": "^3.2", - "league/oauth2-client": "^2.0" - }, - "require-dev": { - "phpmd/phpmd": "~2.2", - "phpunit/php-invoker": "~1.1", - "phpunit/phpunit": "~4.5", - "squizlabs/php_codesniffer": "~2.3" - }, - "type": "library", - "autoload": { - "psr-4": { - "OpenIDConnectClient\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Steve Rhoades", - "email": "sedonami@gmail.com" - } - ], - "description": "OAuth2 OpenID Connect Client that utilizes the PHP Leagues OAuth2 Client", - "support": { - "issues": "https://github.com/steverhoades/oauth2-openid-connect-client/issues", - "source": "https://github.com/steverhoades/oauth2-openid-connect-client/tree/master" - }, - "time": "2020-05-19T23:06:36+00:00" - }, { "name": "swiftmailer/swiftmailer", "version": "v6.2.7", From f3d54e4a2dd0fe22f5c2e44f878d1bc6757aa230 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 12 Oct 2021 00:01:51 +0100 Subject: [PATCH 19/24] Added positive test case for OIDC implementation - To continue coverage and spec cases next. --- .../Access/OpenIdConnect/JwtSigningKey.php | 12 +- .../OpenIdConnect/OpenIdConnectIdToken.php | 3 +- tests/Unit/OpenIdConnectIdTokenTest.php | 118 ++++++++++++++++++ 3 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/OpenIdConnectIdTokenTest.php diff --git a/app/Auth/Access/OpenIdConnect/JwtSigningKey.php b/app/Auth/Access/OpenIdConnect/JwtSigningKey.php index 1687069a9..ed823073a 100644 --- a/app/Auth/Access/OpenIdConnect/JwtSigningKey.php +++ b/app/Auth/Access/OpenIdConnect/JwtSigningKey.php @@ -60,15 +60,19 @@ class JwtSigningKey throw new InvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}"); } - if ($jwk['use'] !== 'sig') { - throw new InvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['sig']}"); + if (empty($jwk['use'])) { + throw new InvalidKeyException('A "use" parameter on the provided key is expected'); } - if (empty($jwk['e'] ?? '')) { + if ($jwk['use'] !== 'sig') { + throw new InvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}"); + } + + if (empty($jwk['e'])) { throw new InvalidKeyException('An "e" parameter on the provided key is expected'); } - if (empty($jwk['n'] ?? '')) { + if (empty($jwk['n'])) { throw new InvalidKeyException('A "n" parameter on the provided key is expected'); } diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php index b5cef1772..0ee43e663 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php @@ -76,11 +76,12 @@ class OpenIdConnectIdToken * Validate all possible parts of the id token. * @throws InvalidTokenException */ - public function validate(string $clientId) + public function validate(string $clientId): bool { $this->validateTokenStructure(); $this->validateTokenSignature(); $this->validateTokenClaims($clientId); + return true; } /** diff --git a/tests/Unit/OpenIdConnectIdTokenTest.php b/tests/Unit/OpenIdConnectIdTokenTest.php new file mode 100644 index 000000000..fdde54f27 --- /dev/null +++ b/tests/Unit/OpenIdConnectIdTokenTest.php @@ -0,0 +1,118 @@ +getValidToken(), 'https://auth.example.com', [ + $this->jwkKeyArray() + ]); + + $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123')); + } + + protected function getValidToken($payloadOverrides = []): string + { + $defaultPayload = [ + "sub" => "abc1234def", + "name" => "Barry Scott", + "email" => "bscott@example.com", + "ver" => 1, + "iss" => "https://auth.example.com", + "aud" => "xxyyzz.aaa.bbccdd.123", + "iat" => time(), + "exp" => time() + 720, + "jti" => "ID.AaaBBBbbCCCcccDDddddddEEEeeeeee", + "amr" => ["pwd"], + "idp" => "fghfghgfh546456dfgdfg", + "preferred_username" => "xXBazzaXx", + "auth_time" => time(), + "at_hash" => "sT4jbsdSGy9w12pq3iNYDA", + ]; + + $payload = array_merge($defaultPayload, $payloadOverrides); + $header = [ + 'kid' => 'xyz456', + 'alg' => 'RS256', + ]; + + $top = implode('.', [ + $this->base64UrlEncode(json_encode($header)), + $this->base64UrlEncode(json_encode($payload)), + ]); + + $privateKey = RSA::loadPrivateKey($this->privatePemKey())->withPadding(RSA::SIGNATURE_PKCS1); + $signature = $privateKey->sign($top); + + return $top . '.' . $this->base64UrlEncode($signature); + } + + protected function base64UrlEncode(string $decoded): string + { + return strtr(base64_encode($decoded), '+/', '-_'); + } + + protected function pemKey(): string + { + return "-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqo1OmfNKec5S2zQC4SP9 +DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvm +zXL16c93Obn7G8x8A3ao6yN5qKO5S5+CETqOZfKN/g75Xlz7VsC3igOhgsXnPx6i +iM6sbYbk0U/XpFaT84LXKI8VTIPUo7gTeZN1pTET//i9FlzAOzX+xfWBKdOqlEzl ++zihMHCZUUvQu99P+o0MDR0lMUT+vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNk +WvsLta1+jNUee+8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw +3wIDAQAB +-----END PUBLIC KEY-----"; + } + + protected function privatePemKey(): string + { + return "-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCqjU6Z80p5zlLb +NALhI/0Ose5RHRWAKLqipwYRHPvOo7fqGqTcDdHdoKAmQSN+ducy6zNFEqzjk1te +g6n2m+bNcvXpz3c5ufsbzHwDdqjrI3moo7lLn4IROo5l8o3+DvleXPtWwLeKA6GC +xec/HqKIzqxthuTRT9ekVpPzgtcojxVMg9SjuBN5k3WlMRP/+L0WXMA7Nf7F9YEp +06qUTOX7OKEwcJlRS9C730/6jQwNHSUxRP688npIl5F+egd7HC3ptkWI2exkgQvT +dtfhA2Ra+wu1rX6M1R577wg9WHMI7xu8zzo3MtopQniTo1nkhWuZ0IWkWyMJYHI6 +sMbzB3DfAgMBAAECggEADm7K2ghWoxwsstQh8j+DaLzx9/dIHIJV2PHdd5FGVeRQ +6gS7MswQmHrBUrtsb4VMZ2iz/AJqkw+jScpGldH3pCc4XELsSfxNHbseO4TNIqjr +4LOKOLYU4bRc3I+8KGXIAI5JzrucTJemEVUCDrte8cjbmqExt+zTyNpyxsapworF +v+vnSdv40d62f+cS1xvwB+ymLK/B/wZ/DemDCi8jsi7ou/M7l5xNCzjH4iMSLtOW +fgEhejIBG9miMJWPiVpTXE3tMdNuN3OsWc4XXm2t4VRovlZdu30Fax1xWB+Locsv +HlHKLOFc8g+jZh0TL2KCNjPffMcC7kHhW3afshpIsQKBgQDhyWUnkqd6FzbwIX70 +SnaMgKoUv5W/K5T+Sv/PA2CyN8Gu8ih/OsoNZSnI0uqe3XQIvvgN/Fq3wO1ttLzf +z5B6ZC7REfTgcR0190gihk6f5rtcj7d6Fy/oG2CE8sDSXgPnpEaBjvJVgN5v/U2s +HpVaidmHTyGLCfEszoeoy8jyrQKBgQDBX8caGylmzQLc6XNntZChlt3e18Nj8MPA +DxWLcoqgdDoofLDQAmLl+vPKyDmhQjos5eas1jgmVVEM4ge+MysaVezvuLBsSnOh +ihc0i63USU6i7YDE83DrCewCthpFHi/wW1S5FoCAzpVy8y99vwcqO4kOXcmf4O6Y +uW6sMsjvOwKBgQDbFtqB+MtsLCSSBF61W6AHHD5tna4H75lG2623yXZF2NanFLF5 +K6muL9DI3ujtOMQETJJUt9+rWJjLEEsJ/dYa/SV0l7D/LKOEnyuu3JZkkLaTzZzi +6qcA2bfhqdCzEKlHV99WjkfV8hNlpex9rLuOPB8JLh7FVONicBGxF/UojQKBgDXs +IlYaSuI6utilVKQP0kPtEPOKERc2VS+iRSy8hQGXR3xwwNFQSQm+f+sFCGT6VcSd +W0TI+6Fc2xwPj38vP465dTentbKM1E+wdSYW6SMwSfhO6ECDbfJsst5Sr2Kkt1N7 +9FUkfDLu6GfEfnK/KR1SurZB2u51R7NYyg7EnplvAoGAT0aTtOcck0oYN30g5mdf +efqXPwg2wAPYeiec49EbfnteQQKAkqNfJ9K69yE2naf6bw3/5mCBsq/cXeuaBMII +ylysUIRBqt2J0kWm2yCpFWR7H+Ilhdx9A7ZLCqYVt8e+vjO/BOI3cQDe2VPOLPSl +q/1PY4iJviGKddtmfClH3v4= +-----END PRIVATE KEY-----"; + } + + protected function jwkKeyArray(): array + { + return [ + 'kty' => 'RSA', + 'alg' => 'RS256', + 'kid' => '066e52af-8884-4926-801d-032a276f9f2a', + 'use' => 'sig', + 'e' => 'AQAB', + 'n' => 'qo1OmfNKec5S2zQC4SP9DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvmzXL16c93Obn7G8x8A3ao6yN5qKO5S5-CETqOZfKN_g75Xlz7VsC3igOhgsXnPx6iiM6sbYbk0U_XpFaT84LXKI8VTIPUo7gTeZN1pTET__i9FlzAOzX-xfWBKdOqlEzl-zihMHCZUUvQu99P-o0MDR0lMUT-vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNkWvsLta1-jNUee-8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw3w', + ]; + } +} \ No newline at end of file From 790723dfc52ca2169234bb86450e03aea51b3676 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 12 Oct 2021 16:48:54 +0100 Subject: [PATCH 20/24] Added further OIDC core class testing --- .../OpenIdConnect/OpenIdConnectIdToken.php | 4 +- .../OpenIdConnect/OpenIdConnectService.php | 2 +- tests/Unit/OpenIdConnectIdTokenTest.php | 174 +++++++++++++++++- 3 files changed, 168 insertions(+), 12 deletions(-) diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php index 0ee43e663..1c1d0913d 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectIdToken.php @@ -97,7 +97,7 @@ class OpenIdConnectIdToken /** * Get all returned claims within the token. */ - public function claims(): array + public function getAllClaims(): array { return $this->payload; } @@ -174,7 +174,7 @@ class OpenIdConnectIdToken $aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud']; if (count($aud) !== 1) { - throw new InvalidTokenException('Token audience value has ' . count($aud) . ' values. Expected 1.'); + throw new InvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1'); } if ($aud[0] !== $clientId) { diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php index 0f9fed006..7471a5007 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php @@ -136,7 +136,7 @@ class OpenIdConnectService ); if ($this->config['dump_user_details']) { - throw new JsonDebugException($idToken->claims()); + throw new JsonDebugException($idToken->getAllClaims()); } try { diff --git a/tests/Unit/OpenIdConnectIdTokenTest.php b/tests/Unit/OpenIdConnectIdTokenTest.php index fdde54f27..d3394daf6 100644 --- a/tests/Unit/OpenIdConnectIdTokenTest.php +++ b/tests/Unit/OpenIdConnectIdTokenTest.php @@ -2,25 +2,169 @@ namespace Tests\Unit; +use BookStack\Auth\Access\OpenIdConnect\InvalidTokenException; use BookStack\Auth\Access\OpenIdConnect\OpenIdConnectIdToken; use phpseclib3\Crypt\RSA; use Tests\TestCase; class OpenIdConnectIdTokenTest extends TestCase { - public function test_valid_token_passes_validation() { - $token = new OpenIdConnectIdToken($this->getValidToken(), 'https://auth.example.com', [ + $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', [ $this->jwkKeyArray() ]); $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123')); } - protected function getValidToken($payloadOverrides = []): string + public function test_get_claim_returns_value_if_existing() { - $defaultPayload = [ + $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', []); + $this->assertEquals('bscott@example.com', $token->getClaim('email')); + } + + public function test_get_claim_returns_null_if_not_existing() + { + $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', []); + $this->assertEquals(null, $token->getClaim('emails')); + } + + public function test_get_all_claims_returns_all_payload_claims() + { + $defaultPayload = $this->getDefaultPayload(); + $token = new OpenIdConnectIdToken($this->idToken($defaultPayload), 'https://auth.example.com', []); + $this->assertEquals($defaultPayload, $token->getAllClaims()); + } + + public function test_token_structure_error_cases() + { + $idToken = $this->idToken(); + $idTokenExploded = explode('.', $idToken); + + $messagesAndTokenValues = [ + ['Could not parse out a valid header within the provided token', ''], + ['Could not parse out a valid header within the provided token', 'cat'], + ['Could not parse out a valid payload within the provided token', $idTokenExploded[0]], + ['Could not parse out a valid payload within the provided token', $idTokenExploded[0] . '.' . 'dog'], + ['Could not parse out a valid signature within the provided token', $idTokenExploded[0] . '.' . $idTokenExploded[1]], + ['Could not parse out a valid signature within the provided token', $idTokenExploded[0] . '.' . $idTokenExploded[1] . '.' . '@$%'], + ]; + + foreach ($messagesAndTokenValues as [$message, $tokenValue]) { + $token = new OpenIdConnectIdToken($tokenValue, 'https://auth.example.com', []); + $err = null; + try { + $token->validate('abc'); + } catch (\Exception $exception) { + $err = $exception; + } + + $this->assertInstanceOf(InvalidTokenException::class, $err, $message); + $this->assertEquals($message, $err->getMessage()); + } + } + + public function test_error_thrown_if_token_signature_not_validated_from_no_keys() + { + $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', []); + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); + $token->validate('abc'); + } + + public function test_error_thrown_if_token_signature_not_validated_from_non_matching_key() + { + $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', [ + array_merge($this->jwkKeyArray(), [ + 'n' => 'iqK-1QkICMf_cusNLpeNnN-bhT0-9WLBvzgwKLALRbrevhdi5ttrLHIQshaSL0DklzfyG2HWRmAnJ9Q7sweEjuRiiqRcSUZbYu8cIv2hLWYu7K_NH67D2WUjl0EnoHEuiVLsZhQe1CmdyLdx087j5nWkd64K49kXRSdxFQUlj8W3NeK3CjMEUdRQ3H4RZzJ4b7uuMiFA29S2ZhMNG20NPbkUVsFL-jiwTd10KSsPT8yBYipI9O7mWsUWt_8KZs1y_vpM_k3SyYihnWpssdzDm1uOZ8U3mzFr1xsLAO718GNUSXk6npSDzLl59HEqa6zs4O9awO2qnSHvcmyELNk31w' + ]) + ]); + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); + $token->validate('abc'); + } + + public function test_error_thrown_if_token_signature_not_validated_from_invalid_key() + { + $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', ['url://example.com']); + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); + $token->validate('abc'); + } + + public function test_error_thrown_if_token_algorithm_is_not_rs256() + { + $token = new OpenIdConnectIdToken($this->idToken([], ['alg' => 'HS256']), 'https://auth.example.com', []); + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage("Only RS256 signature validation is supported. Token reports using HS256"); + $token->validate('abc'); + } + + public function test_token_claim_error_cases() + { + /** @var array $claimOverridesByErrorMessage */ + $claimOverridesByErrorMessage = [ + // 1. iss claim present + ['Missing or non-matching token issuer value', ['iss' => null]], + // 1. iss claim matches provided issuer + ['Missing or non-matching token issuer value', ['iss' => 'https://auth.example.co.uk']], + // 2. aud claim present + ['Missing token audience value', ['aud' => null]], + // 2. aud claim validates all values against those expected (Only expect single) + ['Token audience value has 2 values, Expected 1', ['aud' => ['abc', 'def']]], + // 2. aud claim matches client id + ['Token audience value did not match the expected client_id', ['aud' => 'xxyyzz.aaa.bbccdd.456']], + // 4. azp claim matches client id if present + ['Token authorized party exists but does not match the expected client_id', ['azp' => 'xxyyzz.aaa.bbccdd.456']], + // 5. exp claim present + ['Missing token expiration time value', ['exp' => null]], + // 5. exp claim not expired + ['Token has expired', ['exp' => time() - 360]], + // 6. iat claim present + ['Missing token issued at time value', ['iat' => null]], + // 6. iat claim too far in the future + ['Token issue at time is not recent or is invalid', ['iat' => time() + 600]], + // 6. iat claim too far in the past + ['Token issue at time is not recent or is invalid', ['iat' => time() - 172800]], + + // Custom: sub is present + ['Missing token subject value', ['sub' => null]], + ]; + + foreach ($claimOverridesByErrorMessage as [$message, $overrides]) { + $token = new OpenIdConnectIdToken($this->idToken($overrides), 'https://auth.example.com', [ + $this->jwkKeyArray() + ]); + + $err = null; + try { + $token->validate('xxyyzz.aaa.bbccdd.123'); + } catch (\Exception $exception) { + $err = $exception; + } + + $this->assertInstanceOf(InvalidTokenException::class, $err, $message); + $this->assertEquals($message, $err->getMessage()); + } + } + + public function test_keys_can_be_a_local_file_reference_to_pem_key() + { + $file = tmpfile(); + $testFilePath = 'file://' . stream_get_meta_data($file)['uri']; + file_put_contents($testFilePath, $this->pemKey()); + $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', [ + $testFilePath + ]); + + $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123')); + unlink($testFilePath); + } + + protected function getDefaultPayload(): array + { + return [ "sub" => "abc1234def", "name" => "Barry Scott", "email" => "bscott@example.com", @@ -36,24 +180,36 @@ class OpenIdConnectIdTokenTest extends TestCase "auth_time" => time(), "at_hash" => "sT4jbsdSGy9w12pq3iNYDA", ]; + } - $payload = array_merge($defaultPayload, $payloadOverrides); - $header = [ + protected function idToken($payloadOverrides = [], $headerOverrides = []): string + { + $payload = array_merge($this->getDefaultPayload(), $payloadOverrides); + $header = array_merge([ 'kid' => 'xyz456', 'alg' => 'RS256', - ]; + ], $headerOverrides); $top = implode('.', [ $this->base64UrlEncode(json_encode($header)), $this->base64UrlEncode(json_encode($payload)), ]); - $privateKey = RSA::loadPrivateKey($this->privatePemKey())->withPadding(RSA::SIGNATURE_PKCS1); + $privateKey = $this->getPrivateKey(); $signature = $privateKey->sign($top); - return $top . '.' . $this->base64UrlEncode($signature); } + protected function getPrivateKey() + { + static $key; + if (is_null($key)) { + $key = RSA::loadPrivateKey($this->privatePemKey())->withPadding(RSA::SIGNATURE_PKCS1); + } + + return $key; + } + protected function base64UrlEncode(string $decoded): string { return strtr(base64_encode($decoded), '+/', '-_'); From 06a0d829c883f04da4365ea6a0cbb49cc7ef70c9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 12 Oct 2021 23:00:52 +0100 Subject: [PATCH 21/24] Added OIDC basic autodiscovery support --- .env.example.complete | 1 + .../IssuerDiscoveryException.php | 8 + .../OpenIdConnectProviderSettings.php | 198 ++++++++++++++++++ .../OpenIdConnect/OpenIdConnectService.php | 53 +++-- app/Config/oidc.php | 7 +- composer.json | 1 + composer.lock | 72 ++++++- 7 files changed, 325 insertions(+), 15 deletions(-) create mode 100644 app/Auth/Access/OpenIdConnect/IssuerDiscoveryException.php create mode 100644 app/Auth/Access/OpenIdConnect/OpenIdConnectProviderSettings.php diff --git a/.env.example.complete b/.env.example.complete index e92eb5099..418875165 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -245,6 +245,7 @@ OIDC_DISPLAY_NAME_CLAIMS=name OIDC_CLIENT_ID=null OIDC_CLIENT_SECRET=null OIDC_ISSUER=null +OIDC_ISSUER_DISCOVER=false OIDC_PUBLIC_KEY=null OIDC_AUTH_ENDPOINT=null OIDC_TOKEN_ENDPOINT=null diff --git a/app/Auth/Access/OpenIdConnect/IssuerDiscoveryException.php b/app/Auth/Access/OpenIdConnect/IssuerDiscoveryException.php new file mode 100644 index 000000000..26dfca1fe --- /dev/null +++ b/app/Auth/Access/OpenIdConnect/IssuerDiscoveryException.php @@ -0,0 +1,8 @@ +applySettingsFromArray($settings); + $this->validateInitial(); + } + + /** + * Apply an array of settings to populate setting properties within this class. + */ + protected function applySettingsFromArray(array $settingsArray) + { + foreach ($settingsArray as $key => $value) { + if (property_exists($this, $key)) { + $this->$key = $value; + } + } + } + + /** + * Validate any core, required properties have been set. + * @throws InvalidArgumentException + */ + protected function validateInitial() + { + $required = ['clientId', 'clientSecret', 'redirectUri', 'issuer']; + foreach ($required as $prop) { + if (empty($this->$prop)) { + throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value"); + } + } + + if (strpos($this->issuer, 'https://') !== 0) { + throw new InvalidArgumentException("Issuer value must start with https://"); + } + } + + /** + * Perform a full validation on these settings. + * @throws InvalidArgumentException + */ + public function validate(): void + { + $this->validateInitial(); + $required = ['keys', 'tokenEndpoint', 'authorizationEndpoint']; + foreach ($required as $prop) { + if (empty($this->$prop)) { + throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value"); + } + } + } + + /** + * Discover and autoload settings from the configured issuer. + * @throws IssuerDiscoveryException + */ + public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes) + { + try { + $cacheKey = 'oidc-discovery::' . $this->issuer; + $discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function() use ($httpClient) { + return $this->loadSettingsFromIssuerDiscovery($httpClient); + }); + $this->applySettingsFromArray($discoveredSettings); + } catch (ClientExceptionInterface $exception) { + throw new IssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}"); + } + } + + /** + * @throws IssuerDiscoveryException + * @throws ClientExceptionInterface + */ + protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array + { + $issuerUrl = rtrim($this->issuer, '/') . '/.well-known/openid-configuration'; + $request = new Request('GET', $issuerUrl); + $response = $httpClient->sendRequest($request); + $result = json_decode($response->getBody()->getContents(), true); + + if (empty($result) || !is_array($result)) { + throw new IssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}"); + } + + if ($result['issuer'] !== $this->issuer) { + throw new IssuerDiscoveryException("Unexpected issuer value found on discovery response"); + } + + $discoveredSettings = []; + + if (!empty($result['authorization_endpoint'])) { + $discoveredSettings['authorizationEndpoint'] = $result['authorization_endpoint']; + } + + if (!empty($result['token_endpoint'])) { + $discoveredSettings['tokenEndpoint'] = $result['token_endpoint']; + } + + if (!empty($result['jwks_uri'])) { + $keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient); + $discoveredSettings['keys'] = array_filter($keys); + } + + return $discoveredSettings; + } + + /** + * Filter the given JWK keys down to just those we support. + */ + protected function filterKeys(array $keys): array + { + return array_filter($keys, function(array $key) { + return $key['key'] === 'RSA' && $key['use'] === 'sig' && $key['alg'] === 'RS256'; + }); + } + + /** + * Return an array of jwks as PHP key=>value arrays. + * @throws ClientExceptionInterface + * @throws IssuerDiscoveryException + */ + protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array + { + $request = new Request('GET', $uri); + $response = $httpClient->sendRequest($request); + $result = json_decode($response->getBody()->getContents(), true); + + if (empty($result) || !is_array($result) || !isset($result['keys'])) { + throw new IssuerDiscoveryException("Error reading keys from issuer jwks_uri"); + } + + return $result['keys']; + } + + /** + * Get the settings needed by an OAuth provider, as a key=>value array. + */ + public function arrayForProvider(): array + { + $settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint']; + $settings = []; + foreach ($settingKeys as $setting) { + $settings[$setting] = $this->$setting; + } + return $settings; + } +} \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php index 7471a5007..57c9d1238 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php @@ -8,6 +8,9 @@ use BookStack\Exceptions\OpenIdConnectException; use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\UserRegistrationException; use Exception; +use GuzzleHttp\Client; +use Illuminate\Support\Facades\Cache; +use Psr\Http\Client\ClientExceptionInterface; use function auth; use function config; use function trans; @@ -39,7 +42,8 @@ class OpenIdConnectService */ public function login(): array { - $provider = $this->getProvider(); + $settings = $this->getProviderSettings(); + $provider = $this->getProvider($settings); return [ 'url' => $provider->getAuthorizationUrl(), 'state' => $provider->getState(), @@ -52,34 +56,57 @@ class OpenIdConnectService * the authorization server. * Returns null if not authenticated. * @throws Exception + * @throws ClientExceptionInterface */ public function processAuthorizeResponse(?string $authorizationCode): ?User { - $provider = $this->getProvider(); + $settings = $this->getProviderSettings(); + $provider = $this->getProvider($settings); // Try to exchange authorization code for access token $accessToken = $provider->getAccessToken('authorization_code', [ 'code' => $authorizationCode, ]); - return $this->processAccessTokenCallback($accessToken); + return $this->processAccessTokenCallback($accessToken, $settings); } /** - * Load the underlying OpenID Connect Provider. + * @throws IssuerDiscoveryException + * @throws ClientExceptionInterface */ - protected function getProvider(): OpenIdConnectOAuthProvider + protected function getProviderSettings(): OpenIdConnectProviderSettings { - // Setup settings - $settings = [ + $settings = new OpenIdConnectProviderSettings([ + 'issuer' => $this->config['issuer'], 'clientId' => $this->config['client_id'], 'clientSecret' => $this->config['client_secret'], 'redirectUri' => url('/oidc/redirect'), 'authorizationEndpoint' => $this->config['authorization_endpoint'], 'tokenEndpoint' => $this->config['token_endpoint'], - ]; + ]); - return new OpenIdConnectOAuthProvider($settings); + // Use keys if configured + if (!empty($this->config['jwt_public_key'])) { + $settings->keys = [$this->config['jwt_public_key']]; + } + + // Run discovery + if ($this->config['discover'] ?? false) { + $settings->discoverFromIssuer(new Client(['timeout' => 3]), Cache::store(null), 15); + } + + $settings->validate(); + + return $settings; + } + + /** + * Load the underlying OpenID Connect Provider. + */ + protected function getProvider(OpenIdConnectProviderSettings $settings): OpenIdConnectOAuthProvider + { + return new OpenIdConnectOAuthProvider($settings->arrayForProvider()); } /** @@ -126,13 +153,13 @@ class OpenIdConnectService * @throws UserRegistrationException * @throws StoppedAuthenticationException */ - protected function processAccessTokenCallback(OpenIdConnectAccessToken $accessToken): User + protected function processAccessTokenCallback(OpenIdConnectAccessToken $accessToken, OpenIdConnectProviderSettings $settings): User { $idTokenText = $accessToken->getIdToken(); $idToken = new OpenIdConnectIdToken( $idTokenText, - $this->config['issuer'], - [$this->config['jwt_public_key']] + $settings->issuer, + $settings->keys, ); if ($this->config['dump_user_details']) { @@ -140,7 +167,7 @@ class OpenIdConnectService } try { - $idToken->validate($this->config['client_id']); + $idToken->validate($settings->clientId); } catch (InvalidTokenException $exception) { throw new OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}"); } diff --git a/app/Config/oidc.php b/app/Config/oidc.php index 43e8678ad..1b50d9d66 100644 --- a/app/Config/oidc.php +++ b/app/Config/oidc.php @@ -17,9 +17,14 @@ return [ // OAuth2/OpenId client secret, as configured in your Authorization server. 'client_secret' => env('OIDC_CLIENT_SECRET', null), - // The issuer of the identity token (id_token) this will be compared with what is returned in the token. + // The issuer of the identity token (id_token) this will be compared with + // what is returned in the token. 'issuer' => env('OIDC_ISSUER', null), + // Auto-discover the relevant endpoints and keys from the issuer. + // Fetched details are cached for 15 minutes. + 'discover' => env('OIDC_ISSUER_DISCOVER', false), + // Public key that's used to verify the JWT token with. // Can be the key value itself or a local 'file://public.key' reference. 'jwt_public_key' => env('OIDC_PUBLIC_KEY', null), diff --git a/composer.json b/composer.json index 066e67c4f..dc281e5a8 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "league/commonmark": "^1.5", "league/flysystem-aws-s3-v3": "^1.0.29", "league/html-to-markdown": "^5.0.0", + "league/oauth2-client": "^2.6", "nunomaduro/collision": "^3.1", "onelogin/php-saml": "^4.0", "phpseclib/phpseclib": "~3.0", diff --git a/composer.lock b/composer.lock index d64d8d640..89e408eb9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ef6a8bb7bc6e99c70eeabc7695fc56eb", + "content-hash": "b82cfdfe8bb32847ba2188804858d5fd", "packages": [ { "name": "aws/aws-crt-php", @@ -2536,6 +2536,76 @@ }, "time": "2021-08-15T23:05:49+00:00" }, + { + "name": "league/oauth2-client", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-client.git", + "reference": "badb01e62383430706433191b82506b6df24ad98" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/badb01e62383430706433191b82506b6df24ad98", + "reference": "badb01e62383430706433191b82506b6df24ad98", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "paragonie/random_compat": "^1 || ^2 || ^9.99", + "php": "^5.6 || ^7.0 || ^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.3", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpunit/phpunit": "^5.7 || ^6.0 || ^9.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Woody Gilk", + "homepage": "https://github.com/shadowhand", + "role": "Contributor" + } + ], + "description": "OAuth 2.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "identity", + "idp", + "oauth", + "oauth2", + "single sign on" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-client/issues", + "source": "https://github.com/thephpleague/oauth2-client/tree/2.6.0" + }, + "time": "2020-10-28T02:03:40+00:00" + }, { "name": "monolog/monolog", "version": "2.3.5", From c167f40af32a45f0905c6d2961865fbe8c52d996 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 12 Oct 2021 23:04:28 +0100 Subject: [PATCH 22/24] Renamed OIDC files to all be aligned --- .../OidcAccessToken.php} | 4 +- .../OidcIdToken.php} | 46 +++++++++---------- .../Access/Oidc/OidcInvalidKeyException.php | 8 ++++ .../Access/Oidc/OidcInvalidTokenException.php | 10 ++++ .../Oidc/OidcIssuerDiscoveryException.php | 8 ++++ .../OidcJwtSigningKey.php} | 28 +++++------ .../OidcOAuthProvider.php} | 8 ++-- .../OidcProviderSettings.php} | 18 ++++---- .../OidcService.php} | 24 +++++----- .../OpenIdConnect/InvalidKeyException.php | 8 ---- .../OpenIdConnect/InvalidTokenException.php | 10 ---- .../IssuerDiscoveryException.php | 8 ---- .../Auth/OpenIdConnectController.php | 4 +- ...ectIdTokenTest.php => OidcIdTokenTest.php} | 40 ++++++++-------- 14 files changed, 112 insertions(+), 112 deletions(-) rename app/Auth/Access/{OpenIdConnect/OpenIdConnectAccessToken.php => Oidc/OidcAccessToken.php} (94%) rename app/Auth/Access/{OpenIdConnect/OpenIdConnectIdToken.php => Oidc/OidcIdToken.php} (78%) create mode 100644 app/Auth/Access/Oidc/OidcInvalidKeyException.php create mode 100644 app/Auth/Access/Oidc/OidcInvalidTokenException.php create mode 100644 app/Auth/Access/Oidc/OidcIssuerDiscoveryException.php rename app/Auth/Access/{OpenIdConnect/JwtSigningKey.php => Oidc/OidcJwtSigningKey.php} (65%) rename app/Auth/Access/{OpenIdConnect/OpenIdConnectOAuthProvider.php => Oidc/OidcOAuthProvider.php} (94%) rename app/Auth/Access/{OpenIdConnect/OpenIdConnectProviderSettings.php => Oidc/OidcProviderSettings.php} (89%) rename app/Auth/Access/{OpenIdConnect/OpenIdConnectService.php => Oidc/OidcService.php} (86%) delete mode 100644 app/Auth/Access/OpenIdConnect/InvalidKeyException.php delete mode 100644 app/Auth/Access/OpenIdConnect/InvalidTokenException.php delete mode 100644 app/Auth/Access/OpenIdConnect/IssuerDiscoveryException.php rename tests/Unit/{OpenIdConnectIdTokenTest.php => OidcIdTokenTest.php} (86%) diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectAccessToken.php b/app/Auth/Access/Oidc/OidcAccessToken.php similarity index 94% rename from app/Auth/Access/OpenIdConnect/OpenIdConnectAccessToken.php rename to app/Auth/Access/Oidc/OidcAccessToken.php index 6731ec4be..63853e08a 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectAccessToken.php +++ b/app/Auth/Access/Oidc/OidcAccessToken.php @@ -1,11 +1,11 @@ $prop) || !is_array($this->$prop)) { - throw new InvalidTokenException("Could not parse out a valid {$prop} within the provided token"); + throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token"); } } if (empty($this->signature) || !is_string($this->signature)) { - throw new InvalidTokenException("Could not parse out a valid signature within the provided token"); + throw new OidcInvalidTokenException("Could not parse out a valid signature within the provided token"); } } /** * Validate the signature of the given token and ensure it validates against the provided key. - * @throws InvalidTokenException + * @throws OidcInvalidTokenException */ protected function validateTokenSignature(): void { if ($this->header['alg'] !== 'RS256') { - throw new InvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}"); + throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}"); } $parsedKeys = array_map(function($key) { try { - return new JwtSigningKey($key); - } catch (InvalidKeyException $e) { + return new OidcJwtSigningKey($key); + } catch (OidcInvalidKeyException $e) { return null; } }, $this->keys); @@ -141,27 +141,27 @@ class OpenIdConnectIdToken $parsedKeys = array_filter($parsedKeys); $contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1]; - /** @var JwtSigningKey $parsedKey */ + /** @var OidcJwtSigningKey $parsedKey */ foreach ($parsedKeys as $parsedKey) { if ($parsedKey->verify($contentToSign, $this->signature)) { return; } } - throw new InvalidTokenException('Token signature could not be validated using the provided keys'); + throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys'); } /** * Validate the claims of the token. * As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation - * @throws InvalidTokenException + * @throws OidcInvalidTokenException */ protected function validateTokenClaims(string $clientId): void { // 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) // MUST exactly match the value of the iss (issuer) Claim. if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) { - throw new InvalidTokenException('Missing or non-matching token issuer value'); + throw new OidcInvalidTokenException('Missing or non-matching token issuer value'); } // 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered @@ -169,16 +169,16 @@ class OpenIdConnectIdToken // if the ID Token does not list the Client as a valid audience, or if it contains additional // audiences not trusted by the Client. if (empty($this->payload['aud'])) { - throw new InvalidTokenException('Missing token audience value'); + throw new OidcInvalidTokenException('Missing token audience value'); } $aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud']; if (count($aud) !== 1) { - throw new InvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1'); + throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1'); } if ($aud[0] !== $clientId) { - throw new InvalidTokenException('Token audience value did not match the expected client_id'); + throw new OidcInvalidTokenException('Token audience value did not match the expected client_id'); } // 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present. @@ -187,32 +187,32 @@ class OpenIdConnectIdToken // 4. If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id // is the Claim Value. if (isset($this->payload['azp']) && $this->payload['azp'] !== $clientId) { - throw new InvalidTokenException('Token authorized party exists but does not match the expected client_id'); + throw new OidcInvalidTokenException('Token authorized party exists but does not match the expected client_id'); } // 5. The current time MUST be before the time represented by the exp Claim // (possibly allowing for some small leeway to account for clock skew). if (empty($this->payload['exp'])) { - throw new InvalidTokenException('Missing token expiration time value'); + throw new OidcInvalidTokenException('Missing token expiration time value'); } $skewSeconds = 120; $now = time(); if ($now >= (intval($this->payload['exp']) + $skewSeconds)) { - throw new InvalidTokenException('Token has expired'); + throw new OidcInvalidTokenException('Token has expired'); } // 6. The iat Claim can be used to reject tokens that were issued too far away from the current time, // limiting the amount of time that nonces need to be stored to prevent attacks. // The acceptable range is Client specific. if (empty($this->payload['iat'])) { - throw new InvalidTokenException('Missing token issued at time value'); + throw new OidcInvalidTokenException('Missing token issued at time value'); } $dayAgo = time() - 86400; $iat = intval($this->payload['iat']); if ($iat > ($now + $skewSeconds) || $iat < $dayAgo) { - throw new InvalidTokenException('Token issue at time is not recent or is invalid'); + throw new OidcInvalidTokenException('Token issue at time is not recent or is invalid'); } // 7. If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate. @@ -225,7 +225,7 @@ class OpenIdConnectIdToken // Custom: Ensure the "sub" (Subject) Claim exists and has a value. if (empty($this->payload['sub'])) { - throw new InvalidTokenException('Missing token subject value'); + throw new OidcInvalidTokenException('Missing token subject value'); } } diff --git a/app/Auth/Access/Oidc/OidcInvalidKeyException.php b/app/Auth/Access/Oidc/OidcInvalidKeyException.php new file mode 100644 index 000000000..17c32f416 --- /dev/null +++ b/app/Auth/Access/Oidc/OidcInvalidKeyException.php @@ -0,0 +1,8 @@ + 'RSA', 'alg' => 'RS256', 'n' => 'abc123...'] * @param array|string $jwkOrKeyPath - * @throws InvalidKeyException + * @throws OidcInvalidKeyException */ public function __construct($jwkOrKeyPath) { @@ -29,12 +29,12 @@ class JwtSigningKey } else if (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) { $this->loadFromPath($jwkOrKeyPath); } else { - throw new InvalidKeyException('Unexpected type of key value provided'); + throw new OidcInvalidKeyException('Unexpected type of key value provided'); } } /** - * @throws InvalidKeyException + * @throws OidcInvalidKeyException */ protected function loadFromPath(string $path) { @@ -43,37 +43,37 @@ class JwtSigningKey file_get_contents($path) )->withPadding(RSA::SIGNATURE_PKCS1); } catch (\Exception $exception) { - throw new InvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}"); + throw new OidcInvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}"); } if (!($this->key instanceof RSA)) { - throw new InvalidKeyException("Key loaded from file path is not an RSA key as expected"); + throw new OidcInvalidKeyException("Key loaded from file path is not an RSA key as expected"); } } /** - * @throws InvalidKeyException + * @throws OidcInvalidKeyException */ protected function loadFromJwkArray(array $jwk) { if ($jwk['alg'] !== 'RS256') { - throw new InvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}"); + throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}"); } if (empty($jwk['use'])) { - throw new InvalidKeyException('A "use" parameter on the provided key is expected'); + throw new OidcInvalidKeyException('A "use" parameter on the provided key is expected'); } if ($jwk['use'] !== 'sig') { - throw new InvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}"); + throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}"); } if (empty($jwk['e'])) { - throw new InvalidKeyException('An "e" parameter on the provided key is expected'); + throw new OidcInvalidKeyException('An "e" parameter on the provided key is expected'); } if (empty($jwk['n'])) { - throw new InvalidKeyException('A "n" parameter on the provided key is expected'); + throw new OidcInvalidKeyException('A "n" parameter on the provided key is expected'); } $n = strtr($jwk['n'] ?? '', '-_', '+/'); @@ -85,7 +85,7 @@ class JwtSigningKey 'n' => new BigInteger(base64_decode($n), 256), ])->withPadding(RSA::SIGNATURE_PKCS1); } catch (\Exception $exception) { - throw new InvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}"); + throw new OidcInvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}"); } } diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectOAuthProvider.php b/app/Auth/Access/Oidc/OidcOAuthProvider.php similarity index 94% rename from app/Auth/Access/OpenIdConnect/OpenIdConnectOAuthProvider.php rename to app/Auth/Access/Oidc/OidcOAuthProvider.php index 074f463cc..03230e373 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectOAuthProvider.php +++ b/app/Auth/Access/Oidc/OidcOAuthProvider.php @@ -1,6 +1,6 @@ applySettingsFromArray($discoveredSettings); } catch (ClientExceptionInterface $exception) { - throw new IssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}"); + throw new OidcIssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}"); } } /** - * @throws IssuerDiscoveryException + * @throws OidcIssuerDiscoveryException * @throws ClientExceptionInterface */ protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array @@ -130,11 +130,11 @@ class OpenIdConnectProviderSettings $result = json_decode($response->getBody()->getContents(), true); if (empty($result) || !is_array($result)) { - throw new IssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}"); + throw new OidcIssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}"); } if ($result['issuer'] !== $this->issuer) { - throw new IssuerDiscoveryException("Unexpected issuer value found on discovery response"); + throw new OidcIssuerDiscoveryException("Unexpected issuer value found on discovery response"); } $discoveredSettings = []; @@ -168,7 +168,7 @@ class OpenIdConnectProviderSettings /** * Return an array of jwks as PHP key=>value arrays. * @throws ClientExceptionInterface - * @throws IssuerDiscoveryException + * @throws OidcIssuerDiscoveryException */ protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array { @@ -177,7 +177,7 @@ class OpenIdConnectProviderSettings $result = json_decode($response->getBody()->getContents(), true); if (empty($result) || !is_array($result) || !isset($result['keys'])) { - throw new IssuerDiscoveryException("Error reading keys from issuer jwks_uri"); + throw new OidcIssuerDiscoveryException("Error reading keys from issuer jwks_uri"); } return $result['keys']; diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php b/app/Auth/Access/Oidc/OidcService.php similarity index 86% rename from app/Auth/Access/OpenIdConnect/OpenIdConnectService.php rename to app/Auth/Access/Oidc/OidcService.php index 57c9d1238..be6a5c3c4 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php +++ b/app/Auth/Access/Oidc/OidcService.php @@ -1,4 +1,4 @@ - $this->config['issuer'], 'clientId' => $this->config['client_id'], 'clientSecret' => $this->config['client_secret'], @@ -104,15 +104,15 @@ class OpenIdConnectService /** * Load the underlying OpenID Connect Provider. */ - protected function getProvider(OpenIdConnectProviderSettings $settings): OpenIdConnectOAuthProvider + protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider { - return new OpenIdConnectOAuthProvider($settings->arrayForProvider()); + return new OidcOAuthProvider($settings->arrayForProvider()); } /** * Calculate the display name */ - protected function getUserDisplayName(OpenIdConnectIdToken $token, string $defaultValue): string + protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string { $displayNameAttr = $this->config['display_name_claims']; @@ -135,7 +135,7 @@ class OpenIdConnectService * Extract the details of a user from an ID token. * @return array{name: string, email: string, external_id: string} */ - protected function getUserDetails(OpenIdConnectIdToken $token): array + protected function getUserDetails(OidcIdToken $token): array { $id = $token->getClaim('sub'); return [ @@ -153,10 +153,10 @@ class OpenIdConnectService * @throws UserRegistrationException * @throws StoppedAuthenticationException */ - protected function processAccessTokenCallback(OpenIdConnectAccessToken $accessToken, OpenIdConnectProviderSettings $settings): User + protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User { $idTokenText = $accessToken->getIdToken(); - $idToken = new OpenIdConnectIdToken( + $idToken = new OidcIdToken( $idTokenText, $settings->issuer, $settings->keys, @@ -168,7 +168,7 @@ class OpenIdConnectService try { $idToken->validate($settings->clientId); - } catch (InvalidTokenException $exception) { + } catch (OidcInvalidTokenException $exception) { throw new OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}"); } diff --git a/app/Auth/Access/OpenIdConnect/InvalidKeyException.php b/app/Auth/Access/OpenIdConnect/InvalidKeyException.php deleted file mode 100644 index 85746cb6a..000000000 --- a/app/Auth/Access/OpenIdConnect/InvalidKeyException.php +++ /dev/null @@ -1,8 +0,0 @@ -oidcService = $oidcService; $this->middleware('guard:oidc'); diff --git a/tests/Unit/OpenIdConnectIdTokenTest.php b/tests/Unit/OidcIdTokenTest.php similarity index 86% rename from tests/Unit/OpenIdConnectIdTokenTest.php rename to tests/Unit/OidcIdTokenTest.php index d3394daf6..abc811f75 100644 --- a/tests/Unit/OpenIdConnectIdTokenTest.php +++ b/tests/Unit/OidcIdTokenTest.php @@ -2,16 +2,16 @@ namespace Tests\Unit; -use BookStack\Auth\Access\OpenIdConnect\InvalidTokenException; -use BookStack\Auth\Access\OpenIdConnect\OpenIdConnectIdToken; +use BookStack\Auth\Access\Oidc\OidcInvalidTokenException; +use BookStack\Auth\Access\Oidc\OidcIdToken; use phpseclib3\Crypt\RSA; use Tests\TestCase; -class OpenIdConnectIdTokenTest extends TestCase +class OidcIdTokenTest extends TestCase { public function test_valid_token_passes_validation() { - $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', [ + $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', [ $this->jwkKeyArray() ]); @@ -20,20 +20,20 @@ class OpenIdConnectIdTokenTest extends TestCase public function test_get_claim_returns_value_if_existing() { - $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', []); + $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', []); $this->assertEquals('bscott@example.com', $token->getClaim('email')); } public function test_get_claim_returns_null_if_not_existing() { - $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', []); + $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', []); $this->assertEquals(null, $token->getClaim('emails')); } public function test_get_all_claims_returns_all_payload_claims() { $defaultPayload = $this->getDefaultPayload(); - $token = new OpenIdConnectIdToken($this->idToken($defaultPayload), 'https://auth.example.com', []); + $token = new OidcIdToken($this->idToken($defaultPayload), 'https://auth.example.com', []); $this->assertEquals($defaultPayload, $token->getAllClaims()); } @@ -52,7 +52,7 @@ class OpenIdConnectIdTokenTest extends TestCase ]; foreach ($messagesAndTokenValues as [$message, $tokenValue]) { - $token = new OpenIdConnectIdToken($tokenValue, 'https://auth.example.com', []); + $token = new OidcIdToken($tokenValue, 'https://auth.example.com', []); $err = null; try { $token->validate('abc'); @@ -60,43 +60,43 @@ class OpenIdConnectIdTokenTest extends TestCase $err = $exception; } - $this->assertInstanceOf(InvalidTokenException::class, $err, $message); + $this->assertInstanceOf(OidcInvalidTokenException::class, $err, $message); $this->assertEquals($message, $err->getMessage()); } } public function test_error_thrown_if_token_signature_not_validated_from_no_keys() { - $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', []); - $this->expectException(InvalidTokenException::class); + $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', []); + $this->expectException(OidcInvalidTokenException::class); $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); $token->validate('abc'); } public function test_error_thrown_if_token_signature_not_validated_from_non_matching_key() { - $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', [ + $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', [ array_merge($this->jwkKeyArray(), [ 'n' => 'iqK-1QkICMf_cusNLpeNnN-bhT0-9WLBvzgwKLALRbrevhdi5ttrLHIQshaSL0DklzfyG2HWRmAnJ9Q7sweEjuRiiqRcSUZbYu8cIv2hLWYu7K_NH67D2WUjl0EnoHEuiVLsZhQe1CmdyLdx087j5nWkd64K49kXRSdxFQUlj8W3NeK3CjMEUdRQ3H4RZzJ4b7uuMiFA29S2ZhMNG20NPbkUVsFL-jiwTd10KSsPT8yBYipI9O7mWsUWt_8KZs1y_vpM_k3SyYihnWpssdzDm1uOZ8U3mzFr1xsLAO718GNUSXk6npSDzLl59HEqa6zs4O9awO2qnSHvcmyELNk31w' ]) ]); - $this->expectException(InvalidTokenException::class); + $this->expectException(OidcInvalidTokenException::class); $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); $token->validate('abc'); } public function test_error_thrown_if_token_signature_not_validated_from_invalid_key() { - $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', ['url://example.com']); - $this->expectException(InvalidTokenException::class); + $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', ['url://example.com']); + $this->expectException(OidcInvalidTokenException::class); $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); $token->validate('abc'); } public function test_error_thrown_if_token_algorithm_is_not_rs256() { - $token = new OpenIdConnectIdToken($this->idToken([], ['alg' => 'HS256']), 'https://auth.example.com', []); - $this->expectException(InvalidTokenException::class); + $token = new OidcIdToken($this->idToken([], ['alg' => 'HS256']), 'https://auth.example.com', []); + $this->expectException(OidcInvalidTokenException::class); $this->expectExceptionMessage("Only RS256 signature validation is supported. Token reports using HS256"); $token->validate('abc'); } @@ -133,7 +133,7 @@ class OpenIdConnectIdTokenTest extends TestCase ]; foreach ($claimOverridesByErrorMessage as [$message, $overrides]) { - $token = new OpenIdConnectIdToken($this->idToken($overrides), 'https://auth.example.com', [ + $token = new OidcIdToken($this->idToken($overrides), 'https://auth.example.com', [ $this->jwkKeyArray() ]); @@ -144,7 +144,7 @@ class OpenIdConnectIdTokenTest extends TestCase $err = $exception; } - $this->assertInstanceOf(InvalidTokenException::class, $err, $message); + $this->assertInstanceOf(OidcInvalidTokenException::class, $err, $message); $this->assertEquals($message, $err->getMessage()); } } @@ -154,7 +154,7 @@ class OpenIdConnectIdTokenTest extends TestCase $file = tmpfile(); $testFilePath = 'file://' . stream_get_meta_data($file)['uri']; file_put_contents($testFilePath, $this->pemKey()); - $token = new OpenIdConnectIdToken($this->idToken(), 'https://auth.example.com', [ + $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', [ $testFilePath ]); From a5d72aa45840621c7bc7a00b2335f1bbea6c5c96 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 13 Oct 2021 16:51:27 +0100 Subject: [PATCH 23/24] Fleshed out testing for OIDC system --- app/Auth/Access/Oidc/OidcIdToken.php | 2 +- app/Auth/Access/Oidc/OidcService.php | 49 ++- ...nnectController.php => OidcController.php} | 13 +- app/Providers/AppServiceProvider.php | 6 + routes/web.php | 4 +- tests/Auth/AuthTest.php | 4 +- tests/Auth/OidcTest.php | 381 ++++++++++++++++++ tests/Auth/OpenIdTest.php | 112 ----- tests/Helpers/OidcJwtHelper.php | 132 ++++++ tests/SharedTestHelpers.php | 30 ++ tests/Unit/OidcIdTokenTest.php | 150 +------ 11 files changed, 609 insertions(+), 274 deletions(-) rename app/Http/Controllers/Auth/{OpenIdConnectController.php => OidcController.php} (73%) create mode 100644 tests/Auth/OidcTest.php delete mode 100644 tests/Auth/OpenIdTest.php create mode 100644 tests/Helpers/OidcJwtHelper.php diff --git a/app/Auth/Access/Oidc/OidcIdToken.php b/app/Auth/Access/Oidc/OidcIdToken.php index aa68e29a9..de9c42ab2 100644 --- a/app/Auth/Access/Oidc/OidcIdToken.php +++ b/app/Auth/Access/Oidc/OidcIdToken.php @@ -134,7 +134,7 @@ class OidcIdToken try { return new OidcJwtSigningKey($key); } catch (OidcInvalidKeyException $e) { - return null; + throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage()); } }, $this->keys); diff --git a/app/Auth/Access/Oidc/OidcService.php b/app/Auth/Access/Oidc/OidcService.php index be6a5c3c4..d59d274e8 100644 --- a/app/Auth/Access/Oidc/OidcService.php +++ b/app/Auth/Access/Oidc/OidcService.php @@ -8,9 +8,10 @@ use BookStack\Exceptions\OpenIdConnectException; use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\UserRegistrationException; use Exception; -use GuzzleHttp\Client; use Illuminate\Support\Facades\Cache; +use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider; use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Client\ClientInterface as HttpClient; use function auth; use function config; use function trans; @@ -24,16 +25,16 @@ class OidcService { protected $registrationService; protected $loginService; - protected $config; + protected $httpClient; /** * OpenIdService constructor. */ - public function __construct(RegistrationService $registrationService, LoginService $loginService) + public function __construct(RegistrationService $registrationService, LoginService $loginService, HttpClient $httpClient) { - $this->config = config('oidc'); $this->registrationService = $registrationService; $this->loginService = $loginService; + $this->httpClient = $httpClient; } /** @@ -77,23 +78,24 @@ class OidcService */ protected function getProviderSettings(): OidcProviderSettings { + $config = $this->config(); $settings = new OidcProviderSettings([ - 'issuer' => $this->config['issuer'], - 'clientId' => $this->config['client_id'], - 'clientSecret' => $this->config['client_secret'], - 'redirectUri' => url('/oidc/redirect'), - 'authorizationEndpoint' => $this->config['authorization_endpoint'], - 'tokenEndpoint' => $this->config['token_endpoint'], + 'issuer' => $config['issuer'], + 'clientId' => $config['client_id'], + 'clientSecret' => $config['client_secret'], + 'redirectUri' => url('/oidc/callback'), + 'authorizationEndpoint' => $config['authorization_endpoint'], + 'tokenEndpoint' => $config['token_endpoint'], ]); // Use keys if configured - if (!empty($this->config['jwt_public_key'])) { - $settings->keys = [$this->config['jwt_public_key']]; + if (!empty($config['jwt_public_key'])) { + $settings->keys = [$config['jwt_public_key']]; } // Run discovery - if ($this->config['discover'] ?? false) { - $settings->discoverFromIssuer(new Client(['timeout' => 3]), Cache::store(null), 15); + if ($config['discover'] ?? false) { + $settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15); } $settings->validate(); @@ -106,7 +108,10 @@ class OidcService */ protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider { - return new OidcOAuthProvider($settings->arrayForProvider()); + return new OidcOAuthProvider($settings->arrayForProvider(), [ + 'httpClient' => $this->httpClient, + 'optionProvider' => new HttpBasicAuthOptionProvider(), + ]); } /** @@ -114,7 +119,7 @@ class OidcService */ protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string { - $displayNameAttr = $this->config['display_name_claims']; + $displayNameAttr = $this->config()['display_name_claims']; $displayName = []; foreach ($displayNameAttr as $dnAttr) { @@ -162,7 +167,7 @@ class OidcService $settings->keys, ); - if ($this->config['dump_user_details']) { + if ($this->config()['dump_user_details']) { throw new JsonDebugException($idToken->getAllClaims()); } @@ -175,7 +180,7 @@ class OidcService $userDetails = $this->getUserDetails($idToken); $isLoggedIn = auth()->check(); - if ($userDetails['email'] === null) { + if (empty($userDetails['email'])) { throw new OpenIdConnectException(trans('errors.oidc_no_email_address')); } @@ -194,4 +199,12 @@ class OidcService $this->loginService->login($user, 'oidc'); return $user; } + + /** + * Get the OIDC config from the application. + */ + protected function config(): array + { + return config('oidc'); + } } diff --git a/app/Http/Controllers/Auth/OpenIdConnectController.php b/app/Http/Controllers/Auth/OidcController.php similarity index 73% rename from app/Http/Controllers/Auth/OpenIdConnectController.php rename to app/Http/Controllers/Auth/OidcController.php index 03638847b..f4103cb0a 100644 --- a/app/Http/Controllers/Auth/OpenIdConnectController.php +++ b/app/Http/Controllers/Auth/OidcController.php @@ -6,7 +6,7 @@ use BookStack\Auth\Access\Oidc\OidcService; use BookStack\Http\Controllers\Controller; use Illuminate\Http\Request; -class OpenIdConnectController extends Controller +class OidcController extends Controller { protected $oidcService; @@ -32,10 +32,10 @@ class OpenIdConnectController extends Controller } /** - * Authorization flow redirect. + * Authorization flow redirect callback. * Processes authorization response from the OIDC Authorization Server. */ - public function redirect(Request $request) + public function callback(Request $request) { $storedState = session()->pull('oidc_state'); $responseState = $request->query('state'); @@ -45,12 +45,7 @@ class OpenIdConnectController extends Controller return redirect('/login'); } - $user = $this->oidcService->processAuthorizeResponse($request->query('code')); - if ($user === null) { - $this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')])); - return redirect('/login'); - } - + $this->oidcService->processAuthorizeResponse($request->query('code')); return redirect()->intended(); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8334bb179..18e1fb627 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -12,6 +12,7 @@ use BookStack\Entities\Models\Page; use BookStack\Settings\Setting; use BookStack\Settings\SettingService; use BookStack\Util\CspService; +use GuzzleHttp\Client; use Illuminate\Contracts\Cache\Repository; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Blade; @@ -20,6 +21,7 @@ use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; use Laravel\Socialite\Contracts\Factory as SocialiteFactory; +use Psr\Http\Client\ClientInterface as HttpClientInterface; class AppServiceProvider extends ServiceProvider { @@ -76,5 +78,9 @@ class AppServiceProvider extends ServiceProvider $this->app->singleton(CspService::class, function ($app) { return new CspService(); }); + + $this->app->bind(HttpClientInterface::class, function($app) { + return new Client(['timeout' => 3]); + }); } } diff --git a/routes/web.php b/routes/web.php index 72e0392cc..254076451 100644 --- a/routes/web.php +++ b/routes/web.php @@ -268,8 +268,8 @@ Route::get('/saml2/sls', 'Auth\Saml2Controller@sls'); Route::post('/saml2/acs', 'Auth\Saml2Controller@acs'); // OIDC routes -Route::post('/oidc/login', 'Auth\OpenIdConnectController@login'); -Route::get('/oidc/redirect', 'Auth\OpenIdConnectController@redirect'); +Route::post('/oidc/login', 'Auth\OidcController@login'); +Route::get('/oidc/callback', 'Auth\OidcController@callback'); // User invitation routes Route::get('/register/invite/{token}', 'Auth\UserInviteController@showSetPassword'); diff --git a/tests/Auth/AuthTest.php b/tests/Auth/AuthTest.php index 1ffcc0815..d83f25557 100644 --- a/tests/Auth/AuthTest.php +++ b/tests/Auth/AuthTest.php @@ -318,7 +318,7 @@ class AuthTest extends TestCase $this->assertTrue(auth()->check()); $this->assertTrue(auth('ldap')->check()); $this->assertTrue(auth('saml2')->check()); - $this->assertTrue(auth('openid')->check()); + $this->assertTrue(auth('oidc')->check()); } public function test_login_authenticates_nonadmins_on_default_guard_only() @@ -331,7 +331,7 @@ class AuthTest extends TestCase $this->assertTrue(auth()->check()); $this->assertFalse(auth('ldap')->check()); $this->assertFalse(auth('saml2')->check()); - $this->assertFalse(auth('openid')->check()); + $this->assertFalse(auth('oidc')->check()); } public function test_failed_logins_are_logged_when_message_configured() diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php new file mode 100644 index 000000000..cf04080fc --- /dev/null +++ b/tests/Auth/OidcTest.php @@ -0,0 +1,381 @@ +keyFile = tmpfile(); + $this->keyFilePath = 'file://' . stream_get_meta_data($this->keyFile)['uri']; + file_put_contents($this->keyFilePath, OidcJwtHelper::publicPemKey()); + + config()->set([ + 'auth.method' => 'oidc', + 'auth.defaults.guard' => 'oidc', + 'oidc.name' => 'SingleSignOn-Testing', + 'oidc.display_name_claims' => ['name'], + 'oidc.client_id' => OidcJwtHelper::defaultClientId(), + 'oidc.client_secret' => 'testpass', + 'oidc.jwt_public_key' => $this->keyFilePath, + 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), + 'oidc.authorization_endpoint' => 'https://oidc.local/auth', + 'oidc.token_endpoint' => 'https://oidc.local/token', + 'oidc.discover' => false, + 'oidc.dump_user_details' => false, + ]); + } + + public function tearDown(): void + { + parent::tearDown(); + if (file_exists($this->keyFilePath)) { + unlink($this->keyFilePath); + } + } + + public function test_login_option_shows_on_login_page() + { + $req = $this->get('/login'); + $req->assertSeeText('SingleSignOn-Testing'); + $req->assertElementExists('form[action$="/oidc/login"][method=POST] button'); + } + + public function test_oidc_routes_are_only_active_if_oidc_enabled() + { + config()->set(['auth.method' => 'standard']); + $routes = ['/login' => 'post', '/callback' => 'get']; + foreach ($routes as $uri => $method) { + $req = $this->call($method, '/oidc' . $uri); + $this->assertPermissionError($req); + } + } + + public function test_forgot_password_routes_inaccessible() + { + $resp = $this->get('/password/email'); + $this->assertPermissionError($resp); + + $resp = $this->post('/password/email'); + $this->assertPermissionError($resp); + + $resp = $this->get('/password/reset/abc123'); + $this->assertPermissionError($resp); + + $resp = $this->post('/password/reset'); + $this->assertPermissionError($resp); + } + + public function test_standard_login_routes_inaccessible() + { + $resp = $this->post('/login'); + $this->assertPermissionError($resp); + } + + public function test_logout_route_functions() + { + $this->actingAs($this->getEditor()); + $this->get('/logout'); + $this->assertFalse(auth()->check()); + } + + public function test_user_invite_routes_inaccessible() + { + $resp = $this->get('/register/invite/abc123'); + $this->assertPermissionError($resp); + + $resp = $this->post('/register/invite/abc123'); + $this->assertPermissionError($resp); + } + + public function test_user_register_routes_inaccessible() + { + $resp = $this->get('/register'); + $this->assertPermissionError($resp); + + $resp = $this->post('/register'); + $this->assertPermissionError($resp); + } + + public function test_login() + { + $req = $this->post('/oidc/login'); + $redirect = $req->headers->get('location'); + + $this->assertStringStartsWith('https://oidc.local/auth', $redirect, 'Login redirects to SSO location'); + $this->assertFalse($this->isAuthenticated()); + $this->assertStringContainsString('scope=openid%20profile%20email', $redirect); + $this->assertStringContainsString('client_id=' . OidcJwtHelper::defaultClientId(), $redirect); + $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $redirect); + } + + public function test_login_success_flow() + { + // Start auth + $this->post('/oidc/login'); + $state = session()->get('oidc_state'); + + $transactions = &$this->mockHttpClient([$this->getMockAuthorizationResponse([ + 'email' => 'benny@example.com', + 'sub' => 'benny1010101' + ])]); + + // Callback from auth provider + // App calls token endpoint to get id token + $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); + $resp->assertRedirect('/'); + $this->assertCount(1, $transactions); + /** @var Request $tokenRequest */ + $tokenRequest = $transactions[0]['request']; + $this->assertEquals('https://oidc.local/token', (string) $tokenRequest->getUri()); + $this->assertEquals('POST', $tokenRequest->getMethod()); + $this->assertEquals('Basic ' . base64_encode(OidcJwtHelper::defaultClientId() . ':testpass'), $tokenRequest->getHeader('Authorization')[0]); + $this->assertStringContainsString('grant_type=authorization_code', $tokenRequest->getBody()); + $this->assertStringContainsString('code=SplxlOBeZQQYbYS6WxSbIA', $tokenRequest->getBody()); + $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $tokenRequest->getBody()); + + + $this->assertTrue(auth()->check()); + $this->assertDatabaseHas('users', [ + 'email' => 'benny@example.com', + 'external_auth_id' => 'benny1010101', + 'email_confirmed' => false, + ]); + + $user = User::query()->where('email', '=', 'benny@example.com')->first(); + $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott"); + } + + public function test_callback_fails_if_no_state_present_or_matching() + { + $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124'); + $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); + + $this->post('/oidc/login'); + $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124'); + $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); + } + + public function test_dump_user_details_option_outputs_as_expected() + { + config()->set('oidc.dump_user_details', true); + + $resp = $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benny505' + ]); + + $resp->assertStatus(200); + $resp->assertJson([ + 'email' => 'benny@example.com', + 'sub' => 'benny505', + "iss" => OidcJwtHelper::defaultIssuer(), + "aud" => OidcJwtHelper::defaultClientId(), + ]); + $this->assertFalse(auth()->check()); + } + + public function test_auth_fails_if_no_email_exists_in_user_data() + { + $this->runLogin([ + 'email' => '', + 'sub' => 'benny505' + ]); + + $this->assertSessionError('Could not find an email address, for this user, in the data provided by the external authentication system'); + } + + public function test_auth_fails_if_already_logged_in() + { + $this->asEditor(); + + $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benny505' + ]); + + $this->assertSessionError('Already logged in'); + } + + public function test_auth_login_as_existing_user() + { + $editor = $this->getEditor(); + $editor->external_auth_id = 'benny505'; + $editor->save(); + + $this->assertFalse(auth()->check()); + + $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benny505' + ]); + + $this->assertTrue(auth()->check()); + $this->assertEquals($editor->id, auth()->user()->id); + } + + public function test_auth_login_as_existing_user_email_with_different_auth_id_fails() + { + $editor = $this->getEditor(); + $editor->external_auth_id = 'editor101'; + $editor->save(); + + $this->assertFalse(auth()->check()); + + $this->runLogin([ + 'email' => $editor->email, + 'sub' => 'benny505' + ]); + + $this->assertSessionError('A user with the email ' . $editor->email . ' already exists but with different credentials.'); + $this->assertFalse(auth()->check()); + } + + public function test_auth_login_with_invalid_token_fails() + { + $this->runLogin([ + 'sub' => null, + ]); + + $this->assertSessionError('ID token validate failed with error: Missing token subject value'); + $this->assertFalse(auth()->check()); + } + + public function test_auth_login_with_autodiscovery() + { + $this->withAutodiscovery(); + + $transactions = &$this->mockHttpClient([ + $this->getAutoDiscoveryResponse(), + $this->getJwksResponse(), + ]); + + $this->assertFalse(auth()->check()); + + $this->runLogin(); + + $this->assertTrue(auth()->check()); + /** @var Request $discoverRequest */ + $discoverRequest = $transactions[0]['request']; + /** @var Request $discoverRequest */ + $keysRequest = $transactions[1]['request']; + + $this->assertEquals('GET', $keysRequest->getMethod()); + $this->assertEquals('GET', $discoverRequest->getMethod()); + $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/.well-known/openid-configuration', $discoverRequest->getUri()); + $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/oidc/keys', $keysRequest->getUri()); + } + + public function test_auth_fails_if_autodiscovery_fails() + { + $this->withAutodiscovery(); + $this->mockHttpClient([ + new Response(404, [], 'Not found'), + ]); + + $this->runLogin(); + $this->assertFalse(auth()->check()); + $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); + } + + public function test_autodiscovery_calls_are_cached() + { + $this->withAutodiscovery(); + + $transactions = &$this->mockHttpClient([ + $this->getAutoDiscoveryResponse(), + $this->getJwksResponse(), + $this->getAutoDiscoveryResponse([ + 'issuer' => 'https://auto.example.com' + ]), + $this->getJwksResponse(), + ]); + + // Initial run + $this->post('/oidc/login'); + $this->assertCount(2, $transactions); + // Second run, hits cache + $this->post('/oidc/login'); + $this->assertCount(2, $transactions); + + // Third run, different issuer, new cache key + config()->set(['oidc.issuer' => 'https://auto.example.com']); + $this->post('/oidc/login'); + $this->assertCount(4, $transactions); + } + + protected function withAutodiscovery() + { + config()->set([ + 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), + 'oidc.discover' => true, + 'oidc.authorization_endpoint' => null, + 'oidc.token_endpoint' => null, + 'oidc.jwt_public_key' => null, + ]); + } + + protected function runLogin($claimOverrides = []): TestResponse + { + $this->post('/oidc/login'); + $state = session()->get('oidc_state'); + $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]); + + return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); + } + + protected function getAutoDiscoveryResponse($responseOverrides = []): Response + { + return new Response(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache, no-store', + 'Pragma' => 'no-cache' + ], json_encode(array_merge([ + 'token_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/token', + 'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize', + 'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys', + 'issuer' => OidcJwtHelper::defaultIssuer() + ], $responseOverrides))); + } + + protected function getJwksResponse(): Response + { + return new Response(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache, no-store', + 'Pragma' => 'no-cache' + ], json_encode([ + 'keys' => [ + OidcJwtHelper::publicJwkKeyArray() + ] + ])); + } + + protected function getMockAuthorizationResponse($claimOverrides = []): Response + { + return new Response(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache, no-store', + 'Pragma' => 'no-cache' + ], json_encode([ + 'access_token' => 'abc123', + 'token_type' => 'Bearer', + 'expires_in' => 3600, + 'id_token' => OidcJwtHelper::idToken($claimOverrides) + ])); + } +} diff --git a/tests/Auth/OpenIdTest.php b/tests/Auth/OpenIdTest.php deleted file mode 100644 index 9ad90fb5b..000000000 --- a/tests/Auth/OpenIdTest.php +++ /dev/null @@ -1,112 +0,0 @@ -set([ - 'auth.method' => 'openid', - 'auth.defaults.guard' => 'openid', - 'openid.name' => 'SingleSignOn-Testing', - 'openid.email_attribute' => 'email', - 'openid.display_name_attributes' => ['given_name', 'family_name'], - 'openid.external_id_attribute' => 'uid', - 'openid.openid_overrides' => null, - 'openid.openid.clientId' => 'testapp', - 'openid.openid.clientSecret' => 'testpass', - 'openid.openid.publicKey' => $this->testCert, - 'openid.openid.idTokenIssuer' => 'https://openid.local', - 'openid.openid.urlAuthorize' => 'https://openid.local/auth', - 'openid.openid.urlAccessToken' => 'https://openid.local/token', - ]); - } - - public function test_openid_overrides_functions_as_expected() - { - $json = '{"urlAuthorize": "https://openid.local/custom"}'; - config()->set(['openid.openid_overrides' => $json]); - - $req = $this->get('/openid/login'); - $redirect = $req->headers->get('location'); - $this->assertStringStartsWith('https://openid.local/custom', $redirect, 'Login redirects to SSO location'); - } - - public function test_login_option_shows_on_login_page() - { - $req = $this->get('/login'); - $req->assertSeeText('SingleSignOn-Testing'); - $req->assertElementExists('form[action$="/openid/login"][method=POST] button'); - } - - public function test_login() - { - $req = $this->post('/openid/login'); - $redirect = $req->headers->get('location'); - - $this->assertStringStartsWith('https://openid.local/auth', $redirect, 'Login redirects to SSO location'); - $this->assertFalse($this->isAuthenticated()); - } - - public function test_openid_routes_are_only_active_if_openid_enabled() - { - config()->set(['auth.method' => 'standard']); - $getRoutes = ['/logout', '/metadata', '/sls']; - foreach ($getRoutes as $route) { - $req = $this->get('/openid' . $route); - $this->assertPermissionError($req); - } - - $postRoutes = ['/login', '/acs']; - foreach ($postRoutes as $route) { - $req = $this->post('/openid' . $route); - $this->assertPermissionError($req); - } - } - - public function test_forgot_password_routes_inaccessible() - { - $resp = $this->get('/password/email'); - $this->assertPermissionError($resp); - - $resp = $this->post('/password/email'); - $this->assertPermissionError($resp); - - $resp = $this->get('/password/reset/abc123'); - $this->assertPermissionError($resp); - - $resp = $this->post('/password/reset'); - $this->assertPermissionError($resp); - } - - public function test_standard_login_routes_inaccessible() - { - $resp = $this->post('/login'); - $this->assertPermissionError($resp); - - $resp = $this->get('/logout'); - $this->assertPermissionError($resp); - } - - public function test_user_invite_routes_inaccessible() - { - $resp = $this->get('/register/invite/abc123'); - $this->assertPermissionError($resp); - - $resp = $this->post('/register/invite/abc123'); - $this->assertPermissionError($resp); - } - - public function test_user_register_routes_inaccessible() - { - $resp = $this->get('/register'); - $this->assertPermissionError($resp); - - $resp = $this->post('/register'); - $this->assertPermissionError($resp); - } -} diff --git a/tests/Helpers/OidcJwtHelper.php b/tests/Helpers/OidcJwtHelper.php new file mode 100644 index 000000000..0c44efb01 --- /dev/null +++ b/tests/Helpers/OidcJwtHelper.php @@ -0,0 +1,132 @@ + "abc1234def", + "name" => "Barry Scott", + "email" => "bscott@example.com", + "ver" => 1, + "iss" => static::defaultIssuer(), + "aud" => static::defaultClientId(), + "iat" => time(), + "exp" => time() + 720, + "jti" => "ID.AaaBBBbbCCCcccDDddddddEEEeeeeee", + "amr" => ["pwd"], + "idp" => "fghfghgfh546456dfgdfg", + "preferred_username" => "xXBazzaXx", + "auth_time" => time(), + "at_hash" => "sT4jbsdSGy9w12pq3iNYDA", + ]; + } + + public static function idToken($payloadOverrides = [], $headerOverrides = []): string + { + $payload = array_merge(static::defaultPayload(), $payloadOverrides); + $header = array_merge([ + 'kid' => 'xyz456', + 'alg' => 'RS256', + ], $headerOverrides); + + $top = implode('.', [ + static::base64UrlEncode(json_encode($header)), + static::base64UrlEncode(json_encode($payload)), + ]); + + $privateKey = static::privateKeyInstance(); + $signature = $privateKey->sign($top); + return $top . '.' . static::base64UrlEncode($signature); + } + + public static function privateKeyInstance() + { + static $key; + if (is_null($key)) { + $key = RSA::loadPrivateKey(static::privatePemKey())->withPadding(RSA::SIGNATURE_PKCS1); + } + + return $key; + } + + public static function base64UrlEncode(string $decoded): string + { + return strtr(base64_encode($decoded), '+/', '-_'); + } + + public static function publicPemKey(): string + { + return "-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqo1OmfNKec5S2zQC4SP9 +DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvm +zXL16c93Obn7G8x8A3ao6yN5qKO5S5+CETqOZfKN/g75Xlz7VsC3igOhgsXnPx6i +iM6sbYbk0U/XpFaT84LXKI8VTIPUo7gTeZN1pTET//i9FlzAOzX+xfWBKdOqlEzl ++zihMHCZUUvQu99P+o0MDR0lMUT+vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNk +WvsLta1+jNUee+8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw +3wIDAQAB +-----END PUBLIC KEY-----"; + } + + public static function privatePemKey(): string + { + return "-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCqjU6Z80p5zlLb +NALhI/0Ose5RHRWAKLqipwYRHPvOo7fqGqTcDdHdoKAmQSN+ducy6zNFEqzjk1te +g6n2m+bNcvXpz3c5ufsbzHwDdqjrI3moo7lLn4IROo5l8o3+DvleXPtWwLeKA6GC +xec/HqKIzqxthuTRT9ekVpPzgtcojxVMg9SjuBN5k3WlMRP/+L0WXMA7Nf7F9YEp +06qUTOX7OKEwcJlRS9C730/6jQwNHSUxRP688npIl5F+egd7HC3ptkWI2exkgQvT +dtfhA2Ra+wu1rX6M1R577wg9WHMI7xu8zzo3MtopQniTo1nkhWuZ0IWkWyMJYHI6 +sMbzB3DfAgMBAAECggEADm7K2ghWoxwsstQh8j+DaLzx9/dIHIJV2PHdd5FGVeRQ +6gS7MswQmHrBUrtsb4VMZ2iz/AJqkw+jScpGldH3pCc4XELsSfxNHbseO4TNIqjr +4LOKOLYU4bRc3I+8KGXIAI5JzrucTJemEVUCDrte8cjbmqExt+zTyNpyxsapworF +v+vnSdv40d62f+cS1xvwB+ymLK/B/wZ/DemDCi8jsi7ou/M7l5xNCzjH4iMSLtOW +fgEhejIBG9miMJWPiVpTXE3tMdNuN3OsWc4XXm2t4VRovlZdu30Fax1xWB+Locsv +HlHKLOFc8g+jZh0TL2KCNjPffMcC7kHhW3afshpIsQKBgQDhyWUnkqd6FzbwIX70 +SnaMgKoUv5W/K5T+Sv/PA2CyN8Gu8ih/OsoNZSnI0uqe3XQIvvgN/Fq3wO1ttLzf +z5B6ZC7REfTgcR0190gihk6f5rtcj7d6Fy/oG2CE8sDSXgPnpEaBjvJVgN5v/U2s +HpVaidmHTyGLCfEszoeoy8jyrQKBgQDBX8caGylmzQLc6XNntZChlt3e18Nj8MPA +DxWLcoqgdDoofLDQAmLl+vPKyDmhQjos5eas1jgmVVEM4ge+MysaVezvuLBsSnOh +ihc0i63USU6i7YDE83DrCewCthpFHi/wW1S5FoCAzpVy8y99vwcqO4kOXcmf4O6Y +uW6sMsjvOwKBgQDbFtqB+MtsLCSSBF61W6AHHD5tna4H75lG2623yXZF2NanFLF5 +K6muL9DI3ujtOMQETJJUt9+rWJjLEEsJ/dYa/SV0l7D/LKOEnyuu3JZkkLaTzZzi +6qcA2bfhqdCzEKlHV99WjkfV8hNlpex9rLuOPB8JLh7FVONicBGxF/UojQKBgDXs +IlYaSuI6utilVKQP0kPtEPOKERc2VS+iRSy8hQGXR3xwwNFQSQm+f+sFCGT6VcSd +W0TI+6Fc2xwPj38vP465dTentbKM1E+wdSYW6SMwSfhO6ECDbfJsst5Sr2Kkt1N7 +9FUkfDLu6GfEfnK/KR1SurZB2u51R7NYyg7EnplvAoGAT0aTtOcck0oYN30g5mdf +efqXPwg2wAPYeiec49EbfnteQQKAkqNfJ9K69yE2naf6bw3/5mCBsq/cXeuaBMII +ylysUIRBqt2J0kWm2yCpFWR7H+Ilhdx9A7ZLCqYVt8e+vjO/BOI3cQDe2VPOLPSl +q/1PY4iJviGKddtmfClH3v4= +-----END PRIVATE KEY-----"; + } + + public static function publicJwkKeyArray(): array + { + return [ + 'kty' => 'RSA', + 'alg' => 'RS256', + 'kid' => '066e52af-8884-4926-801d-032a276f9f2a', + 'use' => 'sig', + 'e' => 'AQAB', + 'n' => 'qo1OmfNKec5S2zQC4SP9DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvmzXL16c93Obn7G8x8A3ao6yN5qKO5S5-CETqOZfKN_g75Xlz7VsC3igOhgsXnPx6iiM6sbYbk0U_XpFaT84LXKI8VTIPUo7gTeZN1pTET__i9FlzAOzX-xfWBKdOqlEzl-zihMHCZUUvQu99P-o0MDR0lMUT-vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNkWvsLta1-jNUee-8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw3w', + ]; + } +} \ No newline at end of file diff --git a/tests/SharedTestHelpers.php b/tests/SharedTestHelpers.php index e4d27c849..606a3cd9e 100644 --- a/tests/SharedTestHelpers.php +++ b/tests/SharedTestHelpers.php @@ -18,6 +18,10 @@ use BookStack\Entities\Repos\ChapterRepo; use BookStack\Entities\Repos\PageRepo; use BookStack\Settings\SettingService; use BookStack\Uploads\HttpFetcher; +use GuzzleHttp\Client; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; use Illuminate\Foundation\Testing\Assert as PHPUnit; use Illuminate\Http\JsonResponse; use Illuminate\Support\Env; @@ -25,6 +29,7 @@ use Illuminate\Support\Facades\Log; use Mockery; use Monolog\Handler\TestHandler; use Monolog\Logger; +use Psr\Http\Client\ClientInterface; trait SharedTestHelpers { @@ -244,6 +249,22 @@ trait SharedTestHelpers ->andReturn($returnData); } + /** + * Mock the http client used in BookStack. + * Returns a reference to the container which holds all history of http transactions. + * @link https://docs.guzzlephp.org/en/stable/testing.html#history-middleware + */ + protected function &mockHttpClient(array $responses = []): array + { + $container = []; + $history = Middleware::history($container); + $mock = new MockHandler($responses); + $handlerStack = new HandlerStack($mock); + $handlerStack->push($history); + $this->app[ClientInterface::class] = new Client(['handler' => $handlerStack]); + return $container; + } + /** * Run a set test with the given env variable. * Remembers the original and resets the value after test. @@ -323,6 +344,15 @@ trait SharedTestHelpers ); } + /** + * Assert that the session has a particular error notification message set. + */ + protected function assertSessionError(string $message) + { + $error = session()->get('error'); + PHPUnit::assertTrue($error === $message, "Failed asserting the session contains an error. \nFound: {$error}\nExpecting: {$message}"); + } + /** * Set a test handler as the logging interface for the application. * Allows capture of logs for checking against during tests. diff --git a/tests/Unit/OidcIdTokenTest.php b/tests/Unit/OidcIdTokenTest.php index abc811f75..b08d578b3 100644 --- a/tests/Unit/OidcIdTokenTest.php +++ b/tests/Unit/OidcIdTokenTest.php @@ -4,15 +4,15 @@ namespace Tests\Unit; use BookStack\Auth\Access\Oidc\OidcInvalidTokenException; use BookStack\Auth\Access\Oidc\OidcIdToken; -use phpseclib3\Crypt\RSA; +use Tests\Helpers\OidcJwtHelper; use Tests\TestCase; class OidcIdTokenTest extends TestCase { public function test_valid_token_passes_validation() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', [ - $this->jwkKeyArray() + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [ + OidcJwtHelper::publicJwkKeyArray() ]); $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123')); @@ -20,26 +20,26 @@ class OidcIdTokenTest extends TestCase public function test_get_claim_returns_value_if_existing() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', []); + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []); $this->assertEquals('bscott@example.com', $token->getClaim('email')); } public function test_get_claim_returns_null_if_not_existing() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', []); + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []); $this->assertEquals(null, $token->getClaim('emails')); } public function test_get_all_claims_returns_all_payload_claims() { - $defaultPayload = $this->getDefaultPayload(); - $token = new OidcIdToken($this->idToken($defaultPayload), 'https://auth.example.com', []); + $defaultPayload = OidcJwtHelper::defaultPayload(); + $token = new OidcIdToken(OidcJwtHelper::idToken($defaultPayload), OidcJwtHelper::defaultIssuer(), []); $this->assertEquals($defaultPayload, $token->getAllClaims()); } public function test_token_structure_error_cases() { - $idToken = $this->idToken(); + $idToken = OidcJwtHelper::idToken(); $idTokenExploded = explode('.', $idToken); $messagesAndTokenValues = [ @@ -52,7 +52,7 @@ class OidcIdTokenTest extends TestCase ]; foreach ($messagesAndTokenValues as [$message, $tokenValue]) { - $token = new OidcIdToken($tokenValue, 'https://auth.example.com', []); + $token = new OidcIdToken($tokenValue, OidcJwtHelper::defaultIssuer(), []); $err = null; try { $token->validate('abc'); @@ -67,7 +67,7 @@ class OidcIdTokenTest extends TestCase public function test_error_thrown_if_token_signature_not_validated_from_no_keys() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', []); + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []); $this->expectException(OidcInvalidTokenException::class); $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); $token->validate('abc'); @@ -75,8 +75,8 @@ class OidcIdTokenTest extends TestCase public function test_error_thrown_if_token_signature_not_validated_from_non_matching_key() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', [ - array_merge($this->jwkKeyArray(), [ + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [ + array_merge(OidcJwtHelper::publicJwkKeyArray(), [ 'n' => 'iqK-1QkICMf_cusNLpeNnN-bhT0-9WLBvzgwKLALRbrevhdi5ttrLHIQshaSL0DklzfyG2HWRmAnJ9Q7sweEjuRiiqRcSUZbYu8cIv2hLWYu7K_NH67D2WUjl0EnoHEuiVLsZhQe1CmdyLdx087j5nWkd64K49kXRSdxFQUlj8W3NeK3CjMEUdRQ3H4RZzJ4b7uuMiFA29S2ZhMNG20NPbkUVsFL-jiwTd10KSsPT8yBYipI9O7mWsUWt_8KZs1y_vpM_k3SyYihnWpssdzDm1uOZ8U3mzFr1xsLAO718GNUSXk6npSDzLl59HEqa6zs4O9awO2qnSHvcmyELNk31w' ]) ]); @@ -85,17 +85,17 @@ class OidcIdTokenTest extends TestCase $token->validate('abc'); } - public function test_error_thrown_if_token_signature_not_validated_from_invalid_key() + public function test_error_thrown_if_invalid_key_provided() { - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', ['url://example.com']); + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), ['url://example.com']); $this->expectException(OidcInvalidTokenException::class); - $this->expectExceptionMessage('Token signature could not be validated using the provided keys'); + $this->expectExceptionMessage('Unexpected type of key value provided'); $token->validate('abc'); } public function test_error_thrown_if_token_algorithm_is_not_rs256() { - $token = new OidcIdToken($this->idToken([], ['alg' => 'HS256']), 'https://auth.example.com', []); + $token = new OidcIdToken(OidcJwtHelper::idToken([], ['alg' => 'HS256']), OidcJwtHelper::defaultIssuer(), []); $this->expectException(OidcInvalidTokenException::class); $this->expectExceptionMessage("Only RS256 signature validation is supported. Token reports using HS256"); $token->validate('abc'); @@ -133,8 +133,8 @@ class OidcIdTokenTest extends TestCase ]; foreach ($claimOverridesByErrorMessage as [$message, $overrides]) { - $token = new OidcIdToken($this->idToken($overrides), 'https://auth.example.com', [ - $this->jwkKeyArray() + $token = new OidcIdToken(OidcJwtHelper::idToken($overrides), OidcJwtHelper::defaultIssuer(), [ + OidcJwtHelper::publicJwkKeyArray() ]); $err = null; @@ -153,122 +153,12 @@ class OidcIdTokenTest extends TestCase { $file = tmpfile(); $testFilePath = 'file://' . stream_get_meta_data($file)['uri']; - file_put_contents($testFilePath, $this->pemKey()); - $token = new OidcIdToken($this->idToken(), 'https://auth.example.com', [ + file_put_contents($testFilePath, OidcJwtHelper::publicPemKey()); + $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [ $testFilePath ]); $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123')); unlink($testFilePath); } - - protected function getDefaultPayload(): array - { - return [ - "sub" => "abc1234def", - "name" => "Barry Scott", - "email" => "bscott@example.com", - "ver" => 1, - "iss" => "https://auth.example.com", - "aud" => "xxyyzz.aaa.bbccdd.123", - "iat" => time(), - "exp" => time() + 720, - "jti" => "ID.AaaBBBbbCCCcccDDddddddEEEeeeeee", - "amr" => ["pwd"], - "idp" => "fghfghgfh546456dfgdfg", - "preferred_username" => "xXBazzaXx", - "auth_time" => time(), - "at_hash" => "sT4jbsdSGy9w12pq3iNYDA", - ]; - } - - protected function idToken($payloadOverrides = [], $headerOverrides = []): string - { - $payload = array_merge($this->getDefaultPayload(), $payloadOverrides); - $header = array_merge([ - 'kid' => 'xyz456', - 'alg' => 'RS256', - ], $headerOverrides); - - $top = implode('.', [ - $this->base64UrlEncode(json_encode($header)), - $this->base64UrlEncode(json_encode($payload)), - ]); - - $privateKey = $this->getPrivateKey(); - $signature = $privateKey->sign($top); - return $top . '.' . $this->base64UrlEncode($signature); - } - - protected function getPrivateKey() - { - static $key; - if (is_null($key)) { - $key = RSA::loadPrivateKey($this->privatePemKey())->withPadding(RSA::SIGNATURE_PKCS1); - } - - return $key; - } - - protected function base64UrlEncode(string $decoded): string - { - return strtr(base64_encode($decoded), '+/', '-_'); - } - - protected function pemKey(): string - { - return "-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqo1OmfNKec5S2zQC4SP9 -DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvm -zXL16c93Obn7G8x8A3ao6yN5qKO5S5+CETqOZfKN/g75Xlz7VsC3igOhgsXnPx6i -iM6sbYbk0U/XpFaT84LXKI8VTIPUo7gTeZN1pTET//i9FlzAOzX+xfWBKdOqlEzl -+zihMHCZUUvQu99P+o0MDR0lMUT+vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNk -WvsLta1+jNUee+8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw -3wIDAQAB ------END PUBLIC KEY-----"; - } - - protected function privatePemKey(): string - { - return "-----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCqjU6Z80p5zlLb -NALhI/0Ose5RHRWAKLqipwYRHPvOo7fqGqTcDdHdoKAmQSN+ducy6zNFEqzjk1te -g6n2m+bNcvXpz3c5ufsbzHwDdqjrI3moo7lLn4IROo5l8o3+DvleXPtWwLeKA6GC -xec/HqKIzqxthuTRT9ekVpPzgtcojxVMg9SjuBN5k3WlMRP/+L0WXMA7Nf7F9YEp -06qUTOX7OKEwcJlRS9C730/6jQwNHSUxRP688npIl5F+egd7HC3ptkWI2exkgQvT -dtfhA2Ra+wu1rX6M1R577wg9WHMI7xu8zzo3MtopQniTo1nkhWuZ0IWkWyMJYHI6 -sMbzB3DfAgMBAAECggEADm7K2ghWoxwsstQh8j+DaLzx9/dIHIJV2PHdd5FGVeRQ -6gS7MswQmHrBUrtsb4VMZ2iz/AJqkw+jScpGldH3pCc4XELsSfxNHbseO4TNIqjr -4LOKOLYU4bRc3I+8KGXIAI5JzrucTJemEVUCDrte8cjbmqExt+zTyNpyxsapworF -v+vnSdv40d62f+cS1xvwB+ymLK/B/wZ/DemDCi8jsi7ou/M7l5xNCzjH4iMSLtOW -fgEhejIBG9miMJWPiVpTXE3tMdNuN3OsWc4XXm2t4VRovlZdu30Fax1xWB+Locsv -HlHKLOFc8g+jZh0TL2KCNjPffMcC7kHhW3afshpIsQKBgQDhyWUnkqd6FzbwIX70 -SnaMgKoUv5W/K5T+Sv/PA2CyN8Gu8ih/OsoNZSnI0uqe3XQIvvgN/Fq3wO1ttLzf -z5B6ZC7REfTgcR0190gihk6f5rtcj7d6Fy/oG2CE8sDSXgPnpEaBjvJVgN5v/U2s -HpVaidmHTyGLCfEszoeoy8jyrQKBgQDBX8caGylmzQLc6XNntZChlt3e18Nj8MPA -DxWLcoqgdDoofLDQAmLl+vPKyDmhQjos5eas1jgmVVEM4ge+MysaVezvuLBsSnOh -ihc0i63USU6i7YDE83DrCewCthpFHi/wW1S5FoCAzpVy8y99vwcqO4kOXcmf4O6Y -uW6sMsjvOwKBgQDbFtqB+MtsLCSSBF61W6AHHD5tna4H75lG2623yXZF2NanFLF5 -K6muL9DI3ujtOMQETJJUt9+rWJjLEEsJ/dYa/SV0l7D/LKOEnyuu3JZkkLaTzZzi -6qcA2bfhqdCzEKlHV99WjkfV8hNlpex9rLuOPB8JLh7FVONicBGxF/UojQKBgDXs -IlYaSuI6utilVKQP0kPtEPOKERc2VS+iRSy8hQGXR3xwwNFQSQm+f+sFCGT6VcSd -W0TI+6Fc2xwPj38vP465dTentbKM1E+wdSYW6SMwSfhO6ECDbfJsst5Sr2Kkt1N7 -9FUkfDLu6GfEfnK/KR1SurZB2u51R7NYyg7EnplvAoGAT0aTtOcck0oYN30g5mdf -efqXPwg2wAPYeiec49EbfnteQQKAkqNfJ9K69yE2naf6bw3/5mCBsq/cXeuaBMII -ylysUIRBqt2J0kWm2yCpFWR7H+Ilhdx9A7ZLCqYVt8e+vjO/BOI3cQDe2VPOLPSl -q/1PY4iJviGKddtmfClH3v4= ------END PRIVATE KEY-----"; - } - - protected function jwkKeyArray(): array - { - return [ - 'kty' => 'RSA', - 'alg' => 'RS256', - 'kid' => '066e52af-8884-4926-801d-032a276f9f2a', - 'use' => 'sig', - 'e' => 'AQAB', - 'n' => 'qo1OmfNKec5S2zQC4SP9DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvmzXL16c93Obn7G8x8A3ao6yN5qKO5S5-CETqOZfKN_g75Xlz7VsC3igOhgsXnPx6iiM6sbYbk0U_XpFaT84LXKI8VTIPUo7gTeZN1pTET__i9FlzAOzX-xfWBKdOqlEzl-zihMHCZUUvQu99P-o0MDR0lMUT-vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNkWvsLta1-jNUee-8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw3w', - ]; - } } \ No newline at end of file From 855409bc4f839cde7c31ced8aac26cc924b5a223 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 14 Oct 2021 13:37:55 +0100 Subject: [PATCH 24/24] Fixed lack of oidc discovery filtering during testing Tested oidc system on okta, Keycloak & Auth0 --- app/Auth/Access/Oidc/OidcProviderSettings.php | 4 ++-- app/Providers/AppServiceProvider.php | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/Auth/Access/Oidc/OidcProviderSettings.php b/app/Auth/Access/Oidc/OidcProviderSettings.php index f1b530667..2b72c54b0 100644 --- a/app/Auth/Access/Oidc/OidcProviderSettings.php +++ b/app/Auth/Access/Oidc/OidcProviderSettings.php @@ -149,7 +149,7 @@ class OidcProviderSettings if (!empty($result['jwks_uri'])) { $keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient); - $discoveredSettings['keys'] = array_filter($keys); + $discoveredSettings['keys'] = $this->filterKeys($keys); } return $discoveredSettings; @@ -161,7 +161,7 @@ class OidcProviderSettings protected function filterKeys(array $keys): array { return array_filter($keys, function(array $key) { - return $key['key'] === 'RSA' && $key['use'] === 'sig' && $key['alg'] === 'RS256'; + return $key['kty'] === 'RSA' && $key['use'] === 'sig' && $key['alg'] === 'RS256'; }); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 18e1fb627..5fce642cf 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -80,7 +80,9 @@ class AppServiceProvider extends ServiceProvider }); $this->app->bind(HttpClientInterface::class, function($app) { - return new Client(['timeout' => 3]); + return new Client([ + 'timeout' => 3, + ]); }); } }