diff --git a/core/core.services.yml b/core/core.services.yml index d21e0074da86938979b7d5b49819ae06672fd12c..3ce58886ddf3f855e870bb6c498a14bec01ad851 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1879,3 +1879,4 @@ services: - '@logger.channel.default' tags: - { name: twig.loader, priority: 5 } + Drupal\Core\EventSubscriber\CsrfExceptionSubscriber: ~ diff --git a/core/lib/Drupal/Core/EventSubscriber/CsrfExceptionSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/CsrfExceptionSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..137bc189ace93de460bd38be12a31ac5a332ac1a --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/CsrfExceptionSubscriber.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\EventSubscriber; + +use Drupal\Core\Routing\RouteMatch; +use Drupal\Core\Url; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpKernel\Event\ExceptionEvent; + +/** + * Handles exceptions related to CSRF access. + * + * Redirects CSRF 403 exceptions to a _csrf_confirm_form_route. + */ +class CsrfExceptionSubscriber extends HttpExceptionSubscriberBase { + + /** + * {@inheritdoc} + */ + protected function getHandledFormats(): array { + return ['html']; + } + + /** + * Handles a 403 error for HTML. + * + * @param \Symfony\Component\HttpKernel\Event\ExceptionEvent $event + * The event to process. + */ + public function on403(ExceptionEvent $event): void { + $request = $event->getRequest(); + $routeMatch = RouteMatch::createFromRequest($request); + $route = $routeMatch->getRouteObject(); + if (!$route->hasRequirement('_csrf_token') || empty($route->getOption('_csrf_confirm_form_route'))) { + return; + } + $event->setResponse(new RedirectResponse(Url::fromRoute($route->getOption('_csrf_confirm_form_route'))->toString())); + } + +} diff --git a/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php b/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php index f724b96eb5892712b3b13e9a468c1b94f1c8a210..71daad24ffca45ef0d8275bba91fe27cf5512962 100644 --- a/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php +++ b/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php @@ -538,6 +538,7 @@ public function testRead() { $this->drupalGet('/some/normal/route'); $this->assertFalse($this->drupalUserIsLoggedIn($this->userCanViewProfiles)); $this->container->get('state')->set('system.maintenance_mode', FALSE); + $this->drupalResetSession(); // Test that admin user can bypass maintenance mode. $admin_user = $this->drupalCreateUser([], NULL, TRUE); diff --git a/core/modules/shortcut/tests/src/Functional/ShortcutCacheTagsTest.php b/core/modules/shortcut/tests/src/Functional/ShortcutCacheTagsTest.php index 6cad565e8fcc99801d287f47ca8b2123cc4c3298..9e28e03879d4390bfb797685b8876cee6739ea7a 100644 --- a/core/modules/shortcut/tests/src/Functional/ShortcutCacheTagsTest.php +++ b/core/modules/shortcut/tests/src/Functional/ShortcutCacheTagsTest.php @@ -183,13 +183,13 @@ public function testToolbar() { $this->drupalLogin($site_configuration_user1); $this->verifyDynamicPageCache($test_page_url, 'MISS'); $this->verifyDynamicPageCache($test_page_url, 'HIT'); - $this->assertCacheContexts(['user', 'url.query_args:_wrapper_format']); + $this->assertCacheContexts(['session', 'user', 'url.query_args:_wrapper_format']); $this->assertSession()->linkExists('Shortcuts'); $this->assertSession()->linkExists('Cron'); $this->drupalLogin($site_configuration_user2); $this->verifyDynamicPageCache($test_page_url, 'HIT'); - $this->assertCacheContexts(['user', 'url.query_args:_wrapper_format']); + $this->assertCacheContexts(['session', 'user', 'url.query_args:_wrapper_format']); $this->assertSession()->linkExists('Shortcuts'); $this->assertSession()->linkExists('Cron'); diff --git a/core/modules/toolbar/tests/src/Functional/ToolbarCacheContextsTest.php b/core/modules/toolbar/tests/src/Functional/ToolbarCacheContextsTest.php index 656fa45077fd10019abfb873052532d2dd2d3f84..23b2140fd7213dc498f55cd9e85c5a9f34d63048 100644 --- a/core/modules/toolbar/tests/src/Functional/ToolbarCacheContextsTest.php +++ b/core/modules/toolbar/tests/src/Functional/ToolbarCacheContextsTest.php @@ -84,7 +84,7 @@ public function testCacheIntegration() { */ public function testToolbarCacheContextsCaller() { // Test with default combination and permission to see toolbar. - $this->assertToolbarCacheContexts(['user'], 'Expected cache contexts found for default combination and permission to see toolbar.'); + $this->assertToolbarCacheContexts(['user', 'session'], 'Expected cache contexts found for default combination and permission to see toolbar.'); // Test without user toolbar tab. User module is a required module so we have to // manually remove the user toolbar tab. diff --git a/core/modules/user/src/EventSubscriber/AccessDeniedSubscriber.php b/core/modules/user/src/EventSubscriber/AccessDeniedSubscriber.php index 54d5275997eea6bd5beca9bcaa6c8252805ce6fa..f33b0a303dd877cb51c93e4c859219dae9e80002 100644 --- a/core/modules/user/src/EventSubscriber/AccessDeniedSubscriber.php +++ b/core/modules/user/src/EventSubscriber/AccessDeniedSubscriber.php @@ -65,7 +65,7 @@ public function onException(ExceptionEvent $event) { elseif ($route_name === 'user.page') { $redirect_url = Url::fromRoute('user.login', [], ['absolute' => TRUE]); } - elseif ($route_name === 'user.logout') { + elseif (in_array($route_name, ['user.logout', 'user.logout.confirm'], TRUE)) { $redirect_url = Url::fromRoute('<front>', [], ['absolute' => TRUE]); } diff --git a/core/modules/user/src/Form/UserLogoutConfirm.php b/core/modules/user/src/Form/UserLogoutConfirm.php new file mode 100644 index 0000000000000000000000000000000000000000..df2b1e27eb10212ae9b910aaf2ce1a89ad038d76 --- /dev/null +++ b/core/modules/user/src/Form/UserLogoutConfirm.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\user\Form; + +use Drupal\Core\Form\ConfirmFormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Form\WorkspaceSafeFormInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\Url; + +/** + * Provides a confirmation form for user logout. + */ +class UserLogoutConfirm extends ConfirmFormBase implements WorkspaceSafeFormInterface { + + /** + * {@inheritdoc} + */ + public function getConfirmText(): TranslatableMarkup { + return $this->t('Log out'); + } + + /** + * {@inheritdoc} + */ + public function getQuestion(): TranslatableMarkup { + return $this->t('Are you sure you want to log out?'); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl(): Url { + return new Url('<front>'); + } + + /** + * {@inheritdoc} + */ + public function getFormId(): string { + return 'user_logout_confirm'; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + user_logout(); + $form_state->setRedirect('<front>'); + } + +} diff --git a/core/modules/user/tests/src/Functional/UserLogoutTest.php b/core/modules/user/tests/src/Functional/UserLogoutTest.php new file mode 100644 index 0000000000000000000000000000000000000000..952b15b0711f137fa8a271a90fc8127e99690683 --- /dev/null +++ b/core/modules/user/tests/src/Functional/UserLogoutTest.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\user\Functional; + +use Drupal\Core\Url; +use Drupal\Tests\BrowserTestBase; + +/** + * Tests user logout. + * + * @group user + */ +class UserLogoutTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['user', 'block']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected function setUp() : void { + parent::setUp(); + $this->placeBlock('system_menu_block:account'); + } + + /** + * Tests user logout functionality. + */ + public function testLogout(): void { + $account = $this->createUser(); + $this->drupalLogin($account); + + // Test missing csrf token does not log the user out. + $logoutUrl = Url::fromRoute('user.logout'); + $confirmUrl = Url::fromRoute('user.logout.confirm'); + $this->drupalGet($logoutUrl); + $this->assertTrue($this->drupalUserIsLoggedIn($account)); + $this->assertSession()->addressEquals($confirmUrl); + + // Test invalid csrf token does not log the user out. + $this->drupalGet($logoutUrl, ['query' => ['token' => '123']]); + $this->assertTrue($this->drupalUserIsLoggedIn($account)); + $this->assertSession()->addressEquals($confirmUrl); + // Submitting the confirmation form correctly logs the user out. + $this->submitForm([], 'Log out'); + $this->assertFalse($this->drupalUserIsLoggedIn($account)); + + $this->drupalResetSession(); + $this->drupalLogin($account); + + // Test with valid logout link. + $this->drupalGet('user'); + $this->getSession()->getPage()->clickLink('Log out'); + $this->assertFalse($this->drupalUserIsLoggedIn($account)); + + // Test hitting the confirm form while logged out redirects to the + // frontpage. + $this->drupalGet($confirmUrl); + $this->assertSession()->addressEquals(Url::fromRoute('<front>')); + } + +} diff --git a/core/modules/user/user.routing.yml b/core/modules/user/user.routing.yml index d4799178457168db49ecd638cfce70fd69fb0688..206d8c01a13e911cf41f8ffa175c326b7640ce3b 100644 --- a/core/modules/user/user.routing.yml +++ b/core/modules/user/user.routing.yml @@ -12,6 +12,16 @@ user.logout: _controller: '\Drupal\user\Controller\UserController::logout' requirements: _user_is_logged_in: 'TRUE' + _csrf_token: 'TRUE' + options: + _csrf_confirm_form_route: 'user.logout.confirm' + +user.logout.confirm: + path: '/user/logout/confirm' + defaults: + _form: '\Drupal\user\Form\UserLogoutConfirm' + requirements: + _user_is_logged_in: 'TRUE' user.admin_index: path: '/admin/config/people' diff --git a/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryAuthenticatedPerformanceTest.php b/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryAuthenticatedPerformanceTest.php index 21afa6750af2c382771194a8aa6c046125e9657f..a9b7f6904697a0af062374ae5aff24effabc96e7 100644 --- a/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryAuthenticatedPerformanceTest.php +++ b/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryAuthenticatedPerformanceTest.php @@ -50,11 +50,11 @@ public function testFrontPageAuthenticatedWarmCache(): void { $recorded_queries = $performance_data->getQueries(); $this->assertSame($expected_queries, $recorded_queries); $this->assertSame(4, $performance_data->getQueryCount()); - $this->assertSame(45, $performance_data->getCacheGetCount()); + $this->assertSame(48, $performance_data->getCacheGetCount()); $this->assertSame(0, $performance_data->getCacheSetCount()); $this->assertSame(0, $performance_data->getCacheDeleteCount()); $this->assertSame(0, $performance_data->getCacheTagChecksumCount()); - $this->assertSame(13, $performance_data->getCacheTagIsValidCount()); + $this->assertSame(16, $performance_data->getCacheTagIsValidCount()); $this->assertSame(0, $performance_data->getCacheTagInvalidationCount()); } diff --git a/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php b/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php index e6894ecca164d61d5553e969b50f10b5f7406956..2badae2a5af269c589751b496996830f91817416 100644 --- a/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php +++ b/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php @@ -221,11 +221,11 @@ public function testLogin(): void { $recorded_queries = $performance_data->getQueries(); $this->assertSame($expected_queries, $recorded_queries); $this->assertSame(15, $performance_data->getQueryCount()); - $this->assertSame(63, $performance_data->getCacheGetCount()); + $this->assertSame(67, $performance_data->getCacheGetCount()); $this->assertSame(1, $performance_data->getCacheSetCount()); $this->assertSame(1, $performance_data->getCacheDeleteCount()); $this->assertSame(1, $performance_data->getCacheTagChecksumCount()); - $this->assertSame(28, $performance_data->getCacheTagIsValidCount()); + $this->assertSame(30, $performance_data->getCacheTagIsValidCount()); $this->assertSame(0, $performance_data->getCacheTagInvalidationCount()); } @@ -239,7 +239,6 @@ public function testLoginBlock(): void { // this twice so that any caches which take two requests to warm are also // covered. $account = $this->drupalCreateUser(); - $this->drupalLogout(); foreach (range(0, 1) as $index) { $this->drupalGet('node'); @@ -276,11 +275,11 @@ public function testLoginBlock(): void { $recorded_queries = $performance_data->getQueries(); $this->assertSame($expected_queries, $recorded_queries); $this->assertSame(17, $performance_data->getQueryCount()); - $this->assertSame(107, $performance_data->getCacheGetCount()); + $this->assertSame(110, $performance_data->getCacheGetCount()); $this->assertSame(1, $performance_data->getCacheSetCount()); $this->assertSame(1, $performance_data->getCacheDeleteCount()); $this->assertSame(1, $performance_data->getCacheTagChecksumCount()); - $this->assertSame(43, $performance_data->getCacheTagIsValidCount()); + $this->assertSame(46, $performance_data->getCacheTagIsValidCount()); $this->assertSame(0, $performance_data->getCacheTagInvalidationCount()); } diff --git a/core/tests/Drupal/Nightwatch/Commands/drupalLogout.js b/core/tests/Drupal/Nightwatch/Commands/drupalLogout.js index c5f4b47e5edf8e4989eda61a98bb3a2b62accf52..a58d68efc9aad8d132b65a46bf3c86a23eaeed4e 100644 --- a/core/tests/Drupal/Nightwatch/Commands/drupalLogout.js +++ b/core/tests/Drupal/Nightwatch/Commands/drupalLogout.js @@ -13,7 +13,9 @@ exports.command = function drupalLogout({ silent = false } = {}, callback) { const self = this; - this.drupalRelativeURL('/user/logout'); + this.drupalRelativeURL('/user/logout/confirm').submitForm( + '#user-logout-confirm', + ); this.drupalUserIsLoggedIn((sessionExists) => { if (silent) { diff --git a/core/tests/Drupal/Tests/UiHelperTrait.php b/core/tests/Drupal/Tests/UiHelperTrait.php index 71d6bc97b8fac4081a61c3b04029a55256d0993f..c5ed82ba5dc241f75cbcba340c10d5822798a4d8 100644 --- a/core/tests/Drupal/Tests/UiHelperTrait.php +++ b/core/tests/Drupal/Tests/UiHelperTrait.php @@ -181,10 +181,18 @@ protected function drupalLogout() { // screen. $assert_session = $this->assertSession(); $destination = Url::fromRoute('user.page')->toString(); - $this->drupalGet(Url::fromRoute('user.logout', [], ['query' => ['destination' => $destination]])); + $this->drupalGet(Url::fromRoute('user.logout.confirm', options: ['query' => ['destination' => $destination]])); + $this->submitForm([], 'Log out'); $assert_session->fieldExists('name'); $assert_session->fieldExists('pass'); + $this->drupalResetSession(); + } + + /** + * Resets the current active session back to Anonymous session. + */ + protected function drupalResetSession(): void { // @see BrowserTestBase::drupalUserIsLoggedIn() unset($this->loggedInUser->sessionId); $this->loggedInUser = FALSE;