Merge branch '5.2' into 5.x

* 5.2:
  [CI][Psalm] Install stable/released PHPUnit
  [Security] Add missing Finnish translations
  [Security][Guard] Prevent user enumeration via response content
This commit is contained in:
Nicolas Grekas 2021-05-12 15:40:11 +02:00
commit 8fb0ed752e
14 changed files with 118 additions and 12 deletions

View File

@ -39,7 +39,7 @@ jobs:
run: | run: |
echo "::group::modify composer.json" echo "::group::modify composer.json"
composer remove --no-update --no-interaction symfony/phpunit-bridge composer remove --no-update --no-interaction symfony/phpunit-bridge
composer require --no-update psalm/phar phpunit/phpunit php-http/discovery psr/event-dispatcher composer require --no-update psalm/phar phpunit/phpunit:@stable php-http/discovery psr/event-dispatcher
echo "::endgroup::" echo "::endgroup::"
echo "::group::composer update" echo "::group::composer update"
composer update --no-progress --ansi composer update --no-progress --ansi

View File

@ -502,7 +502,7 @@ class SecurityExtension extends Extension implements PrependExtensionInterface
->replaceArgument(0, $authenticators) ->replaceArgument(0, $authenticators)
->replaceArgument(2, new Reference($firewallEventDispatcherId)) ->replaceArgument(2, new Reference($firewallEventDispatcherId))
->replaceArgument(3, $id) ->replaceArgument(3, $id)
->replaceArgument(6, $firewall['required_badges'] ?? []) ->replaceArgument(7, $firewall['required_badges'] ?? [])
->addTag('monolog.logger', ['channel' => 'security']) ->addTag('monolog.logger', ['channel' => 'security'])
; ;

View File

@ -45,6 +45,7 @@ return static function (ContainerConfigurator $container) {
abstract_arg('Provider-shared Key'), abstract_arg('Provider-shared Key'),
abstract_arg('Authenticators'), abstract_arg('Authenticators'),
service('logger')->nullOnInvalid(), service('logger')->nullOnInvalid(),
param('security.authentication.hide_user_not_found'),
]) ])
->tag('monolog.logger', ['channel' => 'security']) ->tag('monolog.logger', ['channel' => 'security'])
; ;

View File

@ -44,6 +44,7 @@ return static function (ContainerConfigurator $container) {
abstract_arg('provider key'), abstract_arg('provider key'),
service('logger')->nullOnInvalid(), service('logger')->nullOnInvalid(),
param('security.authentication.manager.erase_credentials'), param('security.authentication.manager.erase_credentials'),
param('security.authentication.hide_user_not_found'),
abstract_arg('required badges'), abstract_arg('required badges'),
]) ])
->tag('monolog.logger', ['channel' => 'security']) ->tag('monolog.logger', ['channel' => 'security'])

View File

@ -43,7 +43,7 @@ abstract class CompleteConfigurationTest extends TestCase
$this->assertEquals(AuthenticatorManager::class, $authenticatorManager->getClass()); $this->assertEquals(AuthenticatorManager::class, $authenticatorManager->getClass());
// required badges // required badges
$this->assertEquals([CsrfTokenBadge::class, RememberMeBadge::class], $authenticatorManager->getArgument(6)); $this->assertEquals([CsrfTokenBadge::class, RememberMeBadge::class], $authenticatorManager->getArgument(7));
// login link // login link
$expiredStorage = $container->getDefinition($expiredStorageId = 'security.authenticator.expired_login_link_storage.main'); $expiredStorage = $container->getDefinition($expiredStorageId = 'security.authenticator.expired_login_link_storage.main');

View File

@ -40,7 +40,7 @@ class AuthenticatorTest extends AbstractWebTestCase
if ($withinFirewall) { if ($withinFirewall) {
$this->assertJsonStringEqualsJsonString('{"email":"'.$email.'"}', $client->getResponse()->getContent()); $this->assertJsonStringEqualsJsonString('{"email":"'.$email.'"}', $client->getResponse()->getContent());
} else { } else {
$this->assertJsonStringEqualsJsonString('{"error":"Username could not be found."}', $client->getResponse()->getContent()); $this->assertJsonStringEqualsJsonString('{"error":"Invalid credentials."}', $client->getResponse()->getContent());
} }
} }

View File

