]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'fix/oidc-logout' into development
authorDan Brown <redacted>
Wed, 6 Dec 2023 12:14:43 +0000 (12:14 +0000)
committerDan Brown <redacted>
Wed, 6 Dec 2023 12:14:43 +0000 (12:14 +0000)
1  2 
.env.example.complete
app/Access/Oidc/OidcService.php
app/Config/oidc.php
resources/views/layouts/parts/header-user-menu.blade.php
routes/web.php

diff --combined .env.example.complete
index 0853bd1fecbba729352d46e46ed2c8ec9dde0d4c,e89dc551591774023feea1f96d49c1dda70e3247..0667cb75bd4ad1f85069c3caf700ece1bfd61ca1
@@@ -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 <issuer>/.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
index 8778cbd98c2e5dcfc17e923368eb7626e0838146,d699204bb929167c4524cd64e8f125fd93549bd5..1067b0832d44086e41b58cafe25521ec939385e1
@@@ -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());
              }
      protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
      {
          $provider = new OidcOAuthProvider($settings->arrayForProvider(), [
 -            'httpClient'     => $this->httpClient,
 +            'httpClient'     => $this->http->buildClient(5),
              'optionProvider' => new HttpBasicAuthOptionProvider(),
          ]);
  
       */
      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;
              $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(),
      {
          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 b28b8a41a826a8faf5df82767206aa0d350df752,a624e034c9381c527117f89ef416249b412feffb..0410588b829efa449622a0e82fb3501f74d5e5c3
@@@ -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),
  ];
index 0440e43d037cbf2e3f29e2140379e104dd074958,0000000000000000000000000000000000000000..ff28f1cfb911bdd216728d08430319a26a428f23
mode 100644,000000..100644
--- /dev/null
@@@ -1,42 -1,0 +1,56 @@@
-             <form action="{{ url(config('auth.method') === 'saml2' ? '/saml2/logout' : '/logout') }}"
-                   method="post">
-                 {{ csrf_field() }}
-                 <button class="icon-item" data-shortcut="logout">
-                     @icon('logout')
-                     <div>{{ trans('auth.logout') }}</div>
-                 </button>
-             </form>
 +<div class="dropdown-container" component="dropdown" option:dropdown:bubble-escapes="true">
 +    <span class="user-name py-s hide-under-l" refs="dropdown@toggle"
 +          aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('common.profile_menu') }}" tabindex="0">
 +        <img class="avatar" src="{{$user->getAvatar(30)}}" alt="{{ $user->name }}">
 +        <span class="name">{{ $user->getShortName(9) }}</span> @icon('caret-down')
 +    </span>
 +    <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
 +        <li>
 +            <a href="{{ url('/favourites') }}" data-shortcut="favourites_view" class="icon-item">
 +                @icon('star')
 +                <div>{{ trans('entities.my_favourites') }}</div>
 +            </a>
 +        </li>
 +        <li>
 +            <a href="{{ $user->getProfileUrl() }}" data-shortcut="profile_view" class="icon-item">
 +                @icon('user')
 +                <div>{{ trans('common.view_profile') }}</div>
 +            </a>
 +        </li>
 +        <li>
 +            <a href="{{ url('/my-account') }}" class="icon-item">
 +                @icon('user-preferences')
 +                <div>{{ trans('preferences.my_account') }}</div>
 +            </a>
 +        </li>
 +        <li><hr></li>
 +        <li>
 +            @include('common.dark-mode-toggle', ['classes' => 'icon-item'])
 +        </li>
 +        <li><hr></li>
 +        <li>
++            <?php
++// OIDC Logout Feature: Use /oidc/logout if authentication method is oidc.
++            if (config('auth.method') === 'oidc')  {
++                ?>
++                <form action="/oidc/logout"
++                    method="get">
++                    <?php
++// OIDC Logout Feature: Use /oidc/logout if authentication method is oidc.
++                } else {
++                    ?>
++                <form action="{{ url(config('auth.method') === 'saml2' ? '/saml2/logout' : '/logout') }}"
++                      method="post">
++                        <?php
++// OIDC Logout Feature: Use /oidc/logout if authentication method is oidc.
++                    }
++                    ?>
++                    {{ csrf_field() }}
++                    <button class="icon-item" data-shortcut="logout">
++                        @icon('logout')
++                        <div>{{ trans('auth.logout') }}</div>
++                    </button>
++                </form>
 +        </li>
 +    </ul>
 +</div>
diff --combined routes/web.php
index c86509c68bf78c559bae298652970547b75c9a77,338572bf60864e83aa5a325f06cf1d09791c0457..a02b19ca331401673597476380a61177ad2484cd
@@@ -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 () {
      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']);
  
      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']);