diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cde3a9ba..76a7dafd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ # UNRELEASED +* Add a link on My Profile to reset remembered per-SP WAYF login-method choices #353 +* Deployments must set the new `wayf_reset_url` parameter in `parameters.yaml` (see `config/openconext/parameters.yaml.dist`) to the EngineBlock `/reset-remember-wayf` endpoint. * Upgrade the runtime and build toolchain to PHP 8.5 and Node.js 24. * Refresh the CI Docker base image plus Composer and npm dependencies for the PHP 8.5 build. * Preserve the originally requested deeplink after SAML login instead of redirecting to the homepage #325 diff --git a/assets/css/components/modal-window.scss b/assets/css/components/modal-window.scss index 31aed9bc9..690375d9b 100644 --- a/assets/css/components/modal-window.scss +++ b/assets/css/components/modal-window.scss @@ -79,7 +79,8 @@ width: unset; } - &--form { - width: 100%; - } +} + +.modalWindow__button--form { + display: contents; } diff --git a/assets/css/pages/myProfile/myProfile.scss b/assets/css/pages/myProfile/myProfile.scss index f5d0735ec..9569f14ea 100644 --- a/assets/css/pages/myProfile/myProfile.scss +++ b/assets/css/pages/myProfile/myProfile.scss @@ -1,6 +1,7 @@ @import 'attributesTable'; @import '../../helpers/breakpoints'; @import '../../helpers/functions'; +@import '../../helpers/variables'; .content__myProfile { padding-right: calculateRem(70px); @@ -21,3 +22,42 @@ .myProfile__note { margin-bottom: calculateRem(80px); } + +.myProfile__wayfResetExplanation { + color: $titleBlack; + display: flex; + font-size: $f-normal; + + @include screen('mobile') { + flex-direction: column; + } +} + +.myProfile__wayfResetButtonWrapper { + flex: 0 0 max-content; + height: max-content; + min-width: max-content; + text-align: center; + + @include screen('mobile') { + text-align: left; + } +} + +.myProfile__wayfResetButton { + height: 100%; + width: 100%; +} + +.myProfile__wayfResetText { + margin-top: 0; +} + +@include ie11Only('.myProfile__wayfResetButtonWrapper, .myProfile__wayfResetButton, .myProfile__wayfResetText') { + display: inline-block; +} + +@include ie11Only('.myProfile__wayfResetButton') { + height: auto; + min-width: 14rem; +} diff --git a/config/openconext/parameters.yaml.dist b/config/openconext/parameters.yaml.dist index 490737606..dd033f07a 100644 --- a/config/openconext/parameters.yaml.dist +++ b/config/openconext/parameters.yaml.dist @@ -28,6 +28,11 @@ parameters: engineblock_api_password: secret engineblock_api_verify_ssl: true + # The EngineBlock endpoint that clears a user's remembered per-SP WAYF IdP + # choices. Profile links to this with a `redirect` query parameter so the + # user is sent back here afterwards. See OpenConext-engineblock#2085/#345. + wayf_reset_url: "https://engine.dev.openconext.local/reset-remember-wayf" + user_lifecycle_enabled: true user_lifecycle_api_base_url: "https://userlifecycle.dev.openconext.local/" user_lifecycle_api_username: userlifecycle diff --git a/config/services.yaml b/config/services.yaml index 55da17f50..00d3ebc1a 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -24,6 +24,11 @@ services: $removeConsentEnabled: '%remove_consent_enabled%' tags: [ 'controller.service_arguments' ] + OpenConext\ProfileBundle\Controller\MyProfileController: + arguments: + $wayfResetUrl: '%wayf_reset_url%' + tags: [ 'controller.service_arguments' ] + OpenConext\ProfileBundle\Controller\InviteRolesController: arguments: $enabled: '%invite_roles_enabled%' diff --git a/src/OpenConext/ProfileBundle/Controller/MyProfileController.php b/src/OpenConext/ProfileBundle/Controller/MyProfileController.php index 1b98d3356..2ff1b3935 100644 --- a/src/OpenConext/ProfileBundle/Controller/MyProfileController.php +++ b/src/OpenConext/ProfileBundle/Controller/MyProfileController.php @@ -21,10 +21,17 @@ namespace OpenConext\ProfileBundle\Controller; use OpenConext\ProfileBundle\Service\UserService; +use OpenConext\ProfileBundle\Service\WayfResetLinkBuilder; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Security\Csrf\CsrfToken; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; class MyProfileController extends AbstractController { @@ -32,6 +39,10 @@ class MyProfileController extends AbstractController public function __construct( private readonly UserService $userService, private readonly LoggerInterface $logger, + private readonly WayfResetLinkBuilder $wayfResetLinkBuilder, + private readonly UrlGeneratorInterface $urlGenerator, + private readonly string $wayfResetUrl, + private readonly CsrfTokenManagerInterface $csrfTokenManager, ) { } @@ -49,7 +60,34 @@ public function overview(): Response return $this->render( '@OpenConextProfile/MyProfile/overview.html.twig', - ['user' => $user], + [ + 'user' => $user, + ], ); } + + #[Route( + path: "/my-profile/wayf-reset", + name: "profile.my_profile_wayf_reset", + methods: ["POST"], + schemes: "https", + )] + public function resetWayfChoice( + Request $request, + ): RedirectResponse { + $token = new CsrfToken('wayf_reset', (string) $request->request->get('_token')); + + if (!$this->csrfTokenManager->isTokenValid($token)) { + throw new AccessDeniedHttpException('Invalid CSRF token for the WAYF reset action'); + } + + $this->logger->info('Redirecting to EngineBlock to reset the remembered WAYF choice'); + + $wayfResetLink = $this->wayfResetLinkBuilder->build( + $this->wayfResetUrl, + $this->urlGenerator->generate('profile.my_profile_overview', [], UrlGeneratorInterface::ABSOLUTE_URL), + ); + + return new RedirectResponse($wayfResetLink); + } } diff --git a/src/OpenConext/ProfileBundle/Service/WayfResetLinkBuilder.php b/src/OpenConext/ProfileBundle/Service/WayfResetLinkBuilder.php new file mode 100644 index 000000000..49fd328b8 --- /dev/null +++ b/src/OpenConext/ProfileBundle/Service/WayfResetLinkBuilder.php @@ -0,0 +1,33 @@ +createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->once()) + ->method('isTokenValid') + ->with($this->equalTo(new CsrfToken('wayf_reset', 'valid-token'))) + ->willReturn(true); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects($this->once()) + ->method('generate') + ->with('profile.my_profile_overview', [], UrlGeneratorInterface::ABSOLUTE_URL) + ->willReturn('https://profile.example.org/my-profile'); + + $controller = new MyProfileController( + $this->createUserService(), + $this->createMock(LoggerInterface::class), + new WayfResetLinkBuilder(), + $urlGenerator, + 'https://engine.example.org/reset-remember-wayf', + $csrfTokenManager, + ); + + $response = $controller->resetWayfChoice(new Request([], ['_token' => 'valid-token'])); + + $this->assertSame( + 'https://engine.example.org/reset-remember-wayf?redirect=https%3A%2F%2Fprofile.example.org%2Fmy-profile', + $response->getTargetUrl(), + ); + } + + /** + * @test + * @group Controller + */ + public function it_denies_access_when_the_csrf_token_is_invalid(): void + { + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->once()) + ->method('isTokenValid') + ->with($this->equalTo(new CsrfToken('wayf_reset', 'invalid-token'))) + ->willReturn(false); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects($this->never())->method('generate'); + + $controller = new MyProfileController( + $this->createUserService(), + $this->createMock(LoggerInterface::class), + new WayfResetLinkBuilder(), + $urlGenerator, + 'https://engine.example.org/reset-remember-wayf', + $csrfTokenManager, + ); + + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('Invalid CSRF token for the WAYF reset action'); + + $controller->resetWayfChoice(new Request([], ['_token' => 'invalid-token'])); + } + + private function createUserService(): UserService + { + return new UserService( + new SupportContactEmailService($this->createMock(ContactPersonRepositoryInterface::class)), + $this->createMock(UserRepositoryInterface::class), + $this->createMock(AuthenticatedUserProviderInterface::class), + new LocaleService( + $this->createMock(LocaleRepositoryInterface::class), + LocaleSet::create([new Locale('en')]), + new Locale('en'), + ), + new EntityId('https://engine.example.org/authentication/sp/metadata'), + ); + } +} diff --git a/src/OpenConext/ProfileBundle/Tests/Service/WayfResetLinkBuilderTest.php b/src/OpenConext/ProfileBundle/Tests/Service/WayfResetLinkBuilderTest.php new file mode 100644 index 000000000..e4b52d033 --- /dev/null +++ b/src/OpenConext/ProfileBundle/Tests/Service/WayfResetLinkBuilderTest.php @@ -0,0 +1,85 @@ +build( + 'https://engine.dev.openconext.local/reset-remember-wayf', + 'https://profile.dev.openconext.local/my-profile', + ); + + $this->assertSame( + 'https://engine.dev.openconext.local/reset-remember-wayf?redirect=https%3A%2F%2Fprofile.dev.openconext.local%2Fmy-profile', + $link, + ); + } + + /** + * @test + * @group Service + */ + public function it_url_encodes_special_characters_in_the_return_url(): void + { + $builder = new WayfResetLinkBuilder(); + + $link = $builder->build( + 'https://engine.dev.openconext.local/reset-remember-wayf', + 'https://profile.dev.openconext.local/my-profile?foo=bar&baz=1', + ); + + $this->assertSame( + 'https://engine.dev.openconext.local/reset-remember-wayf?redirect=' + . rawurlencode('https://profile.dev.openconext.local/my-profile?foo=bar&baz=1'), + $link, + ); + } + + /** + * @test + * @group Service + */ + public function it_uses_an_ampersand_when_the_wayf_reset_url_already_has_a_query_string(): void + { + $builder = new WayfResetLinkBuilder(); + + $link = $builder->build( + 'https://engine.dev.openconext.local/reset-remember-wayf?debug=1', + 'https://profile.dev.openconext.local/my-profile', + ); + + $this->assertSame( + 'https://engine.dev.openconext.local/reset-remember-wayf?debug=1&redirect=https%3A%2F%2Fprofile.dev.openconext.local%2Fmy-profile', + $link, + ); + } +} diff --git a/templates/MyProfile/overview.html.twig b/templates/MyProfile/overview.html.twig index 84f51623e..0bee4dff8 100644 --- a/templates/MyProfile/overview.html.twig +++ b/templates/MyProfile/overview.html.twig @@ -30,4 +30,40 @@ text: text } %} +
+