@ -142,7 +142,7 @@ class FormLoginTest extends AbstractWebTestCase
break; break;
case 2: // Third attempt with unexisting username case 2: // Third attempt with unexisting username
$this->assertStringContainsString('Username could not be found.', $text, 'Invalid response on 3rd attempt'); $this->assertStringContainsString('Invalid credentials.', $text, 'Invalid response on 3rd attempt');
break; break;
case 3: // Fourth attempt : still login throttling ! case 3: // Fourth attempt : still login throttling !

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\Security\Core\Authentication\Provider;
use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken; use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Exception\AccountStatusException;
use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\AuthenticationServiceException; use Symfony\Component\Security\Core\Exception\AuthenticationServiceException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\Exception\BadCredentialsException;
@ -79,7 +80,7 @@ abstract class UserAuthenticationProvider implements AuthenticationProviderInter
$this->userChecker->checkPreAuth($user); $this->userChecker->checkPreAuth($user);
$this->checkAuthentication($user, $token); $this->checkAuthentication($user, $token);
$this->userChecker->checkPostAuth($user); $this->userChecker->checkPostAuth($user);
} catch (BadCredentialsException $e) { } catch (AccountStatusException $e) {
if ($this->hideUserNotFoundExceptions) { if ($this->hideUserNotFoundExceptions) {
throw new BadCredentialsException('Bad credentials.', 0, $e); throw new BadCredentialsException('Bad credentials.', 0, $e);
} }

View File

@ -70,6 +70,14 @@
<source>Invalid or expired login link.</source> <source>Invalid or expired login link.</source>
<target>Virheellinen tai vanhentunut kirjautumislinkki.</target> <target>Virheellinen tai vanhentunut kirjautumislinkki.</target>
</trans-unit> </trans-unit>
<trans-unit id="19">
<source>Too many failed login attempts, please try again in %minutes% minute.</source>
<target>Liian monta epäonnistunutta kirjautumisyritystä, yritä uudelleen %minutes% minuutin kuluttua.</target>
</trans-unit>
<trans-unit id="20">
<source>Too many failed login attempts, please try again in %minutes% minutes.</source>
<target>Liian monta epäonnistunutta kirjautumisyritystä, yritä uudelleen %minutes% minuutin kuluttua.</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@ -83,7 +83,7 @@ class UserAuthenticationProviderTest extends TestCase
public function testAuthenticateWhenPreChecksFails() public function testAuthenticateWhenPreChecksFails()
{ {
$this->expectException(CredentialsExpiredException::class); $this->expectException(BadCredentialsException::class);
$userChecker = $this->createMock(UserCheckerInterface::class); $userChecker = $this->createMock(UserCheckerInterface::class);
$userChecker->expects($this->once()) $userChecker->expects($this->once())
->method('checkPreAuth') ->method('checkPreAuth')
@ -101,7 +101,7 @@ class UserAuthenticationProviderTest extends TestCase
public function testAuthenticateWhenPostChecksFails() public function testAuthenticateWhenPostChecksFails()
{ {
$this->expectException(AccountExpiredException::class); $this->expectException(BadCredentialsException::class);
$userChecker = $this->createMock(UserCheckerInterface::class); $userChecker = $this->createMock(UserCheckerInterface::class);
$userChecker->expects($this->once()) $userChecker->expects($this->once())
->method('checkPostAuth') ->method('checkPostAuth')
@ -128,7 +128,7 @@ class UserAuthenticationProviderTest extends TestCase
; ;
$provider->expects($this->once()) $provider->expects($this->once())
->method('checkAuthentication') ->method('checkAuthentication')
->willThrowException(new BadCredentialsException()) ->willThrowException(new CredentialsExpiredException())
; ;
$provider->authenticate($this->getSupportedToken()); $provider->authenticate($this->getSupportedToken());

View File

@ -17,7 +17,11 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AccountStatusException;
use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Guard\AuthenticatorInterface; use Symfony\Component\Security\Guard\AuthenticatorInterface;
use Symfony\Component\Security\Guard\GuardAuthenticatorHandler; use Symfony\Component\Security\Guard\GuardAuthenticatorHandler;
use Symfony\Component\Security\Guard\Token\PreAuthenticationGuardToken; use Symfony\Component\Security\Guard\Token\PreAuthenticationGuardToken;
@ -40,12 +44,13 @@ class GuardAuthenticationListener extends AbstractListener
private $guardAuthenticators; private $guardAuthenticators;
private $logger; private $logger;
private $rememberMeServices; private $rememberMeServices;
private $hideUserNotFoundExceptions;
/** /**
* @param string $providerKey The provider (i.e. firewall) key * @param string $providerKey The provider (i.e. firewall) key
* @param iterable|AuthenticatorInterface[] $guardAuthenticators The authenticators, with keys that match what's passed to GuardAuthenticationProvider * @param iterable|AuthenticatorInterface[] $guardAuthenticators The authenticators, with keys that match what's passed to GuardAuthenticationProvider
*/ */
public function __construct(GuardAuthenticatorHandler $guardHandler, AuthenticationManagerInterface $authenticationManager, string $providerKey, iterable $guardAuthenticators, LoggerInterface $logger = null) public function __construct(GuardAuthenticatorHandler $guardHandler, AuthenticationManagerInterface $authenticationManager, string $providerKey, iterable $guardAuthenticators, LoggerInterface $logger = null, bool $hideUserNotFoundExceptions = true)
{ {
if (empty($providerKey)) { if (empty($providerKey)) {
throw new \InvalidArgumentException('$providerKey must not be empty.'); throw new \InvalidArgumentException('$providerKey must not be empty.');
@ -56,6 +61,7 @@ class GuardAuthenticationListener extends AbstractListener
$this->providerKey = $providerKey; $this->providerKey = $providerKey;
$this->guardAuthenticators = $guardAuthenticators; $this->guardAuthenticators = $guardAuthenticators;
$this->logger = $logger; $this->logger = $logger;
$this->hideUserNotFoundExceptions = $hideUserNotFoundExceptions;
} }
/** /**
@ -160,6 +166,12 @@ class GuardAuthenticationListener extends AbstractListener
$this->logger->info('Guard authentication failed.', ['exception' => $e, 'authenticator' => \get_class($guardAuthenticator)]); $this->logger->info('Guard authentication failed.', ['exception' => $e, 'authenticator' => \get_class($guardAuthenticator)]);
} }
// Avoid leaking error details in case of invalid user (e.g. user not found or invalid account status)
// to prevent user enumeration via response content
if ($this->hideUserNotFoundExceptions && ($e instanceof UsernameNotFoundException || ($e instanceof AccountStatusException && !$e instanceof CustomUserMessageAccountStatusException))) {
$e = new BadCredentialsException('Bad credentials.', 0, $e);
}
$response = $this->guardHandler->handleAuthenticationFailure($e, $request, $guardAuthenticator, $this->providerKey); $response = $this->guardHandler->handleAuthenticationFailure($e, $request, $guardAuthenticator, $this->providerKey);
if ($response instanceof Response) { if ($response instanceof Response) {

View File

@ -19,6 +19,9 @@ use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Security\Core\Authentication\AuthenticationProviderManager; use Symfony\Component\Security\Core\Authentication\AuthenticationProviderManager;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\LockedException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Guard\AuthenticatorInterface; use Symfony\Component\Security\Guard\AuthenticatorInterface;
use Symfony\Component\Security\Guard\Firewall\GuardAuthenticationListener; use Symfony\Component\Security\Guard\Firewall\GuardAuthenticationListener;
use Symfony\Component\Security\Guard\GuardAuthenticatorHandler; use Symfony\Component\Security\Guard\GuardAuthenticatorHandler;
@ -211,6 +214,54 @@ class GuardAuthenticationListenerTest extends TestCase
$listener($this->event); $listener($this->event);
} }
/**
* @dataProvider exceptionsToHide
*/
public function testHandleHidesInvalidUserExceptions(AuthenticationException $exceptionToHide)
{
$authenticator = $this->createMock(AuthenticatorInterface::class);
$providerKey = 'my_firewall2';
$authenticator
->expects($this->once())
->method('supports')
->willReturn(true);
$authenticator
->expects($this->once())
->method('getCredentials')
->willReturn(['username' => 'robin', 'password' => 'hood']);
$this->authenticationManager
->expects($this->once())
->method('authenticate')
->willThrowException($exceptionToHide);
$this->guardAuthenticatorHandler
->expects($this->once())
->method('handleAuthenticationFailure')
->with($this->callback(function ($e) use ($exceptionToHide) {
return $e instanceof BadCredentialsException && $exceptionToHide === $e->getPrevious();
}), $this->request, $authenticator, $providerKey);
$listener = new GuardAuthenticationListener(
$this->guardAuthenticatorHandler,
$this->authenticationManager,
$providerKey,
[$authenticator],
$this->logger
);
$listener($this->event);
}
public function exceptionsToHide()
{
return [
[new UsernameNotFoundException()],
[new LockedException()],
];
}
public function testSupportsReturnFalseSkipAuth() public function testSupportsReturnFalseSkipAuth()
{ {
$authenticator = $this->createMock(AuthenticatorInterface::class); $authenticator = $this->createMock(AuthenticatorInterface::class);

View File

@ -18,8 +18,11 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInt
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\AuthenticationEvents; use Symfony\Component\Security\Core\AuthenticationEvents;
use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent; use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent;
use Symfony\Component\Security\Core\Exception\AccountStatusException;
use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface;
@ -48,12 +51,13 @@ class AuthenticatorManager implements AuthenticatorManagerInterface, UserAuthent
private $eraseCredentials; private $eraseCredentials;
private $logger; private $logger;
private $firewallName; private $firewallName;
private $hideUserNotFoundExceptions;
private $requiredBadges; private $requiredBadges;
/** /**
* @param AuthenticatorInterface[] $authenticators * @param AuthenticatorInterface[] $authenticators
*/ */
public function __construct(iterable $authenticators, TokenStorageInterface $tokenStorage, EventDispatcherInterface $eventDispatcher, string $firewallName, ?LoggerInterface $logger = null, bool $eraseCredentials = true, array $requiredBadges = []) public function __construct(iterable $authenticators, TokenStorageInterface $tokenStorage, EventDispatcherInterface $eventDispatcher, string $firewallName, ?LoggerInterface $logger = null, bool $eraseCredentials = true, bool $hideUserNotFoundExceptions = true, array $requiredBadges = [])
{ {
$this->authenticators = $authenticators; $this->authenticators = $authenticators;
$this->tokenStorage = $tokenStorage; $this->tokenStorage = $tokenStorage;
@ -61,6 +65,7 @@ class AuthenticatorManager implements AuthenticatorManagerInterface, UserAuthent
$this->firewallName = $firewallName; $this->firewallName = $firewallName;
$this->logger = $logger; $this->logger = $logger;
$this->eraseCredentials = $eraseCredentials; $this->eraseCredentials = $eraseCredentials;
$this->hideUserNotFoundExceptions = $hideUserNotFoundExceptions;
$this->requiredBadges = $requiredBadges; $this->requiredBadges = $requiredBadges;
} }
@ -251,6 +256,12 @@ class AuthenticatorManager implements AuthenticatorManagerInterface, UserAuthent
$this->logger->info('Authenticator failed.', ['exception' => $authenticationException, 'authenticator' => \get_class($authenticator)]); $this->logger->info('Authenticator failed.', ['exception' => $authenticationException, 'authenticator' => \get_class($authenticator)]);
} }
// Avoid leaking error details in case of invalid user (e.g. user not found or invalid account status)
// to prevent user enumeration via response content comparison
if ($this->hideUserNotFoundExceptions && ($authenticationException instanceof UsernameNotFoundException || ($authenticationException instanceof AccountStatusException && !$authenticationException instanceof CustomUserMessageAccountStatusException))) {
$authenticationException = new BadCredentialsException('Bad credentials.', 0, $authenticationException);
}
$response = $authenticator->onAuthenticationFailure($request, $authenticationException); $response = $authenticator->onAuthenticationFailure($request, $authenticationException);
if (null !== $response && null !== $this->logger) { if (null !== $response && null !== $this->logger) {
$this->logger->debug('The "{authenticator}" authenticator set the failure response.', ['authenticator' => \get_class($authenticator)]); $this->logger->debug('The "{authenticator}" authenticator set the failure response.', ['authenticator' => \get_class($authenticator)]);

View File

@ -19,6 +19,7 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInt
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Core\User\InMemoryUser;
use Symfony\Component\Security\Http\Authentication\AuthenticatorManager; use Symfony\Component\Security\Http\Authentication\AuthenticatorManager;
use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface;
@ -268,6 +269,26 @@ class AuthenticatorManagerTest extends TestCase
$this->assertSame($this->response, $response); $this->assertSame($this->response, $response);
} }
public function testAuthenticateRequestHidesInvalidUserExceptions()
{
$invalidUserException = new UsernameNotFoundException();
$authenticator = $this->createMock(InteractiveAuthenticatorInterface::class);
$this->request->attributes->set('_security_authenticators', [$authenticator]);
$authenticator->expects($this->any())->method('authenticate')->willThrowException($invalidUserException);
$authenticator->expects($this->any())
->method('onAuthenticationFailure')
->with($this->equalTo($this->request), $this->callback(function ($e) use ($invalidUserException) {
return $e instanceof BadCredentialsException && $invalidUserException === $e->getPrevious();
}))
->willReturn($this->response);
$manager = $this->createManager([$authenticator]);
$response = $manager->authenticateRequest($this->request);
$this->assertSame($this->response, $response);
}
private function createAuthenticator($supports = true) private function createAuthenticator($supports = true)
{ {
$authenticator = $this->createMock(InteractiveAuthenticatorInterface::class); $authenticator = $this->createMock(InteractiveAuthenticatorInterface::class);
@ -278,6 +299,6 @@ class AuthenticatorManagerTest extends TestCase
private function createManager($authenticators, $firewallName = 'main', $eraseCredentials = true, array $requiredBadges = []) private function createManager($authenticators, $firewallName = 'main', $eraseCredentials = true, array $requiredBadges = [])
{ {
return new AuthenticatorManager($authenticators, $this->tokenStorage, $this->eventDispatcher, $firewallName, null, $eraseCredentials, $requiredBadges); return new AuthenticatorManager($authenticators, $this->tokenStorage, $this->eventDispatcher, $firewallName, null, $eraseCredentials, true, $requiredBadges);
} }
} }