From: Dan Brown Date: Wed, 6 Dec 2023 12:14:43 +0000 (+0000) Subject: Merge branch 'fix/oidc-logout' into development X-Git-Tag: v23.12~1^2~15^2~5 X-Git-Url: http://source.bookstackapp.com/bookstack/commitdiff_plain/cc10d1ddfc652f6bcf3bbf61d5ec2e2861394c03?hp=-c Merge branch 'fix/oidc-logout' into development --- cc10d1ddfc652f6bcf3bbf61d5ec2e2861394c03 diff --combined .env.example.complete index 0853bd1fe,e89dc5515..0667cb75b --- a/.env.example.complete +++ b/.env.example.complete @@@ -72,7 -72,7 +72,7 @@@ MYSQL_ATTR_SSL_CA="/path/to/ca.pem # Mail configuration # Refer to https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration MAIL_DRIVER=smtp -MAIL_FROM=mail@bookstackapp.com +MAIL_FROM=bookstack@example.com MAIL_FROM_NAME=BookStack MAIL_HOST=localhost @@@ -274,6 -274,10 +274,10 @@@ OIDC_GROUPS_CLAIM=group OIDC_REMOVE_FROM_GROUPS=false OIDC_EXTERNAL_ID_CLAIM=sub + # OIDC Logout Feature: Its value should be value of end_session_endpoint from /.well-known/openid-configuration + OIDC_END_SESSION_ENDPOINT=null + + # Disable default third-party services such as Gravatar and Draw.IO # Service-specific options will override this option DISABLE_EXTERNAL_SERVICES=false @@@ -359,15 -363,6 +363,15 @@@ ALLOWED_IFRAME_HOSTS=nul # Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured. ALLOWED_IFRAME_SOURCES="https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com" +# A list of the sources/hostnames that can be reached by application SSR calls. +# This is used wherever users can provide URLs/hosts in-platform, like for webhooks. +# Host-specific functionality (usually controlled via other options) like auth +# or user avatars for example, won't use this list. +# Space seperated if multiple. Can use '*' as a wildcard. +# Values will be compared prefix-matched, case-insensitive, against called SSR urls. +# Defaults to allow all hosts. +ALLOWED_SSR_HOSTS="*" + # The default and maximum item-counts for listing API requests. API_DEFAULT_ITEM_COUNT=100 API_MAX_ITEM_COUNT=500 diff --combined app/Access/Oidc/OidcService.php index 8778cbd98,d699204bb..1067b0832 --- a/app/Access/Oidc/OidcService.php +++ b/app/Access/Oidc/OidcService.php @@@ -9,13 -9,13 +9,13 @@@ use BookStack\Exceptions\JsonDebugExcep use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\UserRegistrationException; use BookStack\Facades\Theme; +use BookStack\Http\HttpRequestService; use BookStack\Theming\ThemeEvents; use BookStack\Users\Models\User; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; -use Psr\Http\Client\ClientInterface as HttpClient; /** * Class OpenIdConnectService @@@ -26,7 -26,7 +26,7 @@@ class OidcServic public function __construct( protected RegistrationService $registrationService, protected LoginService $loginService, - protected HttpClient $httpClient, + protected HttpRequestService $http, protected GroupSyncService $groupService ) { } @@@ -94,7 -94,7 +94,7 @@@ // Run discovery if ($config['discover'] ?? false) { try { - $settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15); + $settings->discoverFromIssuer($this->http->buildClient(5), Cache::store(null), 15); } catch (OidcIssuerDiscoveryException $exception) { throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage()); } @@@ -111,7 -111,7 +111,7 @@@ protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider { $provider = new OidcOAuthProvider($settings->arrayForProvider(), [ - 'httpClient' => $this->httpClient, + 'httpClient' => $this->http->buildClient(5), 'optionProvider' => new HttpBasicAuthOptionProvider(), ]); @@@ -142,11 -142,10 +142,11 @@@ */ protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string { - $displayNameAttr = $this->config()['display_name_claims']; + $displayNameAttrString = $this->config()['display_name_claims'] ?? ''; + $displayNameAttrs = explode('|', $displayNameAttrString); $displayName = []; - foreach ($displayNameAttr as $dnAttr) { + foreach ($displayNameAttrs as $dnAttr) { $dnComponent = $token->getClaim($dnAttr) ?? ''; if ($dnComponent !== '') { $displayName[] = $dnComponent; @@@ -217,6 -216,12 +217,12 @@@ $settings->keys, ); + // OIDC Logout Feature: Temporarily save token in session + $access_token_for_logout = $idTokenText; + session()->put("oidctoken", $access_token_for_logout); + + + $returnClaims = Theme::dispatch(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $idToken->getAllClaims(), [ 'access_token' => $accessToken->getToken(), 'expires_in' => $accessToken->getExpires(), @@@ -284,4 -289,37 +290,37 @@@ { return $this->config()['user_to_groups'] !== false; } + + + /** + * OIDC Logout Feature: Initiate a logout flow. + * + * @throws OidcException + * + * @return string + */ + public function logout() { + + $config = $this->config(); + $app_url = env('APP_URL', ''); + $end_session_endpoint = $config["end_session_endpoint"]; + + $oidctoken = session()->get("oidctoken"); + session()->invalidate(); + + if (str_contains($app_url, 'https://')) { + $protocol = 'https://'; + } else { + $protocol = 'http://'; + } + + + + return redirect($end_session_endpoint.'?id_token_hint='.$oidctoken."&post_logout_redirect_uri=".$protocol.$_SERVER['HTTP_HOST']."/"); + + + } + + + } diff --combined app/Config/oidc.php index b28b8a41a,a624e034c..0410588b8 --- a/app/Config/oidc.php +++ b/app/Config/oidc.php @@@ -9,7 -9,7 +9,7 @@@ return 'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false), // Claim, within an OpenId token, to find the user's display name - 'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')), + 'display_name_claims' => env('OIDC_DISPLAY_NAME_CLAIMS', 'name'), // Claim, within an OpenID token, to use to connect a BookStack user to the OIDC user. 'external_id_claim' => env('OIDC_EXTERNAL_ID_CLAIM', 'sub'), @@@ -47,4 -47,9 +47,9 @@@ 'groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'), // When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups. 'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false), + + // OIDC Logout Feature: OAuth2 end_session_endpoint + 'end_session_endpoint' => env('OIDC_END_SESSION_ENDPOINT', null), + ]; + diff --combined resources/views/layouts/parts/header-user-menu.blade.php index 0440e43d0,000000000..ff28f1cfb mode 100644,000000..100644 --- a/resources/views/layouts/parts/header-user-menu.blade.php +++ b/resources/views/layouts/parts/header-user-menu.blade.php @@@ -1,42 -1,0 +1,56 @@@ + diff --combined routes/web.php index c86509c68,338572bf6..a02b19ca3 --- a/routes/web.php +++ b/routes/web.php @@@ -20,7 -20,6 +20,7 @@@ use Illuminate\View\Middleware\ShareErr Route::get('/status', [SettingControllers\StatusController::class, 'show']); Route::get('/robots.txt', [HomeController::class, 'robots']); Route::get('/favicon.ico', [HomeController::class, 'favicon']); +Route::get('/manifest.json', [HomeController::class, 'pwaManifest']); // Authenticated routes... Route::middleware('auth')->group(function () { @@@ -143,7 -142,6 +143,7 @@@ Route::post('/images/drawio', [UploadControllers\DrawioImageController::class, 'create']); Route::get('/images/edit/{id}', [UploadControllers\ImageController::class, 'edit']); Route::put('/images/{id}/file', [UploadControllers\ImageController::class, 'updateFile']); + Route::put('/images/{id}/rebuild-thumbnails', [UploadControllers\ImageController::class, 'rebuildThumbnails']); Route::put('/images/{id}', [UploadControllers\ImageController::class, 'update']); Route::delete('/images/{id}', [UploadControllers\ImageController::class, 'destroy']); @@@ -232,33 -230,26 +232,33 @@@ Route::put('/settings/users/{id}', [UserControllers\UserController::class, 'update']); Route::delete('/settings/users/{id}', [UserControllers\UserController::class, 'destroy']); - // User Preferences - Route::get('/preferences', [UserControllers\UserPreferencesController::class, 'index']); - Route::get('/preferences/shortcuts', [UserControllers\UserPreferencesController::class, 'showShortcuts']); - Route::put('/preferences/shortcuts', [UserControllers\UserPreferencesController::class, 'updateShortcuts']); - Route::get('/preferences/notifications', [UserControllers\UserPreferencesController::class, 'showNotifications']); - Route::put('/preferences/notifications', [UserControllers\UserPreferencesController::class, 'updateNotifications']); + // User Account + Route::get('/my-account', [UserControllers\UserAccountController::class, 'redirect']); + Route::get('/my-account/profile', [UserControllers\UserAccountController::class, 'showProfile']); + Route::put('/my-account/profile', [UserControllers\UserAccountController::class, 'updateProfile']); + Route::get('/my-account/shortcuts', [UserControllers\UserAccountController::class, 'showShortcuts']); + Route::put('/my-account/shortcuts', [UserControllers\UserAccountController::class, 'updateShortcuts']); + Route::get('/my-account/notifications', [UserControllers\UserAccountController::class, 'showNotifications']); + Route::put('/my-account/notifications', [UserControllers\UserAccountController::class, 'updateNotifications']); + Route::get('/my-account/auth', [UserControllers\UserAccountController::class, 'showAuth']); + Route::put('/my-account/auth/password', [UserControllers\UserAccountController::class, 'updatePassword']); + Route::get('/my-account/delete', [UserControllers\UserAccountController::class, 'delete']); + Route::delete('/my-account', [UserControllers\UserAccountController::class, 'destroy']); + + // User Preference Endpoints Route::patch('/preferences/change-view/{type}', [UserControllers\UserPreferencesController::class, 'changeView']); Route::patch('/preferences/change-sort/{type}', [UserControllers\UserPreferencesController::class, 'changeSort']); Route::patch('/preferences/change-expansion/{type}', [UserControllers\UserPreferencesController::class, 'changeExpansion']); Route::patch('/preferences/toggle-dark-mode', [UserControllers\UserPreferencesController::class, 'toggleDarkMode']); Route::patch('/preferences/update-code-language-favourite', [UserControllers\UserPreferencesController::class, 'updateCodeLanguageFavourite']); - Route::patch('/preferences/update-boolean', [UserControllers\UserPreferencesController::class, 'updateBooleanPreference']); // User API Tokens - Route::get('/settings/users/{userId}/create-api-token', [UserApiTokenController::class, 'create']); - Route::post('/settings/users/{userId}/create-api-token', [UserApiTokenController::class, 'store']); - Route::get('/settings/users/{userId}/api-tokens/{tokenId}', [UserApiTokenController::class, 'edit']); - Route::put('/settings/users/{userId}/api-tokens/{tokenId}', [UserApiTokenController::class, 'update']); - Route::get('/settings/users/{userId}/api-tokens/{tokenId}/delete', [UserApiTokenController::class, 'delete']); - Route::delete('/settings/users/{userId}/api-tokens/{tokenId}', [UserApiTokenController::class, 'destroy']); + Route::get('/api-tokens/{userId}/create', [UserApiTokenController::class, 'create']); + Route::post('/api-tokens/{userId}/create', [UserApiTokenController::class, 'store']); + Route::get('/api-tokens/{userId}/{tokenId}', [UserApiTokenController::class, 'edit']); + Route::put('/api-tokens/{userId}/{tokenId}', [UserApiTokenController::class, 'update']); + Route::get('/api-tokens/{userId}/{tokenId}/delete', [UserApiTokenController::class, 'delete']); + Route::delete('/api-tokens/{userId}/{tokenId}', [UserApiTokenController::class, 'destroy']); // Roles Route::get('/settings/roles', [UserControllers\RoleController::class, 'index']); @@@ -332,6 -323,8 +332,8 @@@ Route::get('/saml2/acs', [AccessControl // OIDC routes Route::post('/oidc/login', [AccessControllers\OidcController::class, 'login']); Route::get('/oidc/callback', [AccessControllers\OidcController::class, 'callback']); + // OIDC Logout Feature: Added to cater OIDC logout + Route::get('/oidc/logout', [AccessControllers\OidcController::class, 'logout']); // User invitation routes Route::get('/register/invite/{token}', [AccessControllers\UserInviteController::class, 'showSetPassword']);