From cf7397a1f884d8b7258008ca1f21c71d7eb4bef1 Mon Sep 17 00:00:00 2001 From: Guillaume Sainthillier Date: Wed, 28 Oct 2026 22:26:34 +0100 Subject: [PATCH] Read the token untracked in TwoFactorAccessListener, so public responses stay cacheable --- app/src/Controller/HomeController.php | 10 ++++++++++ app/tests/TestCase.php | 20 ++++++++++++++++++++ app/tests/TwoFactorAccessControlTest.php | 18 ++++++++++++++++++ src/bundle/Resources/config/security.php | 4 +++- 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/app/src/Controller/HomeController.php b/app/src/Controller/HomeController.php index a0ba4bf6..a02bfd56 100644 --- a/app/src/Controller/HomeController.php +++ b/app/src/Controller/HomeController.php @@ -21,4 +21,14 @@ public function alwaysAccessible(): Response { return new Response('This page is accessible for any user any time'); } + + #[Route('/cacheable', name: 'cacheable')] + public function cacheable(): Response + { + $response = new Response('This page is the same for any user'); + $response->setPublic(); + $response->setMaxAge(3600); + + return $response; + } } diff --git a/app/tests/TestCase.php b/app/tests/TestCase.php index 90397d6e..7b6ce33d 100644 --- a/app/tests/TestCase.php +++ b/app/tests/TestCase.php @@ -209,6 +209,11 @@ protected function navigateToPathWithoutAnyAccessControl(): Crawler return $this->client->request('GET', '/'); } + protected function navigateToCacheablePathWithoutAnyAccessControl(): Crawler + { + return $this->client->request('GET', '/cacheable'); + } + protected function navigateToPathWithAnonymousAccessControl() { return $this->client->request('GET', '/alwaysAccessible'); @@ -317,6 +322,21 @@ protected function assertCsrfError(Crawler $page): void ); } + protected function assertIsCacheablePage(Crawler $page): void + { + $this->assertResponseStatusCode(200); + $this->assertStringContainsString( + 'This page is the same for any user', + $page->html(), + 'The page shown must be the "cacheable" page' + ); + } + + protected function assertResponseIsPubliclyCacheable(): void + { + $this->assertSame('max-age=3600, public', $this->client->getResponse()->headers->get('Cache-Control')); + } + protected function assertResponseIs403Forbidden() { $this->assertResponseStatusCode(403); diff --git a/app/tests/TwoFactorAccessControlTest.php b/app/tests/TwoFactorAccessControlTest.php index 451be2a8..cc4b65d4 100644 --- a/app/tests/TwoFactorAccessControlTest.php +++ b/app/tests/TwoFactorAccessControlTest.php @@ -28,6 +28,24 @@ public function test2faProtectsPathWithoutAnyAccessControl(): void $this->assert2faIsRequired($page); } + public function test2faProtectsCacheablePathWithoutAnyAccessControl(): void + { + $this->setAll2faProvidersEnabled(true); + + $pageAfterLogin = $this->performLogin(); + $this->assert2faIsRequired($pageAfterLogin); + + $page = $this->navigateToCacheablePathWithoutAnyAccessControl(); + $this->assert2faIsRequired($page); + } + + public function testAnonymousAccessToPathWithoutAnyAccessControlKeepsResponseCacheable(): void + { + $page = $this->navigateToCacheablePathWithoutAnyAccessControl(); + $this->assertIsCacheablePage($page); + $this->assertResponseIsPubliclyCacheable(); + } + public function test2faAllowsAccessToPageWithAccessControlAnonymous(): void { $this->setAll2faProvidersEnabled(true); diff --git a/src/bundle/Resources/config/security.php b/src/bundle/Resources/config/security.php index 9574cc2e..4728c36a 100644 --- a/src/bundle/Resources/config/security.php +++ b/src/bundle/Resources/config/security.php @@ -118,7 +118,9 @@ ->set('scheb_two_factor.security.access_listener', TwoFactorAccessListener::class) ->args([ abstract_arg('Two-factor firewall config'), - service('security.token_storage'), + // Untracked, like Symfony's firewall listeners: reading the token on every request must not flag the + // session as used, which would make every response private (lazy firewalls) + service('security.untracked_token_storage'), service('scheb_two_factor.security.access.access_decider'), ])