[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:
Wouter de Jong 2021-03-16 13:35:11 +01:00
parent bb1e1e58ae
commit 01c3bf9604
11 changed files with 90 additions and 4 deletions

View File

@ -4,6 +4,7 @@ CHANGELOG
5.3 5.3
--- ---
* Add `required_badges` firewall config option
* [BC break] Add `login_throttling.lock_factory` setting defaulting to `null` (instead of `lock.factory`) * [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 a `login_throttling.interval` (in `security.firewalls`) option to change the default throttling interval.
* Add the `debug:firewall` command. * Add the `debug:firewall` command.

View File

@ -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\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManager; use Symfony\Component\Security\Core\Authorization\AccessDecisionManager;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
use Symfony\Component\Security\Http\Event\LogoutEvent; use Symfony\Component\Security\Http\Event\LogoutEvent;
@ -194,6 +195,7 @@ class MainConfiguration implements ConfigurationInterface
->disallowNewKeysInSubsequentConfigs() ->disallowNewKeysInSubsequentConfigs()
->useAttributeAsKey('name') ->useAttributeAsKey('name')
->prototype('array') ->prototype('array')
->fixXmlConfig('required_badge')
->children() ->children()
; ;
@ -266,6 +268,29 @@ class MainConfiguration implements ConfigurationInterface
->scalarNode('role')->defaultValue('ROLE_ALLOWED_TO_SWITCH')->end() ->scalarNode('role')->defaultValue('ROLE_ALLOWED_TO_SWITCH')->end()
->end() ->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 = []; $abstractFactoryKeys = [];

View File

@ -495,6 +495,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'] ?? [])
->addTag('monolog.logger', ['channel' => 'security']) ->addTag('monolog.logger', ['channel' => 'security'])
; ;

View File

@ -172,6 +172,7 @@
<xsd:element name="remember-me" type="remember_me" minOccurs="0" maxOccurs="1" /> <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="remote-user" type="remote_user" minOccurs="0" maxOccurs="1" />
<xsd:element name="x509" type="x509" 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 --> <!-- allow factories to use dynamic elements -->
<xsd:any processContents="lax" minOccurs="0" maxOccurs="unbounded" /> <xsd:any processContents="lax" minOccurs="0" maxOccurs="unbounded" />
</xsd:choice> </xsd:choice>

View File

@ -46,6 +46,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'),
abstract_arg('required badges'),
]) ])
->tag('monolog.logger', ['channel' => 'security']) ->tag('monolog.logger', ['channel' => 'security'])

View File

@ -26,6 +26,8 @@ use Symfony\Component\Security\Core\Authorization\AccessDecisionManager;
use Symfony\Component\Security\Core\Encoder\NativePasswordEncoder; use Symfony\Component\Security\Core\Encoder\NativePasswordEncoder;
use Symfony\Component\Security\Core\Encoder\SodiumPasswordEncoder; use Symfony\Component\Security\Core\Encoder\SodiumPasswordEncoder;
use Symfony\Component\Security\Http\Authentication\AuthenticatorManager; 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 abstract class CompleteConfigurationTest extends TestCase
{ {
@ -37,7 +39,11 @@ abstract class CompleteConfigurationTest extends TestCase
{ {
$container = $this->getContainer('authenticator_manager'); $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 // 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

@ -1,9 +1,12 @@
<?php <?php
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
$container->loadFromExtension('security', [ $container->loadFromExtension('security', [
'enable_authenticator_manager' => true, 'enable_authenticator_manager' => true,
'firewalls' => [ 'firewalls' => [
'main' => [ 'main' => [
'required_badges' => [CsrfTokenBadge::class, 'RememberMeBadge'],
'login_link' => [ 'login_link' => [
'check_route' => 'login_check', 'check_route' => 'login_check',
'check_post_only' => true, 'check_post_only' => true,

View File

@ -9,6 +9,8 @@
<config enable-authenticator-manager="true"> <config enable-authenticator-manager="true">
<firewall name="main"> <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" <login-link check-route="login_check"
check-post-only="true" check-post-only="true"
max-uses="1" max-uses="1"

View File

@ -2,6 +2,9 @@ security:
enable_authenticator_manager: true enable_authenticator_manager: true
firewalls: firewalls:
main: main:
required_badges:
- 'Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge'
- RememberMeBadge
login_link: login_link:
check_route: login_check check_route: login_check
check_post_only: true check_post_only: true

View File

@ -50,11 +50,12 @@ class AuthenticatorManager implements AuthenticatorManagerInterface, UserAuthent
private $eraseCredentials; private $eraseCredentials;
private $logger; private $logger;
private $firewallName; private $firewallName;
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) public function __construct(iterable $authenticators, TokenStorageInterface $tokenStorage, EventDispatcherInterface $eventDispatcher, string $firewallName, ?LoggerInterface $logger = null, bool $eraseCredentials = true, array $requiredBadges = [])
{ {
$this->authenticators = $authenticators; $this->authenticators = $authenticators;
$this->tokenStorage = $tokenStorage; $this->tokenStorage = $tokenStorage;
@ -62,6 +63,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->requiredBadges = $requiredBadges;
} }
/** /**
@ -170,10 +172,18 @@ class AuthenticatorManager implements AuthenticatorManagerInterface, UserAuthent
$this->eventDispatcher->dispatch($event); $this->eventDispatcher->dispatch($event);
// check if all badges are resolved // check if all badges are resolved
$resolvedBadges = [];
foreach ($passport->getBadges() as $badge) { foreach ($passport->getBadges() as $badge) {
if (!$badge->isResolved()) { 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))); 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 // create the authenticated token

View File

@ -17,10 +17,12 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
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\Exception\BadCredentialsException; use Symfony\Component\Security\Core\Exception\BadCredentialsException;
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;
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\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
@ -133,6 +135,37 @@ class AuthenticatorManagerTest extends TestCase
$manager->authenticateRequest($this->request); $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 * @dataProvider provideEraseCredentialsData
*/ */
@ -243,8 +276,8 @@ class AuthenticatorManagerTest extends TestCase
return $authenticator; 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);
} }
} }