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); - } -}