From af2f5fefbb9c765d72bb1619ad37e9cd6c316b41 Mon Sep 17 00:00:00 2001
From: konradoboza <konrad.oboza@ibexa.co>
Date: Fri, 28 Jun 2024 16:07:05 +0200
Subject: [PATCH] IBX-8323: Reworked RepositoryAuthenticationProvider and moved
 its logic to a dedicated subscriber

---
 phpstan-baseline.neon                         |  80 ------
 .../Compiler/SecurityPass.php                 |  13 -
 src/bundle/Core/Resources/config/security.yml |   8 +-
 ...RepositoryUserAuthenticationSubscriber.php | 112 ++++++++
 .../RepositoryAuthenticationProvider.php      | 133 ---------
 .../Compiler/SecurityPassTest.php             |   5 -
 ...sitoryUserAuthenticationSubscriberTest.php | 152 ++++++++++
 .../RepositoryAuthenticationProviderTest.php  | 272 ------------------
 8 files changed, 271 insertions(+), 504 deletions(-)
 create mode 100644 src/lib/MVC/Symfony/Security/Authentication/EventSubscriber/RepositoryUserAuthenticationSubscriber.php
 delete mode 100644 src/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProvider.php
 create mode 100644 tests/lib/MVC/Symfony/Security/Authentication/EventSubscriber/RepositoryUserAuthenticationSubscriberTest.php
 delete mode 100644 tests/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProviderTest.php

diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 32a513cc32..ba1453f7f4 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -12255,36 +12255,6 @@ parameters:
 			count: 1
 			path: src/lib/MVC/Symfony/Security/Authentication/RememberMeRepositoryAuthenticationProvider.php
 
-		-
-			message: "#^Dead catch \\- Ibexa\\\\Core\\\\Repository\\\\User\\\\Exception\\\\UnsupportedPasswordHashType is never thrown in the try block\\.$#"
-			count: 1
-			path: src/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProvider.php
-
-		-
-			message: "#^Method Ibexa\\\\Core\\\\MVC\\\\Symfony\\\\Security\\\\Authentication\\\\RepositoryAuthenticationProvider\\:\\:checkAuthentication\\(\\) has no return type specified\\.$#"
-			count: 1
-			path: src/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProvider.php
-
-		-
-			message: "#^Method Ibexa\\\\Core\\\\MVC\\\\Symfony\\\\Security\\\\Authentication\\\\RepositoryAuthenticationProvider\\:\\:setConstantAuthTime\\(\\) has no return type specified\\.$#"
-			count: 1
-			path: src/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProvider.php
-
-		-
-			message: "#^Method Ibexa\\\\Core\\\\MVC\\\\Symfony\\\\Security\\\\Authentication\\\\RepositoryAuthenticationProvider\\:\\:setPermissionResolver\\(\\) has no return type specified\\.$#"
-			count: 1
-			path: src/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProvider.php
-
-		-
-			message: "#^Method Ibexa\\\\Core\\\\MVC\\\\Symfony\\\\Security\\\\Authentication\\\\RepositoryAuthenticationProvider\\:\\:setUserService\\(\\) has no return type specified\\.$#"
-			count: 1
-			path: src/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProvider.php
-
-		-
-			message: "#^Method Ibexa\\\\Core\\\\MVC\\\\Symfony\\\\Security\\\\Authentication\\\\RepositoryAuthenticationProvider\\:\\:startConstantTimer\\(\\) has no return type specified\\.$#"
-			count: 1
-			path: src/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProvider.php
-
 		-
 			message: "#^Method Ibexa\\\\Core\\\\MVC\\\\Symfony\\\\Security\\\\Authorization\\\\Attribute\\:\\:__construct\\(\\) has parameter \\$function with no type specified\\.$#"
 			count: 1
@@ -47075,56 +47045,6 @@ parameters:
 			count: 1
 			path: tests/lib/MVC/Symfony/Security/Authentication/RememberMeRepositoryAuthenticationProviderTest.php
 
