[Security] Add concept of required passport badges
A badge on a passport is a critical security element, it determines which security checks are run during authentication. Using the `required_badges` setting, applications can make sure the expected security checks are run.
This commit is contained in:
parent
bb1e1e58ae
commit
01c3bf9604
@ -4,6 +4,7 @@ CHANGELOG
|
||||
5.3
|
||||
---
|
||||
|
||||
* Add `required_badges` firewall config option
|
||||
* [BC break] Add `login_throttling.lock_factory` setting defaulting to `null` (instead of `lock.factory`)
|
||||
* Add a `login_throttling.interval` (in `security.firewalls`) option to change the default throttling interval.
|
||||
* Add the `debug:firewall` command.
|
||||
|
@ -15,6 +15,7 @@ use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AbstractF
|
||||
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
|
||||
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
|
||||
use Symfony\Component\Config\Definition\ConfigurationInterface;
|
||||
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
|
||||
use Symfony\Component\Security\Core\Authorization\AccessDecisionManager;
|
||||
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
|
||||
use Symfony\Component\Security\Http\Event\LogoutEvent;
|
||||
@ -194,6 +195,7 @@ class MainConfiguration implements ConfigurationInterface
|
||||
->disallowNewKeysInSubsequentConfigs()
|
||||
->useAttributeAsKey('name')
|
||||
->prototype('array')
|
||||
->fixXmlConfig('required_badge')
|
||||
->children()
|
||||
;
|
||||
|
||||
@ -266,6 +268,29 @@ class MainConfiguration implements ConfigurationInterface
|
||||
->scalarNode('role')->defaultValue('ROLE_ALLOWED_TO_SWITCH')->end()
|
||||
->end()
|
||||
->end()
|
||||
->arrayNode('required_badges')
|
||||
->info('A list of badges that must be present on the authenticated passport.')
|
||||
->validate()
|
||||
->always()
|
||||
->then(function ($requiredBadges) {
|
||||
return array_map(function ($requiredBadge) {
|
||||
if (class_exists($requiredBadge)) {
|
||||
return $requiredBadge;
|
||||
}
|
||||
|
||||
if (false === strpos($requiredBadge, '\\')) {
|
||||
$fqcn = 'Symfony\Component\Security\Http\Authenticator\Passport\Badge\\'.$requiredBadge;
|
||||
if (class_exists($fqcn)) {
|
||||
return $fqcn;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidConfigurationException(sprintf('Undefined security Badge class "%s" set in "security.firewall.required_badges".', $requiredBadge));
|
||||
}, $requiredBadges);
|
||||
})
|
||||
->end()
|
||||
->prototype('scalar')->end()
|
||||
->end()
|
||||
;
|
||||
|
||||
$abstractFactoryKeys = [];
|
||||
|
@ -495,6 +495,7 @@ class SecurityExtension extends Extension implements PrependExtensionInterface
|
||||
->replaceArgument(0, $authenticators)
|
||||
->replaceArgument(2, new Reference($firewallEventDispatcherId))
|
||||
->replaceArgument(3, $id)
|
||||
->replaceArgument(6, $firewall['required_badges'] ?? [])
|
||||
->addTag('monolog.logger', ['channel' => 'security'])
|
||||
;
|
||||
|
||||
|
@ -172,6 +172,7 @@
|
||||
<xsd:element name="remember-me" type="remember_me" minOccurs="0" maxOccurs="1" />
|
||||
<xsd:element name="remote-user" type="remote_user" minOccurs="0" maxOccurs="1" />
|
||||
<xsd:element name="x509" type="x509" minOccurs="0" maxOccurs="1" />
|
||||
<xsd:element name="required-badge" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
|
||||
<!-- allow factories to use dynamic elements -->
|
||||
<xsd:any processContents="lax" minOccurs="0" maxOccurs="unbounded" />
|
||||
</xsd:choice>
|
||||
|
@ -46,6 +46,7 @@ return static function (ContainerConfigurator $container) {
|
||||
abstract_arg('provider key'),
|
||||
service('logger')->nullOnInvalid(),
|
||||
param('security.authentication.manager.erase_credentials'),
|
||||
abstract_arg('required badges'),
|
||||
])
|
||||
->tag('monolog.logger', ['channel' => 'security'])
|
||||
|
||||
|
@ -26,6 +26,8 @@ use Symfony\Component\Security\Core\Authorization\AccessDecisionManager;
|
||||
use Symfony\Component\Security\Core\Encoder\NativePasswordEncoder;
|
||||
use Symfony\Component\Security\Core\Encoder\SodiumPasswordEncoder;
|
||||
use Symfony\Component\Security\Http\Authentication\AuthenticatorManager;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
|
||||
|
||||
abstract class CompleteConfigurationTest extends TestCase
|
||||
{
|
||||
@ -37,7 +39,11 @@ abstract class CompleteConfigurationTest extends TestCase
|
||||
{
|
||||
$container = $this->getContainer('authenticator_manager');
|
||||
|
||||
$this->assertEquals(AuthenticatorManager::class, $container->getDefinition('security.authenticator.manager.main')->getClass());
|
||||
$authenticatorManager = $container->getDefinition('security.authenticator.manager.main');
|
||||
$this->assertEquals(AuthenticatorManager::class, $authenticatorManager->getClass());
|
||||
|
||||
// required badges
|
||||
$this->assertEquals([CsrfTokenBadge::class, RememberMeBadge::class], $authenticatorManager->getArgument(6));
|
||||
|
||||
// login link
|
||||
$expiredStorage = $container->getDefinition($expiredStorageId = 'security.authenticator.expired_login_link_storage.main');
|
||||
|
@ -1,9 +1,12 @@
|
||||
<?php
|
||||
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
|
||||
|
||||
$container->loadFromExtension('security', [
|
||||
'enable_authenticator_manager' => true,
|
||||
'firewalls' => [
|
||||
'main' => [
|
||||
'required_badges' => [CsrfTokenBadge::class, 'RememberMeBadge'],
|
||||
'login_link' => [
|
||||
'check_route' => 'login_check',
|
||||
'check_post_only' => true,
|
||||
|
@ -9,6 +9,8 @@
|
||||
|
||||
<config enable-authenticator-manager="true">
|
||||
<firewall name="main">
|
||||
<required-badge>Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge</required-badge>
|
||||
<required-badge>RememberMeBadge</required-badge>
|
||||
<login-link check-route="login_check"
|
||||
check-post-only="true"
|
||||
max-uses="1"
|
||||
|
@ -2,6 +2,9 @@ security:
|
||||
enable_authenticator_manager: true
|
||||
firewalls:
|
||||
main:
|
||||
required_badges:
|
||||
- 'Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge'
|
||||
- RememberMeBadge
|
||||
login_link:
|
||||
check_route: login_check
|
||||
check_post_only: true
|
||||
|
@ -50,11 +50,12 @@ class AuthenticatorManager implements AuthenticatorManagerInterface, UserAuthent
|
||||
private $eraseCredentials;
|
||||
private $logger;
|
||||
private $firewallName;
|
||||
private $requiredBadges;
|
||||
|
||||
/**
|
||||
* @param AuthenticatorInterface[] $authenticators
|
||||
*/
|
||||
public function __construct(iterable $authenticators, TokenStorageInterface $tokenStorage, EventDispatcherInterface $eventDispatcher, string $firewallName, ?LoggerInterface $logger = null, bool $eraseCredentials = true)
|
||||
public function __construct(iterable $authenticators, TokenStorageInterface $tokenStorage, EventDispatcherInterface $eventDispatcher, string $firewallName, ?LoggerInterface $logger = null, bool $eraseCredentials = true, array $requiredBadges = [])
|
||||
{
|
||||
$this->authenticators = $authenticators;
|
||||
$this->tokenStorage = $tokenStorage;
|
||||
@ -62,6 +63,7 @@ class AuthenticatorManager implements AuthenticatorManagerInterface, UserAuthent
|
||||
$this->firewallName = $firewallName;
|
||||
$this->logger = $logger;
|
||||
$this->eraseCredentials = $eraseCredentials;
|
||||
$this->requiredBadges = $requiredBadges;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -170,10 +172,18 @@ class AuthenticatorManager implements AuthenticatorManagerInterface, UserAuthent
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
|
||||
// check if all badges are resolved
|
||||
$resolvedBadges = [];
|
||||
foreach ($passport->getBadges() as $badge) {
|
||||
if (!$badge->isResolved()) {
|
||||
throw new BadCredentialsException(sprintf('Authentication failed: Security badge "%s" is not resolved, did you forget to register the correct listeners?', get_debug_type($badge)));
|
||||
}
|
||||
|
||||
$resolvedBadges[] = \get_class($badge);
|
||||
}
|
||||
|
||||
$missingRequiredBadges = array_diff($this->requiredBadges, $resolvedBadges);
|
||||
if ($missingRequiredBadges) {
|
||||
throw new BadCredentialsException(sprintf('Authentication failed; Some badges marked as required by the firewall config are not available on the passport: "%s".', implode('", "', $missingRequiredBadges)));
|
||||
}
|
||||
|
||||
// create the authenticated token
|
||||
|
@ -17,10 +17,12 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
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\InMemoryUser;
|
||||
use Symfony\Component\Security\Http\Authentication\AuthenticatorManager;
|
||||
use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
|
||||
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;
|
||||
@ -133,6 +135,37 @@ class AuthenticatorManagerTest extends TestCase
|
||||
$manager->authenticateRequest($this->request);
|
||||
}
|
||||
|
||||
public function testRequiredBadgeMissing()
|
||||
{
|
||||
$authenticator = $this->createAuthenticator();
|
||||
$this->request->attributes->set('_security_authenticators', [$authenticator]);
|
||||
|
||||
$authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter')));
|
||||
|
||||
$authenticator->expects($this->once())->method('onAuthenticationFailure')->with($this->anything(), $this->callback(function ($exception) {
|
||||
return 'Authentication failed; Some badges marked as required by the firewall config are not available on the passport: "'.CsrfTokenBadge::class.'".' === $exception->getMessage();
|
||||
}));
|
||||
|
||||
$manager = $this->createManager([$authenticator], 'main', true, [CsrfTokenBadge::class]);
|
||||
$manager->authenticateRequest($this->request);
|
||||
}
|
||||
|
||||
public function testAllRequiredBadgesPresent()
|
||||
{
|
||||
$authenticator = $this->createAuthenticator();
|
||||
$this->request->attributes->set('_security_authenticators', [$authenticator]);
|
||||
|
||||
$csrfBadge = new CsrfTokenBadge('csrfid', 'csrftoken');
|
||||
$csrfBadge->markResolved();
|
||||
$authenticator->expects($this->any())->method('authenticate')->willReturn(new SelfValidatingPassport(new UserBadge('wouter'), [$csrfBadge]));
|
||||
$authenticator->expects($this->any())->method('createAuthenticatedToken')->willReturn(new UsernamePasswordToken($this->user, null, 'main'));
|
||||
|
||||
$authenticator->expects($this->once())->method('onAuthenticationSuccess');
|
||||
|
||||
$manager = $this->createManager([$authenticator], 'main', true, [CsrfTokenBadge::class]);
|
||||
$manager->authenticateRequest($this->request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideEraseCredentialsData
|
||||
*/
|
||||
@ -243,8 +276,8 @@ class AuthenticatorManagerTest extends TestCase
|
||||
return $authenticator;
|
||||
}
|
||||
|
||||
private function createManager($authenticators, $firewallName = 'main', $eraseCredentials = true)
|
||||
private function createManager($authenticators, $firewallName = 'main', $eraseCredentials = true, array $requiredBadges = [])
|
||||
{
|
||||
return new AuthenticatorManager($authenticators, $this->tokenStorage, $this->eventDispatcher, $firewallName, null, $eraseCredentials);
|
||||
return new AuthenticatorManager($authenticators, $this->tokenStorage, $this->eventDispatcher, $firewallName, null, $eraseCredentials, $requiredBadges);
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user