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: |
echo "::group::modify composer.json"
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 "::group::composer update"
composer update --no-progress --ansi

View File

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

View File

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

View File

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

View File

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

View File

@ -40,7 +40,7 @@ class AuthenticatorTest extends AbstractWebTestCase
if ($withinFirewall) {
$this->assertJsonStringEqualsJsonString('{"email":"'.$email.'"}', $client->getResponse()->getContent());
} 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;
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;
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\TokenInterface;
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\AuthenticationServiceException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
@ -79,7 +80,7 @@ abstract class UserAuthenticationProvider implements AuthenticationProviderInter
$this->userChecker->checkPreAuth($user);
$this->checkAuthentication($user, $token);
$this->userChecker->checkPostAuth($user);
} catch (BadCredentialsException $e) {
} catch (AccountStatusException $e) {
if ($this->hideUserNotFoundExceptions) {
throw new BadCredentialsException('Bad credentials.', 0, $e);
}

View File

@ -70,6 +70,14 @@
<source>Invalid or expired login link.</source>
<target>Virheellinen tai vanhentunut kirjautumislinkki.</target>
</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>
</file>
</xliff>

View File

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

View File

@ -17,7 +17,11 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface;
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\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\GuardAuthenticatorHandler;
use Symfony\Component\Security\Guard\Token\PreAuthenticationGuardToken;
@ -40,12 +44,13 @@ class GuardAuthenticationListener extends AbstractListener
private $guardAuthenticators;
private $logger;
private $rememberMeServices;
private $hideUserNotFoundExceptions;
/**
* @param string $providerKey The provider (i.e. firewall) key
* @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)) {
throw new \InvalidArgumentException('$providerKey must not be empty.');
@ -56,6 +61,7 @@ class GuardAuthenticationListener extends AbstractListener
$this->providerKey = $providerKey;
$this->guardAuthenticators = $guardAuthenticators;
$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)]);
}
// 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);
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\Token\TokenInterface;
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\Firewall\GuardAuthenticationListener;
use Symfony\Component\Security\Guard\GuardAuthenticatorHandler;
@ -211,6 +214,54 @@ class GuardAuthenticationListenerTest extends TestCase
$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()
{
$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\AuthenticationEvents;
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\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\Http\Authenticator\AuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface;
@ -48,12 +51,13 @@ class AuthenticatorManager implements AuthenticatorManagerInterface, UserAuthent
private $eraseCredentials;
private $logger;
private $firewallName;
private $hideUserNotFoundExceptions;
private $requiredBadges;
/**
* @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->tokenStorage = $tokenStorage;
@ -61,6 +65,7 @@ class AuthenticatorManager implements AuthenticatorManagerInterface, UserAuthent
$this->firewallName = $firewallName;
$this->logger = $logger;
$this->eraseCredentials = $eraseCredentials;
$this->hideUserNotFoundExceptions = $hideUserNotFoundExceptions;
$this->requiredBadges = $requiredBadges;
}
@ -251,6 +256,12 @@ class AuthenticatorManager implements AuthenticatorManagerInterface, UserAuthent
$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);
if (null !== $response && null !== $this->logger) {
$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\UsernamePasswordToken;
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\Http\Authentication\AuthenticatorManager;
use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface;
@ -268,6 +269,26 @@ class AuthenticatorManagerTest extends TestCase
$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)
{
$authenticator = $this->createMock(InteractiveAuthenticatorInterface::class);
@ -278,6 +299,6 @@ class AuthenticatorManagerTest extends TestCase
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);
}
}