-		-
-			message: "#^Call to an undefined method Symfony\\\\Component\\\\Security\\\\Core\\\\User\\\\UserProviderInterface\\:\\:method\\(\\)\\.$#"
-			count: 1
-			path: tests/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProviderTest.php
-
-		-
-			message: "#^Method Ibexa\\\\Tests\\\\Core\\\\MVC\\\\Symfony\\\\Security\\\\Authentication\\\\RepositoryAuthenticationProviderTest\\:\\:testAuthenticationNotEzUser\\(\\) has no return type specified\\.$#"
-			count: 1
-			path: tests/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProviderTest.php
-
-		-
-			message: "#^Method Ibexa\\\\Tests\\\\Core\\\\MVC\\\\Symfony\\\\Security\\\\Authentication\\\\RepositoryAuthenticationProviderTest\\:\\:testCheckAuthentication\\(\\) has no return type specified\\.$#"
-			count: 1
-			path: tests/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProviderTest.php
-
-		-
-			message: "#^Method Ibexa\\\\Tests\\\\Core\\\\MVC\\\\Symfony\\\\Security\\\\Authentication\\\\RepositoryAuthenticationProviderTest\\:\\:testCheckAuthenticationAlreadyLoggedIn\\(\\) has no return type specified\\.$#"
-			count: 1
-			path: tests/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProviderTest.php
-
-		-
-			message: "#^Method Ibexa\\\\Tests\\\\Core\\\\MVC\\\\Symfony\\\\Security\\\\Authentication\\\\RepositoryAuthenticationProviderTest\\:\\:testCheckAuthenticationCredentialsChanged\\(\\) has no return type specified\\.$#"
-			count: 1
-			path: tests/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProviderTest.php
-
-		-
-			message: "#^Method Ibexa\\\\Tests\\\\Core\\\\MVC\\\\Symfony\\\\Security\\\\Authentication\\\\RepositoryAuthenticationProviderTest\\:\\:testCheckAuthenticationFailed\\(\\) has no return type specified\\.$#"
-			count: 1
-			path: tests/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProviderTest.php
-
-		-
-			message: "#^Method Ibexa\\\\Tests\\\\Core\\\\MVC\\\\Symfony\\\\Security\\\\Authentication\\\\RepositoryAuthenticationProviderTest\\:\\:testCheckAuthenticationFailedWhenPasswordInUnsupportedFormat\\(\\) has no return type specified\\.$#"
-			count: 1
-			path: tests/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProviderTest.php
-
-		-
-			message: "#^Parameter \\#1 \\$user of class Symfony\\\\Component\\\\Security\\\\Core\\\\Authentication\\\\Token\\\\UsernamePasswordToken constructor expects Symfony\\\\Component\\\\Security\\\\Core\\\\User\\\\UserInterface, string given\\.$#"
-			count: 6
-			path: tests/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProviderTest.php
-
-		-
-			message: "#^Parameter \\#3 \\$roles of class Symfony\\\\Component\\\\Security\\\\Core\\\\Authentication\\\\Token\\\\UsernamePasswordToken constructor expects array\\<string\\>, string given\\.$#"
-			count: 9
-			path: tests/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProviderTest.php
-
-		-
-			message: "#^Parameter \\#4 \\$hasherFactory of class Ibexa\\\\Core\\\\MVC\\\\Symfony\\\\Security\\\\Authentication\\\\RepositoryAuthenticationProvider constructor expects Symfony\\\\Component\\\\PasswordHasher\\\\Hasher\\\\PasswordHasherFactoryInterface, PHPUnit\\\\Framework\\\\MockObject\\\\MockObject&Symfony\\\\Component\\\\Security\\\\Core\\\\Encoder\\\\EncoderFactoryInterface given\\.$#"
-			count: 1
-			path: tests/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProviderTest.php
-
 		-
 			message: "#^Method Ibexa\\\\Tests\\\\Core\\\\MVC\\\\Symfony\\\\Security\\\\HttpUtilsTest\\:\\:checkRequestPathProvider\\(\\) has no return type specified\\.$#"
 			count: 1
diff --git a/src/bundle/Core/DependencyInjection/Compiler/SecurityPass.php b/src/bundle/Core/DependencyInjection/Compiler/SecurityPass.php
index d9520db0cd..8c3a61cdc6 100644
--- a/src/bundle/Core/DependencyInjection/Compiler/SecurityPass.php
+++ b/src/bundle/Core/DependencyInjection/Compiler/SecurityPass.php
@@ -11,7 +11,6 @@
 use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface;
 use Ibexa\Core\MVC\Symfony\Security\Authentication\DefaultAuthenticationSuccessHandler;
 use Ibexa\Core\MVC\Symfony\Security\Authentication\GuardRepositoryAuthenticationProvider;
-use Ibexa\Core\MVC\Symfony\Security\Authentication\RememberMeRepositoryAuthenticationProvider;
 use Ibexa\Core\MVC\Symfony\Security\HttpUtils;
 use Ibexa\Core\MVC\Symfony\SiteAccess;
 use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
