diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a32910e9..f7f76d49c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ Features: * The per-SP mode does not require `wayf.remember_choice`. Leave `wayf.remember_choice` set to `false` when enabling it. * The cookie removal page `/authentication/idp/remove-cookies` is now also available in the per-SP mode and lists the `rememberedidps` cookie. Before, it was only available when `wayf.remember_choice` was `true`. +* Added the endpoint `/reset-remember-wayf`, which removes the per-SP `rememberedidps` cookie and redirects to the URL + configured in `wayf.reset_choice_per_idp_redirect`. This new parameter must not be empty. See + `docs/wayf_remember_choice.md`. ## 7.2.1 diff --git a/config/packages/parameters.yml.dist b/config/packages/parameters.yml.dist index a89af0a694..29a845f09f 100644 --- a/config/packages/parameters.yml.dist +++ b/config/packages/parameters.yml.dist @@ -195,6 +195,11 @@ parameters: ## oldest-expiring entries are evicted first; the newly added choice is always kept and no ## error is raised (see RememberedIdpCookie::pruneOverLimit()). wayf.remember_choice_per_idp_max: 16 + ## URL to redirect the user to after visiting /reset-remember-wayf and having their + ## per-SP remembered IdP choices cookie removed. Operators should override this with a + ## real destination for their deployment; it must never be left empty, as the endpoint + ## refuses to serve requests (failing fast at construction time) when it is blank. + wayf.reset_choice_per_idp_redirect: 'https://engine.dev.openconext.local/' ## Toggle the default IdP quick link banner on the WAYF. wayf.display_default_idp_banner_on_wayf: true diff --git a/config/services/controllers/authentication.yml b/config/services/controllers/authentication.yml index 21b07e7ccc..73ec6fe5e3 100644 --- a/config/services/controllers/authentication.yml +++ b/config/services/controllers/authentication.yml @@ -74,6 +74,12 @@ services: - '@twig' - '@OpenConext\EngineBlock\Service\SsoSessionService' + OpenConext\EngineBlockBundle\Controller\ResetRememberedWayfController: + arguments: + $rememberedIdpCookie: '@OpenConext\EngineBlock\Service\Wayf\RememberedIdpCookie' + $logger: '@engineblock.compat.logger' + $redirectUrl: '%wayf.reset_choice_per_idp_redirect%' + OpenConext\EngineBlock\Service\RequestAccessMailer: arguments: - '@symfony.mailer' diff --git a/docs/wayf_remember_choice.md b/docs/wayf_remember_choice.md index cd1a8a0761..330f378517 100644 --- a/docs/wayf_remember_choice.md +++ b/docs/wayf_remember_choice.md @@ -90,6 +90,21 @@ The page `/authentication/idp/remove-cookies` lets a user inspect and remove the including `rememberchoice` and `rememberedidps`. The page is available when either mode is enabled, and returns a 404 when both are disabled. +## Resetting the remembered choices + +In the per-SP mode, other applications (for example a profile page) can let users forget all their remembered choices +by sending them to: + + https:///reset-remember-wayf + +The endpoint removes the `rememberedidps` cookie and redirects the user (HTTP 302) to the URL configured in: + + # Where to send the user after the reset. Required, must not be empty. + wayf.reset_choice_per_idp_redirect: 'https://engine.dev.openconext.local/' + +The endpoint only accepts `GET` requests and needs no authentication, because it only clears a cookie in the user's own +browser. It does nothing when the cookie is not present. The `rememberchoice` cookie of the global mode is not touched. + ## Switching modes Cookies written in one mode are not read in the other. After switching, users have to make their choice once more. diff --git a/src/OpenConext/EngineBlockBundle/Controller/ResetRememberedWayfController.php b/src/OpenConext/EngineBlockBundle/Controller/ResetRememberedWayfController.php new file mode 100644 index 0000000000..8dae9e8303 --- /dev/null +++ b/src/OpenConext/EngineBlockBundle/Controller/ResetRememberedWayfController.php @@ -0,0 +1,61 @@ +redirectUrl === '') { + throw new InvalidArgumentException( + 'The "wayf.reset_choice_per_idp_redirect" parameter must be configured with a redirect URL' + ); + } + } + + #[Route(path: '/reset-remember-wayf', name: 'reset_remember_wayf', methods: ['GET'])] + public function __invoke(Request $request): RedirectResponse + { + $raw = $request->cookies->get(RememberedIdpCookie::NAME); + + if ($raw !== null) { + $entryCount = count($this->rememberedIdpCookie->normalize($raw)['entries']); + $this->rememberedIdpCookie->clear(); + $this->logger->info(sprintf( + 'WAYF-remember-my-choice cookie removed (had %d entries)', + $entryCount + )); + } + + return new RedirectResponse($this->redirectUrl, Response::HTTP_FOUND); + } +} diff --git a/tests/functional/OpenConext/EngineBlockBundle/Controller/ResetRememberedWayfControllerTest.php b/tests/functional/OpenConext/EngineBlockBundle/Controller/ResetRememberedWayfControllerTest.php new file mode 100644 index 0000000000..fd86703c6a --- /dev/null +++ b/tests/functional/OpenConext/EngineBlockBundle/Controller/ResetRememberedWayfControllerTest.php @@ -0,0 +1,74 @@ +getCookieJar()->set(new Cookie( + RememberedIdpCookie::NAME, + self::encodeEntries([ + 'https://sp.example.org' => ['idp' => 'https://idp.example.org', 'expires' => time() + 3600], + ]), + null, + '/', + 'engine.dev.openconext.local' + )); + + $client->request('GET', 'https://engine.dev.openconext.local/reset-remember-wayf'); + + $response = $client->getResponse(); + $this->assertSame(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertSame( + self::getContainer()->getParameter('wayf.reset_choice_per_idp_redirect'), + $response->headers->get('Location') + ); + } + + #[Test] + public function visiting_the_endpoint_without_a_remembered_idp_cookie_still_redirects(): void + { + $client = self::createClient(); + + $client->request('GET', 'https://engine.dev.openconext.local/reset-remember-wayf'); + + $response = $client->getResponse(); + $this->assertSame(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertSame( + self::getContainer()->getParameter('wayf.reset_choice_per_idp_redirect'), + $response->headers->get('Location') + ); + } + + /** @param array $entries */ + private static function encodeEntries(array $entries): string + { + return base64_encode((string) gzdeflate((string) json_encode($entries))); + } +} diff --git a/tests/unit/OpenConext/EngineBlockBundle/Controller/ResetRememberedWayfControllerTest.php b/tests/unit/OpenConext/EngineBlockBundle/Controller/ResetRememberedWayfControllerTest.php new file mode 100644 index 0000000000..99690f8f5f --- /dev/null +++ b/tests/unit/OpenConext/EngineBlockBundle/Controller/ResetRememberedWayfControllerTest.php @@ -0,0 +1,154 @@ +clearCookieWithSameSite(Phake::anyParameters())->thenReturn(true); + + $rememberedIdpCookie = $this->buildRememberedIdpCookie($cookieService); + $raw = $rememberedIdpCookie->encode([ + 'https://sp1.example.org' => ['idp' => 'https://idp1.example.org', 'expires' => time() + 3600], + 'https://sp2.example.org' => ['idp' => 'https://idp2.example.org', 'expires' => time() + 3600], + ]); + + $logger = Mockery::mock(LoggerInterface::class); + $logger->shouldReceive('info') + ->once() + ->with('WAYF-remember-my-choice cookie removed (had 2 entries)'); + + $controller = new ResetRememberedWayfController($rememberedIdpCookie, $logger, self::REDIRECT_URL); + + $response = $controller($this->buildRequest($raw)); + + $this->assertSame(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertSame(self::REDIRECT_URL, $response->getTargetUrl()); + Phake::verify($cookieService)->clearCookieWithSameSite( + RememberedIdpCookie::NAME, + self::COOKIE_PATH, + self::COOKIE_DOMAIN, + true, + true, + 'None' + ); + } + + #[Test] + public function invalid_cookie_is_still_cleared_and_logged_with_zero_entries(): void + { + $cookieService = Phake::mock(CookieService::class); + Phake::when($cookieService)->clearCookieWithSameSite(Phake::anyParameters())->thenReturn(true); + + $rememberedIdpCookie = $this->buildRememberedIdpCookie($cookieService); + + $logger = Mockery::mock(LoggerInterface::class); + $logger->shouldReceive('info') + ->once() + ->with('WAYF-remember-my-choice cookie removed (had 0 entries)'); + + $controller = new ResetRememberedWayfController($rememberedIdpCookie, $logger, self::REDIRECT_URL); + + $response = $controller($this->buildRequest('not valid base64 or deflated data!')); + + $this->assertSame(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertSame(self::REDIRECT_URL, $response->getTargetUrl()); + Phake::verify($cookieService)->clearCookieWithSameSite(Phake::anyParameters()); + } + + #[Test] + public function missing_cookie_is_not_cleared_or_logged_but_still_redirects(): void + { + $cookieService = Phake::mock(CookieService::class); + $rememberedIdpCookie = $this->buildRememberedIdpCookie($cookieService); + + $logger = Mockery::mock(LoggerInterface::class); + $logger->shouldNotReceive('info'); + + $controller = new ResetRememberedWayfController($rememberedIdpCookie, $logger, self::REDIRECT_URL); + + $response = $controller($this->buildRequest(null)); + + $this->assertSame(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertSame(self::REDIRECT_URL, $response->getTargetUrl()); + Phake::verifyNoInteraction($cookieService); + } + + #[Test] + public function constructor_rejects_an_empty_redirect_url(): void + { + $this->expectException(InvalidArgumentException::class); + + new ResetRememberedWayfController( + $this->buildRememberedIdpCookie(Phake::mock(CookieService::class)), + Mockery::mock(LoggerInterface::class), + '' + ); + } + + private function buildRememberedIdpCookie(CookieService $cookieService): RememberedIdpCookie + { + return new RememberedIdpCookie( + new TimeProvider(), + $cookieService, + self::LIFETIME, + self::MAX_ENTRIES, + self::COOKIE_DOMAIN, + self::COOKIE_PATH, + true, + ); + } + + private function buildRequest(?string $rememberedIdpsCookie): Request + { + $request = Request::create('/reset-remember-wayf'); + if ($rememberedIdpsCookie !== null) { + $request->cookies->set(RememberedIdpCookie::NAME, $rememberedIdpsCookie); + } + + return $request; + } +}