{{ 'profile.my_profile.wayf_reset.title'|trans }}

+
+

{{ 'profile.my_profile.wayf_reset.explanation'|trans }}

+
+ {% set modalContent %} +

{{ 'profile.my_profile.wayf_reset.modal_explanation'|trans }}

+ {% endset %} + + {% set buttonRowContent %} + + {% endset %} + + {# The modal helper presupposes a `for` loop context (it derives its element + ids from `loop.index`); wrap it in a single-iteration loop since this page + only ever shows one reset link. #} + {% for _ in [1] %} + {% include "@OpenConextProfile/helpers/modal.html.twig" with { + buttonText: 'profile.my_profile.wayf_reset.link_text'|trans, + linkIdPrefix: 'wayf_reset_', + buttonClass: 'modalWindowButton myProfile__wayfResetButton', + describedBy: 'wayf-reset-description', + modalHeader: 'profile.my_profile.wayf_reset.modal_title'|trans, + modalContent: modalContent, + buttonRowContent: buttonRowContent, + } %} + {% endfor %} +
+
+
+ {% endblock %} diff --git a/translations/messages.en.php b/translations/messages.en.php index 1363e62fe..91f262f36 100644 --- a/translations/messages.en.php +++ b/translations/messages.en.php @@ -185,6 +185,15 @@ 'link_text' => 'Download overview', 'title' => 'Download', ], + + 'wayf_reset' => [ + 'confirm_button' => 'Reset all', + 'explanation' => 'When logging in through %suiteName%, you can choose to always use the same login method for an application. You can reset this choice for all applications here.', + 'link_text' => 'Reset login method choices', + 'modal_explanation' => 'This removes your saved login method choice for every application. The next time you log in, %suiteName% will ask you to choose a login method again.', + 'modal_title' => 'Reset login method choices', + 'title' => 'Login method choices', + ], ], 'my_services' => [ diff --git a/translations/messages.nl.php b/translations/messages.nl.php index d0a0148db..c8fb225db 100644 --- a/translations/messages.nl.php +++ b/translations/messages.nl.php @@ -188,6 +188,15 @@ 'link_text' => 'Download overzicht', 'title' => 'Downloaden', ], + + 'wayf_reset' => [ + 'confirm_button' => 'Alles resetten', + 'explanation' => 'Bij het inloggen via %suiteName% kun je ervoor kiezen om steeds dezelfde inlogmethode voor een applicatie te gebruiken. Je kunt deze keuze hier voor alle applicaties resetten.', + 'link_text' => 'Inlogmethode-keuzes resetten', + 'modal_explanation' => 'Hiermee verwijder je je opgeslagen inlogmethode-keuze voor elke applicatie. De volgende keer dat je inlogt, vraagt %suiteName% je opnieuw om een inlogmethode te kiezen.', + 'modal_title' => 'Inlogmethode-keuzes resetten', + 'title' => 'Inlogmethode-keuzes', + ], ], 'my_services' => [ diff --git a/translations/messages.pt.php b/translations/messages.pt.php index b1fd3bdac..d79d99def 100644 --- a/translations/messages.pt.php +++ b/translations/messages.pt.php @@ -186,6 +186,15 @@ 'link_text' => 'Download visão geral', 'title' => 'Download', ], + + 'wayf_reset' => [ + 'confirm_button' => 'Repor tudo', + 'explanation' => 'Ao iniciar sessão através de %suiteName%, pode optar por usar sempre o mesmo método de início de sessão para um serviço. Pode repor esta escolha aqui para todos os serviços.', + 'link_text' => 'Repor escolhas de método de início de sessão', + 'modal_explanation' => 'Isto remove a sua escolha guardada de método de início de sessão para todos os serviços. Da próxima vez que iniciar sessão, %suiteName% pedir-lhe-á novamente para escolher um método de início de sessão.', + 'modal_title' => 'Repor escolhas de método de início de sessão', + 'title' => 'Escolhas de método de início de sessão', + ], ], 'my_services' => [