@@ -24,14 +23,9 @@
  */
 final class SecurityPass implements CompilerPassInterface
 {
-    public const string CONSTANT_AUTH_TIME_SETTING = 'ibexa.security.authentication.constant_auth_time';
-
-    public const float CONSTANT_AUTH_TIME_DEFAULT = 1.0;
-
     public function process(ContainerBuilder $container): void
     {
         if (
-            !$container->hasDefinition('security.authentication.provider.rememberme') ||
             !$container->hasDefinition('security.authentication.provider.guard')
         ) {
             return;
@@ -39,13 +33,6 @@ public function process(ContainerBuilder $container): void
 
         $permissionResolverRef = new Reference(PermissionResolver::class);
 
-        $rememberMeAuthenticationProviderDef = $container->findDefinition('security.authentication.provider.rememberme');
-        $rememberMeAuthenticationProviderDef->setClass(RememberMeRepositoryAuthenticationProvider::class);
-        $rememberMeAuthenticationProviderDef->addMethodCall(
-            'setPermissionResolver',
-            [$permissionResolverRef]
-        );
-
         $guardAuthenticationProviderDef = $container->findDefinition('security.authentication.provider.guard');
         $guardAuthenticationProviderDef->setClass(GuardRepositoryAuthenticationProvider::class);
         $guardAuthenticationProviderDef->addMethodCall(
diff --git a/src/bundle/Core/Resources/config/security.yml b/src/bundle/Core/Resources/config/security.yml
index cc0f36177d..b93f16db4f 100644
--- a/src/bundle/Core/Resources/config/security.yml
+++ b/src/bundle/Core/Resources/config/security.yml
@@ -2,7 +2,7 @@ parameters:
     # Constant authentication execution time in seconds (float). Blocks timing attacks.
     # Must be larger than expected real execution time, with a good margin.
     # If set to zero, constant time authentication is disabled. Do not do this on production environments.
-    ibexa.security.authentication.constant_auth_time: !php/const Ibexa\Bundle\Core\DependencyInjection\Compiler\SecurityPass::CONSTANT_AUTH_TIME_DEFAULT
+    ibexa.security.authentication.constant_auth_time: 1.0
 
 services:
     Ibexa\Core\MVC\Symfony\Security\User\UsernameProvider:
@@ -44,6 +44,12 @@ services:
     ibexa.security.user_provider.username: '@Ibexa\Core\MVC\Symfony\Security\User\UsernameProvider'
     ibexa.security.user_provider.email: '@Ibexa\Core\MVC\Symfony\Security\User\EmailProvider'
 
+    Ibexa\Core\MVC\Symfony\Security\Authentication\EventSubscriber\RepositoryUserAuthenticationSubscriber:
+        autowire: true
+        autoconfigure: true
+        arguments:
+            $constantAuthTime: '%ibexa.security.authentication.constant_auth_time%'
+
     Ibexa\Core\MVC\Symfony\Security\Authentication\EventSubscriber\OnAuthenticationTokenCreatedRepositoryUserSubscriber:
         autowire: true
         autoconfigure: true
diff --git a/src/lib/MVC/Symfony/Security/Authentication/EventSubscriber/RepositoryUserAuthenticationSubscriber.php b/src/lib/MVC/Symfony/Security/Authentication/EventSubscriber/RepositoryUserAuthenticationSubscriber.php
new file mode 100644
index 0000000000..317d578841
--- /dev/null
+++ b/src/lib/MVC/Symfony/Security/Authentication/EventSubscriber/RepositoryUserAuthenticationSubscriber.php
@@ -0,0 +1,112 @@
+<?php
+
+/**
+ * @copyright Copyright (C) Ibexa AS. All rights reserved.
+ * @license For full copyright and license information view LICENSE file distributed with this source code.
+ */
+declare(strict_types=1);
+
+namespace Ibexa\Core\MVC\Symfony\Security\Authentication\EventSubscriber;
+
+use Exception;
+use Ibexa\Contracts\Core\Repository\Exceptions\PasswordInUnsupportedFormatException;
+use Ibexa\Contracts\Core\Repository\UserService;
+use Ibexa\Core\MVC\Symfony\Security\UserInterface as IbexaUserInterface;
+use Ibexa\Core\Repository\User\Exception\UnsupportedPasswordHashType;
+use Psr\Log\LoggerAwareTrait;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
+use Symfony\Component\Security\Http\Event\CheckPassportEvent;
+
+final class RepositoryUserAuthenticationSubscriber implements EventSubscriberInterface
+{
+    use LoggerAwareTrait;
+
+    public const string CONSTANT_AUTH_TIME_SETTING = 'ibexa.security.authentication.constant_auth_time';
+
+    private const int USLEEP_MULTIPLIER = 1000000;
+
+    public function __construct(
+        private readonly RequestStack $requestStack,
+        private readonly UserService $userService,
+        private readonly float $constantAuthTime,
+        ?LoggerInterface $logger = null
+    ) {
+        $this->logger = $logger ?? new NullLogger();
+    }
+
+    public static function getSubscribedEvents(): array
+    {
+        return [
+            CheckPassportEvent::class => ['validateRepositoryUser'],
+        ];
+    }
+
+    public function validateRepositoryUser(CheckPassportEvent $event): void
+    {
+        $request = $this->requestStack->getCurrentRequest();
+        if ($request === null) {
+            return;
+        }
+
+        $badge = $event->getPassport()->getBadge(UserBadge::class);
+        if (!$badge instanceof UserBadge) {
+            return;
+        }
+
+        $user = $badge->getUser();
+        if (!$user instanceof IbexaUserInterface) {
+            return;
+        }
+
+        $startTime = $this->startConstantTimer();
+        try {
+            $this->userService->checkUserCredentials(
+                $user->getAPIUser(),
+                $user->getPassword() ?? ''
+            );
+
+            $event->getAuthenticator()->authenticate($request);
+        } catch (UnsupportedPasswordHashType $exception) {
+            $this->sleepUsingConstantTimer($startTime);
+
+            throw new PasswordInUnsupportedFormatException($exception);
+        } catch (Exception $e) {
+            $this->sleepUsingConstantTimer($startTime);
+
+            throw $e;
+        }
+
+        $this->sleepUsingConstantTimer($startTime);
+    }
+
+    private function startConstantTimer(): float
+    {
+        return microtime(true);
+    }
+
+    private function sleepUsingConstantTimer(float $startTime): void
+    {
+        if ($this->constantAuthTime <= 0.0) {
+            return;
+        }
+
+        $remainingTime = $this->constantAuthTime - (microtime(true) - $startTime);
+        if ($remainingTime > 0) {
+            $microseconds = $remainingTime * self::USLEEP_MULTIPLIER;
+
+            usleep((int)$microseconds);
+        } elseif ($this->logger) {
+            $this->logger->warning(
+                sprintf(
+                    'Authentication took longer than the configured constant time. Consider increasing the value of %s',
+                    self::CONSTANT_AUTH_TIME_SETTING
+                ),
+                [__CLASS__]
+            );
+        }
+    }
+}
diff --git a/src/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProvider.php b/src/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProvider.php
deleted file mode 100644
index 2bbbec5a54..0000000000
--- a/src/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProvider.php
+++ /dev/null
@@ -1,133 +0,0 @@
-<?php
-
-/**
- * @copyright Copyright (C) Ibexa AS. All rights reserved.
- * @license For full copyright and license information view LICENSE file distributed with this source code.
- */
-
-namespace Ibexa\Core\MVC\Symfony\Security\Authentication;
-
-use Ibexa\Bundle\Core\DependencyInjection\Compiler\SecurityPass;
-use Ibexa\Contracts\Core\Repository\Exceptions\PasswordInUnsupportedFormatException;
-use Ibexa\Contracts\Core\Repository\PermissionResolver;
-use Ibexa\Contracts\Core\Repository\UserService;
-use Ibexa\Core\MVC\Symfony\Security\UserInterface as IbexaUserInterface;
-use Ibexa\Core\Repository\User\Exception\UnsupportedPasswordHashType;
-use Psr\Log\LoggerAwareInterface;
-use Psr\Log\LoggerAwareTrait;
-use Symfony\Component\Security\Core\Authentication\Provider\DaoAuthenticationProvider;
-use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
-use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
-use Symfony\Component\Security\Core\Exception\BadCredentialsException;
-use Symfony\Component\Security\Core\User\UserInterface;
-
-class RepositoryAuthenticationProvider extends DaoAuthenticationProvider implements LoggerAwareInterface
-{
-    use LoggerAwareTrait;
-
-    private const USLEEP_MULTIPLIER = 1000000;
-
-    /** @var float|null */
-    private $constantAuthTime;
-
-    /** @var \Ibexa\Contracts\Core\Repository\PermissionResolver */
-    private $permissionResolver;
-
-    /** @var \Ibexa\Contracts\Core\Repository\UserService */
-    private $userService;
-
-    public function setConstantAuthTime(float $constantAuthTime)
-    {
-        $this->constantAuthTime = $constantAuthTime;
-    }
-
-    public function setPermissionResolver(PermissionResolver $permissionResolver)
-    {
-        $this->permissionResolver = $permissionResolver;
-    }
-
-    public function setUserService(UserService $userService)
-    {
-        $this->userService = $userService;
-    }
-
-    protected function checkAuthentication(UserInterface $user, UsernamePasswordToken $token)
-    {
-        if (!$user instanceof IbexaUserInterface) {
-            parent::checkAuthentication($user, $token);
-
-            return;
-        }
-
-        $apiUser = $user->getAPIUser();
-
-        // $currentUser can either be an instance of UserInterface or just the username (e.g. during form login).
-        /** @var \Ibexa\Core\MVC\Symfony\Security\UserInterface|string $currentUser */
-        $currentUser = $token->getUser();
-        if ($currentUser instanceof UserInterface) {
-            if ($currentUser->getAPIUser()->passwordHash !== $apiUser->passwordHash) {
-                throw new BadCredentialsException('The credentials were changed in another session.');
-            }
-
-            $apiUser = $currentUser->getAPIUser();
-        } else {
-            $credentialsValid = $this->userService->checkUserCredentials($apiUser, $token->getCredentials());
-
-            if (!$credentialsValid) {
-                throw new BadCredentialsException('Invalid credentials', 0);
-            }
-        }
-
-        // Finally inject current user in the Repository
-        $this->permissionResolver->setCurrentUserReference($apiUser);
-    }
-
-    /**
-     * @throws \Ibexa\Contracts\Core\Repository\Exceptions\PasswordInUnsupportedFormatException
-     */
-    public function authenticate(TokenInterface $token)
-    {
-        $startTime = $this->startConstantTimer();
-
-        try {
-            $result = parent::authenticate($token);
-        } catch (UnsupportedPasswordHashType $exception) {
-            $this->sleepUsingConstantTimer($startTime);
-            throw new PasswordInUnsupportedFormatException($exception);
-        } catch (\Exception $e) {
-            $this->sleepUsingConstantTimer($startTime);
-            throw $e;
-        }
-
-        $this->sleepUsingConstantTimer($startTime);
-
-        return $result;
-    }
-
-    private function startConstantTimer()
-    {
-        return microtime(true);
-    }
-
-    private function sleepUsingConstantTimer(float $startTime): void
-    {
-        if ($this->constantAuthTime <= 0.0) {
-            return;
-        }
-
-        $remainingTime = $this->constantAuthTime - (microtime(true) - $startTime);
-        if ($remainingTime > 0) {
-            $microseconds = $remainingTime * self::USLEEP_MULTIPLIER;
-
-            usleep((int)$microseconds);
-        } elseif ($this->logger) {
-            $this->logger->warning(
-                sprintf(
-                    'Authentication took longer than the configured constant time. Consider increasing the value of %s',
-                    SecurityPass::CONSTANT_AUTH_TIME_SETTING
-                ),
-                [static::class]
-            );
-        }
-    }
-}
diff --git a/tests/bundle/Core/DependencyInjection/Compiler/SecurityPassTest.php b/tests/bundle/Core/DependencyInjection/Compiler/SecurityPassTest.php
index d6689f86d4..1ea86e598d 100644
--- a/tests/bundle/Core/DependencyInjection/Compiler/SecurityPassTest.php
+++ b/tests/bundle/Core/DependencyInjection/Compiler/SecurityPassTest.php
@@ -41,11 +41,6 @@ public function testAlteredDaoAuthenticationProvider(): void
     {
         $this->compile();
 
-        $this->assertContainerBuilderHasServiceDefinitionWithMethodCall(
-            'security.authentication.provider.rememberme',
-            'setPermissionResolver',
-            [new Reference(PermissionResolver::class)]
-        );
         $this->assertContainerBuilderHasServiceDefinitionWithMethodCall(
             'security.authentication.provider.guard',
             'setPermissionResolver',
diff --git a/tests/lib/MVC/Symfony/Security/Authentication/EventSubscriber/RepositoryUserAuthenticationSubscriberTest.php b/tests/lib/MVC/Symfony/Security/Authentication/EventSubscriber/RepositoryUserAuthenticationSubscriberTest.php
new file mode 100644
index 0000000000..3bb189080c
--- /dev/null
+++ b/tests/lib/MVC/Symfony/Security/Authentication/EventSubscriber/RepositoryUserAuthenticationSubscriberTest.php
@@ -0,0 +1,152 @@
+<?php
+
+/**
+ * @copyright Copyright (C) Ibexa AS. All rights reserved.
+ * @license For full copyright and license information view LICENSE file distributed with this source code.
+ */
+declare(strict_types=1);
+
+namespace Ibexa\Tests\Core\MVC\Symfony\Security\Authentication\EventSubscriber;
+
+use Exception;
+use Ibexa\Contracts\Core\Repository\Exceptions\PasswordInUnsupportedFormatException;
+use Ibexa\Contracts\Core\Repository\UserService;
+use Ibexa\Core\MVC\Symfony\Security\Authentication\EventSubscriber\RepositoryUserAuthenticationSubscriber;
+use Ibexa\Core\MVC\Symfony\Security\User;
+use Ibexa\Core\Repository\User\Exception\UnsupportedPasswordHashType;
+use Ibexa\Core\Repository\Values\User\User as APIUser;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\Security\Core\User\UserInterface;
+use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
+use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
+use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
+use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
+use Symfony\Component\Security\Http\Event\CheckPassportEvent;
+use Symfony\Component\Stopwatch\Stopwatch;
+
+final class RepositoryUserAuthenticationSubscriberTest extends TestCase
+{
+    private const int UNSUPPORTED_USER_PASSWORD_HASH_TYPE = 5;
+
+    public function testGetSubscribedEvents(): void
+    {
+        self::assertEquals(
+            [
+                CheckPassportEvent::class => ['validateRepositoryUser'],
+            ],
+            $this->getSubscriber()->getSubscribedEvents()
+        );
+    }
+
+    public function testCheckAuthenticationFailedWhenPasswordInUnsupportedFormat(): void
+    {
+        $apiUser = new APIUser();
+        $user = $this->createMock(User::class);
+        $user
+            ->expects(self::once())
+            ->method('getAPIUser')
+            ->willReturn($apiUser);
+        $user
+            ->method('getPassword')
+            ->willReturn('my_password');
+
+        $userService = $this->createMock(UserService::class);
+        $userService
+            ->expects(self::once())
+            ->method('checkUserCredentials')
+            ->with($apiUser, 'my_password')
+            ->willThrowException(
+                new UnsupportedPasswordHashType(self::UNSUPPORTED_USER_PASSWORD_HASH_TYPE)
+            );
+
+        $this->expectException(PasswordInUnsupportedFormatException::class);
+
+        $this->getSubscriber($userService)->validateRepositoryUser(
+            $this->getCheckPassportEvent($user)
+        );
+    }
+
+    public function testAuthenticateInConstantTime(): void
+    {
+        $constantAuthTime = 1.0;
+        $stopwatch = new Stopwatch();
+        $stopwatch->start('authenticate_constant_time_test');
+
+        try {
+            $this->getSubscriber(null, $constantAuthTime)->validateRepositoryUser(
+                $this->getCheckPassportEvent()
+            );
+        } catch (Exception) {
+            // We don't care, we just need test execution to continue
+        }
+
+        $duration = $stopwatch->stop('authenticate_constant_time_test')->getDuration();
+
+        self::assertGreaterThanOrEqual($constantAuthTime * 1000, $duration);
+    }
+
+    public function testAuthenticateWarningOnConstantTimeExceeded(): void
+    {
+        $logger = $this->createMock(LoggerInterface::class);
+        $logger
+            ->expects(self::once())
+            ->method('warning')
+            ->with('Authentication took longer than the configured constant time. Consider increasing the value of ' . RepositoryUserAuthenticationSubscriber::CONSTANT_AUTH_TIME_SETTING);
+
+        // constant auth time is much too short, but not zero, which would disable the check
+        $this->getSubscriber(null, 0.0000001, $logger)->validateRepositoryUser(
+            $this->getCheckPassportEvent()
+        );
+    }
+
+    public function testAuthenticateConstantTimeDisabled(): void
+    {
+        $logger = $this->createMock(LoggerInterface::class);
+        $logger->expects(self::never())->method('warning');
+
+        $this->getSubscriber(null, 0.0, $logger)->validateRepositoryUser(
+            $this->getCheckPassportEvent()
+        );
+    }
+
+    private function getSubscriber(
+        ?UserService $userService = null,
+        float $constantAuthTime = 1.0,
+        ?LoggerInterface $logger = null
+    ): RepositoryUserAuthenticationSubscriber {
+        $request = $this->createMock(Request::class);
+        $requestStack = $this->createMock(RequestStack::class);
+        $requestStack
+            ->method('getCurrentRequest')
+            ->willReturn($request);
+
+        return new RepositoryUserAuthenticationSubscriber(
+            $requestStack,
+            $userService ?? $this->createMock(UserService::class),
+            $constantAuthTime,
+            $logger ?? $this->createMock(LoggerInterface::class)
+        );
+    }
+
+    private function getCheckPassportEvent(?UserInterface $user = null): CheckPassportEvent
+    {
+        $user = $user ?? $this->createMock(User::class);
+
+        $userProvider = $this->createMock(User\APIUserProviderInterface::class);
+        $userProvider
+            ->expects(self::once())
+            ->method('loadUserByUsername')
+            ->willReturn($user);
+
+        return new CheckPassportEvent(
+            $this->createMock(AuthenticatorInterface::class),
+            new Passport(
+                new UserBadge($user->getUsername(), [$userProvider, 'loadUserByUsername']),
+                new PasswordCredentials($user->getPassword() ?? '')
+            )
+        );
+    }
+}
diff --git a/tests/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProviderTest.php b/tests/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProviderTest.php
deleted file mode 100644
index dff44088c9..0000000000
--- a/tests/lib/MVC/Symfony/Security/Authentication/RepositoryAuthenticationProviderTest.php
+++ /dev/null
@@ -1,272 +0,0 @@
-<?php
-
-/**
- * @copyright Copyright (C) Ibexa AS. All rights reserved.
- * @license For full copyright and license information view LICENSE file distributed with this source code.
- */
-
-namespace Ibexa\Tests\Core\MVC\Symfony\Security\Authentication;
-
-use Ibexa\Bundle\Core\DependencyInjection\Compiler\SecurityPass;
-use Ibexa\Contracts\Core\Repository\Exceptions\PasswordInUnsupportedFormatException;
-use Ibexa\Contracts\Core\Repository\PermissionResolver;
-use Ibexa\Contracts\Core\Repository\UserService;
-use Ibexa\Contracts\Core\Repository\Values\User\User as APIUser;
-use Ibexa\Core\MVC\Symfony\Security\Authentication\RepositoryAuthenticationProvider;
-use Ibexa\Core\MVC\Symfony\Security\User;
-use Ibexa\Core\Repository\User\Exception\UnsupportedPasswordHashType;
-use PHPUnit\Framework\TestCase;
-use Psr\Log\LoggerInterface;
-use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
-use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
-use Symfony\Component\Security\Core\Exception\AuthenticationException;
-use Symfony\Component\Security\Core\Exception\BadCredentialsException;
-use Symfony\Component\Security\Core\User\UserCheckerInterface;
-use Symfony\Component\Security\Core\User\UserInterface;
-use Symfony\Component\Security\Core\User\UserProviderInterface;
-use Symfony\Component\Stopwatch\Stopwatch;
-
-class RepositoryAuthenticationProviderTest extends TestCase
-{
-    private const UNSUPPORTED_USER_PASSWORD_HASH_TYPE = 5;
-
-    /** @var \PHPUnit\Framework\MockObject\MockObject|\Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface */
-    private $encoderFactory;
-
-    /** @var \Ibexa\Core\MVC\Symfony\Security\Authentication\RepositoryAuthenticationProvider */
-    private $authProvider;
-
-    /** @var \Ibexa\Contracts\Core\Repository\PermissionResolver|\PHPUnit\Framework\MockObject\MockObject */
-    private $permissionResolver;
-
-    /**
-     * @var \Symfony\Component\Security\Core\User\UserProviderInterface
-     */
-    private $userProvider;
-
-    /** @var \Ibexa\Contracts\Core\Repository\UserService|\PHPUnit\Framework\MockObject\MockObject */
-    private $userService;
-
-    /** @var \Psr\Log\LoggerInterface|\PHPUnit\Framework\MockObject\MockObject */
-    private $logger;
-
-    protected function setUp(): void
-    {
-        parent::setUp();
-        $this->encoderFactory = $this->createMock(EncoderFactoryInterface::class);
-        $this->userProvider = $this->createMock(UserProviderInterface::class);
-        $this->authProvider = new RepositoryAuthenticationProvider(
-            $this->userProvider,
-            $this->createMock(UserCheckerInterface::class),
-            'foo',
-            $this->encoderFactory
-        );
-        $this->permissionResolver = $this->createMock(PermissionResolver::class);
-        $this->userService = $this->createMock(UserService::class);
-        $this->authProvider->setPermissionResolver($this->permissionResolver);
-        $this->authProvider->setUserService($this->userService);
-
-        $this->logger = $this->createMock(LoggerInterface::class);
-        $this->authProvider->setLogger($this->logger);
-    }
-
-    public function testAuthenticationNotEzUser()
-    {
-        $password = 'some_encoded_password';
-        $user = $this->createMock(UserInterface::class);
-        $user
-            ->expects(self::any())
-            ->method('getPassword')
-            ->will(self::returnValue($password));
-
-        $tokenUser = $this->createMock(UserInterface::class);
-        $tokenUser
-            ->expects(self::any())
-            ->method('getPassword')
-            ->will(self::returnValue($password));
-        $token = new UsernamePasswordToken($tokenUser, 'foo', 'bar');
-
-        $method = new \ReflectionMethod($this->authProvider, 'checkAuthentication');
-        $method->setAccessible(true);
-        $method->invoke($this->authProvider, $user, $token);
-    }
-
-    public function testCheckAuthenticationCredentialsChanged()
-    {
-        $this->expectException(BadCredentialsException::class);
-
-        $apiUser = $this->getMockBuilder(APIUser::class)
-            ->setConstructorArgs([['passwordHash' => 'some_encoded_password']])
-            ->setMethods(['getUserId'])
-            ->getMockForAbstractClass();
-        $apiUser
-            ->expects(self::once())
-            ->method('getUserId')
-            ->will(self::returnValue(456));
-        $tokenUser = new User($apiUser);
-        $token = new UsernamePasswordToken($tokenUser, 'foo', 'bar');
-
-        $renewedApiUser = $this->getMockBuilder(APIUser::class)
-            ->setConstructorArgs([['passwordHash' => 'renewed_encoded_password']])
-            ->getMockForAbstractClass();
-
-        $user = $this->createMock(User::class);
-        $user
-            ->expects(self::any())
-            ->method('getAPIUser')
-            ->will(self::returnValue($renewedApiUser));
-
-        $method = new \ReflectionMethod($this->authProvider, 'checkAuthentication');
-        $method->setAccessible(true);
-        $method->invoke($this->authProvider, $user, $token);
-    }
-
-    public function testCheckAuthenticationAlreadyLoggedIn()
-    {
-        $password = 'encoded_password';
-
-        $apiUser = $this->getMockBuilder(APIUser::class)
-            ->setConstructorArgs([['passwordHash' => $password]])
-            ->setMethods(['getUserId'])
-            ->getMockForAbstractClass();
-        $tokenUser = new User($apiUser);
-        $token = new UsernamePasswordToken($tokenUser, 'foo', 'bar');
-
-        $user = $this->createMock(User::class);
-        $user
-            ->expects(self::once())
-            ->method('getAPIUser')
-            ->will(self::returnValue($apiUser));
-
-        $this->permissionResolver
-            ->expects(self::once())
-            ->method('setCurrentUserReference')
-            ->with($apiUser);
-
-        $method = new \ReflectionMethod($this->authProvider, 'checkAuthentication');
-        $method->setAccessible(true);
-        $method->invoke($this->authProvider, $user, $token);
-    }
-
-    public function testCheckAuthenticationFailed()
-    {
-        $this->expectException(BadCredentialsException::class);
-
-        $apiUser = $this->createMock(APIUser::class);
-        $user = $this->createMock(User::class);
-        $user->method('getAPIUser')
-            ->willReturn($apiUser);
-        $userName = 'my_username';
-        $password = 'foo';
-        $token = new UsernamePasswordToken($userName, $password, 'bar');
-
-        $this->userService
-            ->expects(self::once())
-            ->method('checkUserCredentials')
-            ->with($apiUser, $password)
-            ->willReturn(false);
-
-        $method = new \ReflectionMethod($this->authProvider, 'checkAuthentication');
-        $method->setAccessible(true);
-        $method->invoke($this->authProvider, $user, $token);
-    }
-
-    public function testCheckAuthentication()
-    {
-        $userName = 'my_username';
-        $password = 'foo';
-        $token = new UsernamePasswordToken($userName, $password, 'bar');
-
-        $apiUser = $this->createMock(APIUser::class);
-        $user = $this->createMock(User::class);
-        $user->method('getAPIUser')
-            ->willReturn($apiUser);
-
-        $this->userService
-            ->expects(self::once())
-            ->method('checkUserCredentials')
-            ->with($apiUser, $password)
-            ->willReturn(true);
-
-        $this->permissionResolver
-            ->expects(self::once())
-            ->method('setCurrentUserReference')
-            ->with($apiUser);
-
-        $method = new \ReflectionMethod($this->authProvider, 'checkAuthentication');
-        $method->setAccessible(true);
-        $method->invoke($this->authProvider, $user, $token);
-    }
-
-    public function testCheckAuthenticationFailedWhenPasswordInUnsupportedFormat()
-    {
-        $this->expectException(PasswordInUnsupportedFormatException::class);
-
-        $apiUser = $this->createMock(APIUser::class);
-        $user = $this->createMock(User::class);
-        $user->method('getAPIUser')
-            ->willReturn($apiUser);
-        $userName = 'my_username';
-        $password = 'foo';
-        $token = new UsernamePasswordToken($userName, $password, 'foo');
-        $this->userProvider
-            ->method('loadUserByUsername')
-            ->willReturn($user);
-
-        $this->userService
-            ->expects(self::once())
-            ->method('checkUserCredentials')
-            ->with($apiUser, $password)
-            ->willThrowException(new UnsupportedPasswordHashType(self::UNSUPPORTED_USER_PASSWORD_HASH_TYPE));
-
-        $this->authProvider->authenticate($token);
-    }
-
-    public function testAuthenticateInConstantTime(): void
-    {
-        $this->authProvider->setConstantAuthTime(SecurityPass::CONSTANT_AUTH_TIME_DEFAULT); // a reasonable value
-
-        $token = new UsernamePasswordToken('my_username', 'my_password', 'bar');
-
-        $stopwatch = new Stopwatch();
-        $stopwatch->start('authenticate_constant_time_test');
-
-        try {
-            $this->authProvider->authenticate($token);
-        } catch (\Exception $e) {
-            // We don't care, we just need test execution to continue
-        }
-
-        $duration = $stopwatch->stop('authenticate_constant_time_test')->getDuration();
-        self::assertGreaterThanOrEqual(SecurityPass::CONSTANT_AUTH_TIME_DEFAULT * 1000, $duration);
-    }
-
-    public function testAuthenticateWarningOnConstantTimeExceeded(): void
-    {
-        $this->authProvider->setConstantAuthTime(0.0000001); // much too short, but not zero, which would disable the check
-
-        $token = new UsernamePasswordToken('my_username', 'my_password', 'bar');
-
-        $this->logger
-            ->expects(self::atLeastOnce())
-            ->method('warning')
-            ->with('Authentication took longer than the configured constant time. Consider increasing the value of ' . SecurityPass::CONSTANT_AUTH_TIME_SETTING);
-
-        $this->expectException(AuthenticationException::class);
-        $this->authProvider->authenticate($token);
-    }
-
-    public function testAuthenticateConstantTimeDisabled(): void
-    {
-        $this->authProvider->setConstantAuthTime(0.0); // zero disables the check
-
-        $token = new UsernamePasswordToken('my_username', 'my_password', 'bar');
-
-        $this->logger
-            ->expects(self::never())
-            ->method('warning');
-
-        $this->expectException(AuthenticationException::class);
-        $this->authProvider->authenticate($token);
-    }
-}