diff --git a/src/Security/BpnAuthenticator.php b/src/Security/BpnAuthenticator.php index 808760d..ac8b2cd 100644 --- a/src/Security/BpnAuthenticator.php +++ b/src/Security/BpnAuthenticator.php @@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge; @@ -23,9 +24,14 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; +use Symfony\Component\Security\Http\Util\TargetPathTrait; class BpnAuthenticator extends AbstractLoginFormAuthenticator implements AuthenticationEntryPointInterface { + use TargetPathTrait; + + private const FIREWALL_NAME = 'main'; + public function __construct( private readonly UrlGeneratorInterface $urlGenerator, private readonly EntityManagerInterface $entityManager, @@ -41,6 +47,21 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent return $this->urlGenerator->generate('app_security_login'); } + /** + * The firewall saves the requested url as the target path right before it calls this. An + * htmx request is a fragment - a modal, a table body - that cannot stand as a page, so + * after the login it must not be where the user lands. htmx does not send the + * X-Requested-With header that would make Symfony skip it on its own. + */ + public function start(Request $request, ?AuthenticationException $authException = null): Response + { + if (true === $request->headers->has('HX-Request') && true === $request->hasSession()) { + $this->removeTargetPath($request->getSession(), self::FIREWALL_NAME); + } + + return parent::start($request, $authException); + } + public function authenticate(Request $request): Passport { $email = mb_strtolower(trim($request->request->getString('_username'))); @@ -92,6 +113,14 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent $this->entityManager->flush(); + // A deeplink the user was sent to the login from. Outstanding teamer checks still + // win: RequiredTeamerCheckSubscriber redirects every app_teamer_ request to them. + if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) { + $this->removeTargetPath($request->getSession(), $firewallName); + + return new RedirectResponse($targetPath); + } + $route = $user->getDefaultRoute(); if ('app_teamer_index' === $route) { $check = $this->requiredTeamerCheckRegistry->getFirstUnresolvedCheck($user); diff --git a/tests/Security/BpnAuthenticatorTest.php b/tests/Security/BpnAuthenticatorTest.php index d76d406..5090b4c 100644 --- a/tests/Security/BpnAuthenticatorTest.php +++ b/tests/Security/BpnAuthenticatorTest.php @@ -24,6 +24,7 @@ use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Core\Exception\UserNotFoundException; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken; #[AllowMockObjectsWithoutExpectations] class BpnAuthenticatorTest extends TestCase @@ -137,6 +138,48 @@ class BpnAuthenticatorTest extends TestCase $this->assertFalse($user->isDisabled()); } + public function testTheLoginLeadsToTheDeeplinkTheUserCameFrom(): void + { + $request = $this->createRequest(); + $request->getSession()->set('_security.main.target_path', 'https://team.example.com/administrative/assignment/detail/abc'); + + $response = $this->createAuthenticator()->onAuthenticationSuccess($request, $this->createToken(), 'main'); + + $this->assertSame('https://team.example.com/administrative/assignment/detail/abc', $response->headers->get('Location')); + // used once: a later login in the same session starts from the default route again + $this->assertFalse($request->getSession()->has('_security.main.target_path')); + } + + public function testWithoutADeeplinkTheLoginLeadsToTheDefaultRoute(): void + { + $response = $this->createAuthenticator()->onAuthenticationSuccess($this->createRequest(), $this->createToken(), 'main'); + + $this->assertSame('/default-route', $response->headers->get('Location')); + } + + public function testAnHtmxRequestIsNotKeptAsTheDeeplink(): void + { + // a modal opened after the session expired: the firewall saved its url just before + $request = $this->createRequest(); + $request->headers->set('HX-Request', 'true'); + $request->getSession()->set('_security.main.target_path', 'https://team.example.com/admin/system/fee/edit/1'); + + $this->createAuthenticator()->start($request); + + $this->assertFalse($request->getSession()->has('_security.main.target_path')); + } + + public function testAPageRequestIsKeptAsTheDeeplink(): void + { + $request = $this->createRequest(); + $request->getSession()->set('_security.main.target_path', 'https://team.example.com/administrative/assignment'); + + $response = $this->createAuthenticator()->start($request); + + $this->assertSame('/login', $response->headers->get('Location')); + $this->assertTrue($request->getSession()->has('_security.main.target_path')); + } + private function createCrmAttributes(): CrmAttributesResponse { return (new CrmAttributesResponse())->setAttributeGroups([new CrmAttributeGroup()]); @@ -148,17 +191,25 @@ class BpnAuthenticatorTest extends TestCase $this->apiClient->method('getCrmAttributes')->willReturn($crmAttributes); } - private function loadUser(): ?User + private function createAuthenticator(): BpnAuthenticator { - $authenticator = new BpnAuthenticator( - $this->createStub(UrlGeneratorInterface::class), + $urlGenerator = $this->createStub(UrlGeneratorInterface::class); + $urlGenerator->method('generate')->willReturnCallback( + static fn (string $route): string => 'app_security_login' === $route ? '/login' : '/default-route' + ); + + return new BpnAuthenticator( + $urlGenerator, $this->createStub(EntityManagerInterface::class), $this->apiClient, $this->createStub(LoggerInterface::class), $this->userDataHandler, $this->createStub(RequiredTeamerCheckRegistry::class), ); + } + private function createRequest(): Request + { $request = new Request(request: [ '_username' => 'user@example.com', '_password' => 'secret', @@ -166,7 +217,19 @@ class BpnAuthenticatorTest extends TestCase ]); $request->setSession(new Session(new MockArraySessionStorage())); - $passport = $authenticator->authenticate($request); + return $request; + } + + private function createToken(): PostAuthenticationToken + { + $user = (new User())->setRoles(['ROLE_TEAM_ADMIN']); + + return new PostAuthenticationToken($user, 'main', $user->getRoles()); + } + + private function loadUser(): ?User + { + $passport = $this->createAuthenticator()->authenticate($this->createRequest()); /** @var UserBadge $badge */ $badge = $passport->getBadge(UserBadge::class);