From 2aa98b81feca856d4ad5f19bc56efac1f5cea436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Sun, 27 Sep 2026 13:42:18 +0200 Subject: [PATCH] feat: dedicated login page for oauth2 logins --- src/Controller/SecurityController.php | 65 ++++-- templates/_partials/_header.html.twig | 14 +- templates/_partials/_login_form.html.twig | 47 +++++ templates/_partials/_logo.html.twig | 15 ++ templates/layout_oauth2.html.twig | 14 ++ templates/security/login.html.twig | 45 +---- templates/security/login_oauth2.html.twig | 37 ++++ tests/Controller/SecurityControllerTest.php | 208 ++++++++++++++++++++ 8 files changed, 376 insertions(+), 69 deletions(-) create mode 100644 templates/_partials/_login_form.html.twig create mode 100644 templates/_partials/_logo.html.twig create mode 100644 templates/layout_oauth2.html.twig create mode 100644 templates/security/login_oauth2.html.twig create mode 100644 tests/Controller/SecurityControllerTest.php diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index e6b85d26..82952878 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -5,6 +5,7 @@ namespace App\Controller; use App\Form\Model\BookingDto; use App\Service\BookingSessionManager; use App\Service\BookingSummaryAssembler; +use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -21,13 +22,28 @@ class SecurityController extends AbstractController Request $request, BookingSessionManager $bookingSessionService, BookingSummaryAssembler $summaryDataService, + ClientManagerInterface $clientManager, ): Response { + // flag this request in the session as external OAuth2 request if + // applicable to immediately logout the current user after successful + // authentication (see App\EventListener\AuthorizationCodeListener). + // Read before the booking flow is decided, because it overrules it. + $session = $request->getSession(); + $targetPath = $session->get('_security.main.target_path'); + $isOauth2 = \is_string($targetPath) && str_contains($targetPath, '/authorize'); + $session->set('_oauth2', $isOauth2); + // Check if this is a booking flow (BookingDto exists in session) $bookingDto = $bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE); - $isBookingFlow = null !== $bookingDto; + $hasPendingBooking = null !== $bookingDto; + + // A pending authorization wins over a half-finished booking. Otherwise the target path + // below is overwritten with booking step 1, the user is sent there after logging in, and + // the application that asked for the authorization never receives its code. + $isBookingFlow = $hasPendingBooking && false === $isOauth2; // If authenticated and in booking flow, proceed to Step 1 - if (null !== $this->getUser() && true === $isBookingFlow) { + if (null !== $this->getUser() && true === $hasPendingBooking) { return $this->redirectToRoute('app_booking_create_step_1'); } @@ -39,21 +55,18 @@ class SecurityController extends AbstractController $error = $authenticationUtils->getLastAuthenticationError(); $lastUsername = $authenticationUtils->getLastUsername(); - // flag this request in the session as external OAuth2 request if - // applicable to immediately logout the current user after successful - // authentication (see App\EventListener\AuthorizationCodeListener). - $session = $request->getSession(); - $targetPath = $session->get('_security.main.target_path'); - $isOauth2 = null !== $targetPath && str_contains($targetPath, '/authorize'); - $session->set('_oauth2', $isOauth2); - // For booking flow, set target path to Step 1 (after successful auth, redirect there) if (true === $isBookingFlow) { $session->set('_security.main.target_path', $this->generateUrl('app_booking_create_step_1')); } - // Render booking login template if in booking flow, otherwise standard login - $template = true === $isBookingFlow ? 'booking/create/authenticate.html.twig' : 'security/login.html.twig'; + // Render booking login template if in booking flow, the self-explaining one when another + // application sent them here to authorize it, otherwise standard login + $template = match (true) { + $isBookingFlow => 'booking/create/authenticate.html.twig', + $isOauth2 => 'security/login_oauth2.html.twig', + default => 'security/login.html.twig', + }; // Fetch CMS data for booking flow (uses cache warmed in IndexController) $cmsData = null; @@ -72,9 +85,37 @@ class SecurityController extends AbstractController 'travel_date_to' => $bookingDto?->travel->dateTo, 'cmsData' => $cmsData, 'oauth2' => $isOauth2, + 'client_name' => $isOauth2 ? $this->authorizingClientName($clientManager, $targetPath) : null, ]); } + /** + * The display name of the application that sent the user here to authorize it, so the login page + * can name it instead of leaving the user wondering why the site changed. + * + * Derived from the pending target path on every render rather than stashed on the session: a + * failed login redirects back to this route and the target path survives, so the name comes back + * with it. Returns null when the client cannot be resolved, and the page falls back to neutral + * wording rather than failing -- this runs before authentication. + */ + private function authorizingClientName(ClientManagerInterface $clientManager, ?string $targetPath): ?string + { + $query = null !== $targetPath ? parse_url($targetPath, \PHP_URL_QUERY) : null; + if (!\is_string($query) || '' === $query) { + return null; + } + + parse_str($query, $params); + $clientId = $params['client_id'] ?? null; + if (!\is_string($clientId) || '' === $clientId) { + return null; + } + + $name = $clientManager->find($clientId)?->getName() ?? ''; + + return '' !== $name ? $name : null; + } + #[Route('/logout', name: 'app_logout')] #[IsGranted('ROLE_USER')] public function logout(): void diff --git a/templates/_partials/_header.html.twig b/templates/_partials/_header.html.twig index 6c1a697d..9e0dbfb4 100644 --- a/templates/_partials/_header.html.twig +++ b/templates/_partials/_header.html.twig @@ -1,18 +1,6 @@
- {# The operating entity depends on where the accommodation is, so its logo wins over the host's #} - {% set country_logo = accommodation|default(null) and accommodation.country ? accommodation.country.logo : null %} - {% if country_logo %} - - {% elseif booking_theme() == 'sbw' %} - - {% elseif booking_theme() == 'ser' %} - - {% elseif booking_theme() == 'snz' %} - - {% else %} - - {% endif %} + {% include '_partials/_logo.html.twig' %}
{% if is_granted('ROLE_USER') %} diff --git a/templates/_partials/_login_form.html.twig b/templates/_partials/_login_form.html.twig new file mode 100644 index 00000000..256dc2c1 --- /dev/null +++ b/templates/_partials/_login_form.html.twig @@ -0,0 +1,47 @@ +{# The login form itself, shared by security/login.html.twig and security/login_oauth2.html.twig so + the field names, the CSRF token id and the error rendering cannot drift between the two pages. + Expects `error` and `last_username` from the including template's context. #} +{% if error %} + {% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %} +{% endif %} +
+
+
+ + +
+
+ +
+ + +
+
+
+ +
+ +
+
diff --git a/templates/_partials/_logo.html.twig b/templates/_partials/_logo.html.twig new file mode 100644 index 00000000..741fb4e1 --- /dev/null +++ b/templates/_partials/_logo.html.twig @@ -0,0 +1,15 @@ +{# The brand logo, extracted from _header.html.twig so the OAuth2 login chrome can show the same + logo without inheriting the account links that go with it. Takes an optional `accommodation`. #} +{# The operating entity depends on where the accommodation is, so its logo wins over the host's #} +{% set country_logo = accommodation|default(null) and accommodation.country ? accommodation.country.logo : null %} +{% if country_logo %} + +{% elseif booking_theme() == 'sbw' %} + +{% elseif booking_theme() == 'ser' %} + +{% elseif booking_theme() == 'snz' %} + +{% else %} + +{% endif %} diff --git a/templates/layout_oauth2.html.twig b/templates/layout_oauth2.html.twig new file mode 100644 index 00000000..299d239e --- /dev/null +++ b/templates/layout_oauth2.html.twig @@ -0,0 +1,14 @@ +{# Chrome for a login that is serving an external OAuth2 authorization. + + Only the logo survives from the normal header: the account and logout links are dead ends in the + middle of an authorization, and the marketing column of the standard login page is noise when the + user did not come here to read about MyE&P. The footer's legal links stay -- this page collects + credentials, so dropping them would be the wrong direction. + + The background is inherited unchanged, which is why the shared form partial's `text-white` + styling still works here. #} +{% extends 'layout.html.twig' %} + +{% block header %} + {% include '_partials/_logo.html.twig' %} +{% endblock %} diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index 50f86a45..61d982ef 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -7,50 +7,7 @@ Login
MyE&P {% include '_partials/_flashes.html.twig' %} - {% if error %} - {% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %} - {% endif %} -
-
-
- - -
-
- -
- - -
-
-
- -
- -
-
+ {% include '_partials/_login_form.html.twig' %}
diff --git a/templates/security/login_oauth2.html.twig b/templates/security/login_oauth2.html.twig new file mode 100644 index 00000000..cadad728 --- /dev/null +++ b/templates/security/login_oauth2.html.twig @@ -0,0 +1,37 @@ +{# The login page as seen by someone who was sent here by another E&P application (ephub, myep-team) + to authorize it. Its job is to explain why they are suddenly on a different website and that they + will be sent back, which the standard login page never said. + + The grid is the one from security/login.html.twig, minus its marketing column: that keeps the + form column at exactly the width it has on the standard login page. #} +{% extends 'layout_oauth2.html.twig' %} + +{% block content %} + +{% endblock %} diff --git a/tests/Controller/SecurityControllerTest.php b/tests/Controller/SecurityControllerTest.php new file mode 100644 index 00000000..08d8a971 --- /dev/null +++ b/tests/Controller/SecurityControllerTest.php @@ -0,0 +1,208 @@ +createController(); + $controller->clientManager + ->expects(self::once()) + ->method('find') + ->with('abc123') + ->willReturn(new Client('EPHub', 'abc123', null)); + + $response = $controller->login(...$this->arguments($controller, self::AUTHORIZE_PATH)); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('security/login_oauth2.html.twig', $controller->renderedView); + self::assertTrue($controller->renderedParameters['oauth2']); + self::assertSame('EPHub', $controller->renderedParameters['client_name']); + } + + public function testWithoutAnAuthorizeTargetPathTheStandardLoginPageIsRendered(): void + { + $controller = $this->createController(); + $controller->clientManager->expects(self::never())->method('find'); + + $response = $controller->login(...$this->arguments($controller, null)); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('security/login.html.twig', $controller->renderedView); + self::assertFalse($controller->renderedParameters['oauth2']); + self::assertNull($controller->renderedParameters['client_name']); + } + + /** + * The name is cosmetic and this runs before authentication, so an unknown client must degrade + * to neutral wording rather than break the login. + */ + public function testAnUnresolvableClientIdFallsBackToNeutralWording(): void + { + $controller = $this->createController(); + $controller->clientManager + ->expects(self::once()) + ->method('find') + ->willReturn(null); + + $response = $controller->login(...$this->arguments($controller, self::AUTHORIZE_PATH)); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('security/login_oauth2.html.twig', $controller->renderedView); + self::assertNull($controller->renderedParameters['client_name']); + } + + public function testAnAuthorizeTargetPathWithoutAClientIdFallsBackToNeutralWording(): void + { + $controller = $this->createController(); + $controller->clientManager->expects(self::never())->method('find'); + + $response = $controller->login(...$this->arguments($controller, '/authorize')); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('security/login_oauth2.html.twig', $controller->renderedView); + self::assertNull($controller->renderedParameters['client_name']); + } + + /** + * A booking left half-finished in the same browser used to overwrite the pending target path + * with booking step 1, so the application that asked for the authorization never got its code. + */ + public function testAPendingAuthorizationWinsOverAHalfFinishedBooking(): void + { + $controller = $this->createController(bookingDto: $this->createBookingDto()); + $controller->clientManager + ->expects(self::once()) + ->method('find') + ->willReturn(new Client('EPHub', 'abc123', null)); + + $session = $this->createSession(self::AUTHORIZE_PATH); + $response = $controller->login(...$this->arguments($controller, self::AUTHORIZE_PATH, $session)); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('security/login_oauth2.html.twig', $controller->renderedView); + self::assertSame(self::AUTHORIZE_PATH, $session->get('_security.main.target_path')); + } + + public function testAHalfFinishedBookingStillWinsWhenThereIsNoAuthorization(): void + { + $controller = $this->createController(bookingDto: $this->createBookingDto()); + $controller->clientManager->expects(self::never())->method('find'); + + $session = $this->createSession(null); + $response = $controller->login(...$this->arguments($controller, null, $session)); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('booking/create/authenticate.html.twig', $controller->renderedView); + self::assertSame('/booking/create/step-1', $session->get('_security.main.target_path')); + } + + /** + * @return array{AuthenticationUtils, Request, BookingSessionManager, BookingSummaryAssembler, ClientManagerInterface} + */ + private function arguments(TestableSecurityController $controller, ?string $targetPath, ?Session $session = null): array + { + $request = Request::create('/', 'GET'); + $request->setSession($session ?? $this->createSession($targetPath)); + + return [ + $controller->authenticationUtils, + $request, + $controller->bookingSessionManager, + $controller->summaryAssembler, + $controller->clientManager, + ]; + } + + private function createSession(?string $targetPath): Session + { + $session = new Session(new MockArraySessionStorage()); + if (null !== $targetPath) { + $session->set('_security.main.target_path', $targetPath); + } + + return $session; + } + + private function createBookingDto(): BookingDto + { + $travel = new Travel(); + $travel->id = 123; + + return new BookingDto($travel, 77); + } + + private function createController(?BookingDto $bookingDto = null): TestableSecurityController + { + $authenticationUtils = $this->createStub(AuthenticationUtils::class); + $authenticationUtils->method('getLastAuthenticationError')->willReturn(null); + $authenticationUtils->method('getLastUsername')->willReturn(''); + + $bookingSessionManager = $this->createStub(BookingSessionManager::class); + $bookingSessionManager->method('getBookingDto')->willReturn($bookingDto); + + return new TestableSecurityController( + $authenticationUtils, + $bookingSessionManager, + $this->createStub(BookingSummaryAssembler::class), + $this->createMock(ClientManagerInterface::class), + ); + } +} + +final class TestableSecurityController extends SecurityController +{ + /** + * @var array + */ + public array $renderedParameters = []; + + public string $renderedView = ''; + + public function __construct( + public readonly AuthenticationUtils $authenticationUtils, + public readonly BookingSessionManager $bookingSessionManager, + public readonly BookingSummaryAssembler $summaryAssembler, + public readonly ClientManagerInterface $clientManager, + ) { + } + + protected function getUser(): ?UserInterface + { + return null; + } + + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + $this->renderedView = $view; + $this->renderedParameters = $parameters; + + return new Response('ok'); + } + + protected function generateUrl(string $route, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string + { + return '/booking/create/step-1'; + } +}