From 3ec5e96a87542fc9bccca106f5fcfd57e8c780cf Mon Sep 17 00:00:00 2001 From: Robin Chalas Date: Mon, 9 Nov 2020 11:13:07 +0100 Subject: [PATCH] [Security][WIP] Add authenticators info to the profiler --- UPGRADE-5.4.md | 1 + UPGRADE-6.0.md | 1 + .../Bundle/SecurityBundle/CHANGELOG.md | 1 + .../DataCollector/SecurityDataCollector.php | 11 +- .../Authenticator/TraceableAuthenticator.php | 106 ++++ .../TraceableAuthenticatorManagerListener.php | 81 +++ .../Debug/TraceableFirewallListener.php | 22 +- .../Debug/TraceableListenerTrait.php | 3 +- .../DependencyInjection/SecurityExtension.php | 17 +- .../views/Collector/security.html.twig | 573 ++++++++++-------- .../Security/FirewallConfig.php | 20 +- .../SecurityDataCollectorTest.php | 2 +- .../Debug/TraceableFirewallListenerTest.php | 83 +++ .../Tests/Security/FirewallConfigTest.php | 6 +- .../GuardBridgeAuthenticator.php | 5 + .../Authentication/AuthenticatorManager.php | 11 +- 16 files changed, 669 insertions(+), 274 deletions(-) create mode 100644 src/Symfony/Bundle/SecurityBundle/Debug/Authenticator/TraceableAuthenticator.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Debug/Authenticator/TraceableAuthenticatorManagerListener.php diff --git a/UPGRADE-5.4.md b/UPGRADE-5.4.md index 1e29b77185a05..23f5ff5d17544 100644 --- a/UPGRADE-5.4.md +++ b/UPGRADE-5.4.md @@ -51,6 +51,7 @@ Messenger SecurityBundle -------------- + * Deprecate `FirewallConfig::getListeners()`, use `FirewallConfig::getAuthenticators()` instead * Deprecate `security.authentication.basic_entry_point` and `security.authentication.retry_entry_point` services, the logic is moved into the `HttpBasicAuthenticator` and `ChannelListener` respectively * Deprecate not setting `$authenticatorManagerEnabled` to `true` in `SecurityDataCollector` and `DebugFirewallCommand` diff --git a/UPGRADE-6.0.md b/UPGRADE-6.0.md index a28ec6b1b3949..d852220391944 100644 --- a/UPGRADE-6.0.md +++ b/UPGRADE-6.0.md @@ -394,6 +394,7 @@ Security SecurityBundle -------------- + * Remove `FirewallConfig::getListeners()`, use `FirewallConfig::getAuthenticators()` instead * Remove `security.authentication.basic_entry_point` and `security.authentication.retry_entry_point` services, the logic is moved into the `HttpBasicAuthenticator` and `ChannelListener` respectively * Remove `SecurityFactoryInterface` and `SecurityExtension::addSecurityListenerFactory()` in favor of diff --git a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md index d492609fa94be..19517f6e24fda 100644 --- a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 5.4 --- + * Deprecate `FirewallConfig::getListeners()`, use `FirewallConfig::getAuthenticators()` instead * Deprecate `security.authentication.basic_entry_point` and `security.authentication.retry_entry_point` services, the logic is moved into the `HttpBasicAuthenticator` and `ChannelListener` respectively * Deprecate `FirewallConfig::allowsAnonymous()` and the `allows_anonymous` from the data collector data, there will be no anonymous concept as of version 6. diff --git a/src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php b/src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php index 504cf95a3235d..116e3d029c638 100644 --- a/src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php +++ b/src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php @@ -194,7 +194,7 @@ public function collect(Request $request, Response $response, \Throwable $except 'access_denied_handler' => $firewallConfig->getAccessDeniedHandler(), 'access_denied_url' => $firewallConfig->getAccessDeniedUrl(), 'user_checker' => $firewallConfig->getUserChecker(), - 'listeners' => $firewallConfig->getListeners(), + 'authenticators' => $firewallConfig->getAuthenticators(), ]; // generate exit impersonation path from current request @@ -215,6 +215,7 @@ public function collect(Request $request, Response $response, \Throwable $except } $this->data['authenticator_manager_enabled'] = $this->authenticatorManagerEnabled; + $this->data['authenticators'] = $this->firewall ? $this->firewall->getAuthenticatorsInfo() : []; } /** @@ -370,6 +371,14 @@ public function getListeners() return $this->data['listeners']; } + /** + * @return array|Data + */ + public function getAuthenticators() + { + return $this->data['authenticators']; + } + /** * {@inheritdoc} */ diff --git a/src/Symfony/Bundle/SecurityBundle/Debug/Authenticator/TraceableAuthenticator.php b/src/Symfony/Bundle/SecurityBundle/Debug/Authenticator/TraceableAuthenticator.php new file mode 100644 index 0000000000000..adce9eda408b8 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Debug/Authenticator/TraceableAuthenticator.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Debug\Authenticator; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Guard\Authenticator\GuardBridgeAuthenticator; +use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; +use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface; +use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; +use Symfony\Component\Security\Http\EntryPoint\Exception\NotAnEntryPointException; +use Symfony\Component\VarDumper\Caster\ClassStub; + +/** + * @author Robin Chalas + * + * @internal + */ +final class TraceableAuthenticator implements AuthenticatorInterface, InteractiveAuthenticatorInterface, AuthenticationEntryPointInterface +{ + private $authenticator; + private $passport; + private $duration; + private $stub; + + public function __construct(AuthenticatorInterface $authenticator) + { + $this->authenticator = $authenticator; + } + + public function getInfo(): array + { + return [ + 'supports' => true, + 'passport' => $this->passport, + 'duration' => $this->duration, + 'stub' => $this->stub ?? $this->stub = new ClassStub(\get_class($this->authenticator instanceof GuardBridgeAuthenticator ? $this->authenticator->getGuardAuthenticator() : $this->authenticator)), + ]; + } + + public function supports(Request $request): ?bool + { + return $this->authenticator->supports($request); + } + + public function authenticate(Request $request): PassportInterface + { + $startTime = microtime(true); + $this->passport = $this->authenticator->authenticate($request); + $this->duration = microtime(true) - $startTime; + + return $this->passport; + } + + public function createToken(Passport $passport, string $firewallName): TokenInterface + { + return method_exists($this->authenticator, 'createToken') ? $this->authenticator->createToken($passport, $firewallName) : $this->authenticator->createAuthenticatedToken($passport, $firewallName); + } + + public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface + { + return $this->authenticator->createAuthenticatedToken($passport, $firewallName); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return $this->authenticator->onAuthenticationSuccess($request, $token, $firewallName); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return $this->authenticator->onAuthenticationFailure($request, $exception); + } + + public function start(Request $request, AuthenticationException $authException = null): Response + { + if (!$this->authenticator instanceof AuthenticationEntryPointInterface) { + throw new NotAnEntryPointException(); + } + + return $this->authenticator->start($request, $authException); + } + + public function isInteractive(): bool + { + return $this->authenticator instanceof InteractiveAuthenticatorInterface && $this->authenticator->isInteractive(); + } + + public function __call($method, $args) + { + return $this->authenticator->{$method}(...$args); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Debug/Authenticator/TraceableAuthenticatorManagerListener.php b/src/Symfony/Bundle/SecurityBundle/Debug/Authenticator/TraceableAuthenticatorManagerListener.php new file mode 100644 index 0000000000000..571970990d693 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Debug/Authenticator/TraceableAuthenticatorManagerListener.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Debug\Authenticator; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\Security\Http\Firewall\AbstractListener; +use Symfony\Component\Security\Http\Firewall\AuthenticatorManagerListener; +use Symfony\Component\VarDumper\Caster\ClassStub; + +/** + * Decorates the AuthenticatorManagerListener to collect information about security authenticators. + * + * @author Robin Chalas + * + * @internal + */ +class TraceableAuthenticatorManagerListener extends AbstractListener +{ + private $authenticationManagerListener; + private $authenticatorsInfo = []; + + public function __construct(AuthenticatorManagerListener $authenticationManagerListener) + { + $this->authenticationManagerListener = $authenticationManagerListener; + } + + public function supports(Request $request): ?bool + { + return $this->authenticationManagerListener->supports($request); + } + + public function authenticate(RequestEvent $event): void + { + $request = $event->getRequest(); + + if (!$authenticators = $request->attributes->get('_security_authenticators')) { + return; + } + + foreach ($request->attributes->get('_security_skipped_authenticators') as $skippedAuthenticator) { + $this->authenticatorsInfo[] = [ + 'supports' => false, + 'stub' => new ClassStub(\get_class($skippedAuthenticator)), + 'passport' => null, + 'duration' => 0, + ]; + } + + foreach ($authenticators as $key => $authenticator) { + $authenticators[$key] = new TraceableAuthenticator($authenticator); + } + + $request->attributes->set('_security_authenticators', $authenticators); + + $this->authenticationManagerListener->authenticate($event); + + foreach ($authenticators as $authenticator) { + $this->authenticatorsInfo[] = $authenticator->getInfo(); + } + } + + public function getAuthenticatorManagerListener(): AuthenticatorManagerListener + { + return $this->authenticationManagerListener; + } + + public function getAuthenticatorsInfo(): array + { + return $this->authenticatorsInfo; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Debug/TraceableFirewallListener.php b/src/Symfony/Bundle/SecurityBundle/Debug/TraceableFirewallListener.php index bc7549a97a34d..7eb0b3a783b5c 100644 --- a/src/Symfony/Bundle/SecurityBundle/Debug/TraceableFirewallListener.php +++ b/src/Symfony/Bundle/SecurityBundle/Debug/TraceableFirewallListener.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\SecurityBundle\Debug; +use Symfony\Bundle\SecurityBundle\Debug\Authenticator\TraceableAuthenticatorManagerListener; use Symfony\Bundle\SecurityBundle\EventListener\FirewallListener; use Symfony\Bundle\SecurityBundle\Security\FirewallContext; use Symfony\Bundle\SecurityBundle\Security\LazyFirewallContext; @@ -18,29 +19,39 @@ use Symfony\Component\Security\Http\Firewall\FirewallListenerInterface; /** - * Firewall collecting called listeners. + * Firewall collecting called security listeners and authenticators. * * @author Robin Chalas */ final class TraceableFirewallListener extends FirewallListener { private $wrappedListeners = []; + private $authenticatorsInfo = []; public function getWrappedListeners() { return $this->wrappedListeners; } + public function getAuthenticatorsInfo(): array + { + return $this->authenticatorsInfo; + } + protected function callListeners(RequestEvent $event, iterable $listeners) { $wrappedListeners = []; $wrappedLazyListeners = []; + $authenticatorManagerListener = null; foreach ($listeners as $listener) { if ($listener instanceof LazyFirewallContext) { - \Closure::bind(function () use (&$wrappedLazyListeners, &$wrappedListeners) { + \Closure::bind(function () use (&$wrappedLazyListeners, &$wrappedListeners, &$authenticatorManagerListener) { $listeners = []; foreach ($this->listeners as $listener) { + if (!$authenticatorManagerListener && $listener instanceof TraceableAuthenticatorManagerListener) { + $authenticatorManagerListener = $listener; + } if ($listener instanceof FirewallListenerInterface) { $listener = new WrappedLazyListener($listener); $listeners[] = $listener; @@ -61,6 +72,9 @@ protected function callListeners(RequestEvent $event, iterable $listeners) $wrappedListener = $listener instanceof FirewallListenerInterface ? new WrappedLazyListener($listener) : new WrappedListener($listener); $wrappedListener($event); $wrappedListeners[] = $wrappedListener->getInfo(); + if (!$authenticatorManagerListener && $listener instanceof TraceableAuthenticatorManagerListener) { + $authenticatorManagerListener = $listener; + } } if ($event->hasResponse()) { @@ -75,5 +89,9 @@ protected function callListeners(RequestEvent $event, iterable $listeners) } $this->wrappedListeners = array_merge($this->wrappedListeners, $wrappedListeners); + + if ($authenticatorManagerListener) { + $this->authenticatorsInfo = $authenticatorManagerListener->getAuthenticatorsInfo(); + } } } diff --git a/src/Symfony/Bundle/SecurityBundle/Debug/TraceableListenerTrait.php b/src/Symfony/Bundle/SecurityBundle/Debug/TraceableListenerTrait.php index 691c6659d5384..28de4031a542d 100644 --- a/src/Symfony/Bundle/SecurityBundle/Debug/TraceableListenerTrait.php +++ b/src/Symfony/Bundle/SecurityBundle/Debug/TraceableListenerTrait.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\SecurityBundle\Debug; +use Symfony\Bundle\SecurityBundle\Debug\Authenticator\TraceableAuthenticatorManagerListener; use Symfony\Component\VarDumper\Caster\ClassStub; /** @@ -43,7 +44,7 @@ public function getInfo(): array return [ 'response' => $this->response, 'time' => $this->time, - 'stub' => $this->stub ?? $this->stub = ClassStub::wrapCallable($this->listener), + 'stub' => $this->stub ?? $this->stub = ClassStub::wrapCallable($this->listener instanceof TraceableAuthenticatorManagerListener ? $this->listener->getAuthenticatorManagerListener() : $this->listener), ]; } } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index 455f1112ad00e..f8e7e1dce6e91 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -12,6 +12,7 @@ namespace Symfony\Bundle\SecurityBundle\DependencyInjection; use Symfony\Bridge\Twig\Extension\LogoutUrlExtension; +use Symfony\Bundle\SecurityBundle\Debug\Authenticator\TraceableAuthenticatorManagerListener; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AuthenticatorFactoryInterface; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\FirewallListenerFactoryInterface; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SecurityFactoryInterface; @@ -504,6 +505,14 @@ private function createFirewall(ContainerBuilder $container, string $id, array $ ->replaceArgument(0, new Reference($managerId)) ; + if ($container->hasDefinition('debug.security.firewall') && $this->authenticatorManagerEnabled) { + $container + ->register('debug.security.firewall.authenticator.'.$id, TraceableAuthenticatorManagerListener::class) + ->setDecoratedService('security.firewall.authenticator.'.$id) + ->setArguments([new Reference('debug.security.firewall.authenticator.'.$id.'.inner')]) + ; + } + // user checker listener $container ->setDefinition('security.listener.user_checker.'.$id, new ChildDefinition('security.listener.user_checker')) @@ -542,11 +551,17 @@ private function createFirewall(ContainerBuilder $container, string $id, array $ foreach ($this->getSortedFactories() as $factory) { $key = str_replace('-', '_', $factory->getKey()); - if (\array_key_exists($key, $firewall)) { + if ('custom_authenticators' !== $key && \array_key_exists($key, $firewall)) { $listenerKeys[] = $key; } } + if ($firewall['custom_authenticators'] ?? false) { + foreach ($firewall['custom_authenticators'] as $customAuthenticatorId) { + $listenerKeys[] = $customAuthenticatorId; + } + } + $config->replaceArgument(10, $listenerKeys); $config->replaceArgument(11, $firewall['switch_user'] ?? null); diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig b/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig index 54196a8adb81d..b332ba5ddb596 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig +++ b/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig @@ -104,289 +104,346 @@ {% endblock %} {% block panel %} -

Security Token

- +

Security

{% if collector.enabled %} - {% if collector.token %} -
-
- {{ collector.user == 'anon.' ? 'Anonymous' : collector.user }} - Username -
+
+
+

Token

+ +
+ {% if collector.token %} +
+
+ {{ collector.user == 'anon.' ? 'Anonymous' : collector.user }} + Username +
+ +
+ {{ include('@WebProfiler/Icon/' ~ (collector.authenticated ? 'yes' : 'no') ~ '.svg') }} + Authenticated +
+
+ + + + + + + + + + + + + + + {% if collector.supportsRoleHierarchy %} + + + + + {% endif %} -
- {{ include('@WebProfiler/Icon/' ~ (collector.authenticated ? 'yes' : 'no') ~ '.svg') }} - Authenticated + {% if collector.token %} +
+ + + + {% endif %} + +
PropertyValue
Roles + {{ collector.roles is empty ? 'none' : profiler_dump(collector.roles, maxDepth=1) }} + + {% if not collector.authenticated and collector.roles is empty %} +

User is not authenticated probably because they have no roles.

+ {% endif %} +
Inherited Roles{{ collector.inheritedRoles is empty ? 'none' : profiler_dump(collector.inheritedRoles, maxDepth=1) }}
Token{{ profiler_dump(collector.token) }}
+ {% elseif collector.enabled %} +
+

There is no security token.

+
+ {% endif %}
- - - - - - - - - - - - - - {% if collector.supportsRoleHierarchy %} - - - - - {% endif %} + - {% if collector.token %} - - - - + {% if collector.firewall.security_enabled %} +

Configuration

+
PropertyValue
Roles - {{ collector.roles is empty ? 'none' : profiler_dump(collector.roles, maxDepth=1) }} - - {% if not collector.authenticated and collector.roles is empty %} -

User is not authenticated probably because they have no roles.

+
+

Firewall

+
+ {% if collector.firewall %} +
+
+ {{ collector.firewall.name }} + Name +
+
+ {{ include('@WebProfiler/Icon/' ~ (collector.firewall.security_enabled ? 'yes' : 'no') ~ '.svg') }} + Security enabled +
+
+ {{ include('@WebProfiler/Icon/' ~ (collector.firewall.stateless ? 'yes' : 'no') ~ '.svg') }} + Stateless +
+ {% if collector.authenticatorManagerEnabled == false %} +
+ {{ include('@WebProfiler/Icon/' ~ (collector.firewall.allows_anonymous ? 'yes' : 'no') ~ '.svg') }} + Allows anonymous +
{% endif %} -
Inherited Roles{{ collector.inheritedRoles is empty ? 'none' : profiler_dump(collector.inheritedRoles, maxDepth=1) }}
Token{{ profiler_dump(collector.token) }}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if collector.authenticatorManagerEnabled %} + + + + + {% else %} + + + + + {% endif %} + +
KeyValue
provider{{ collector.firewall.provider ?: '(none)' }}
context{{ collector.firewall.context ?: '(none)' }}
entry_point{{ collector.firewall.entry_point ?: '(none)' }}
user_checker{{ collector.firewall.user_checker ?: '(none)' }}
access_denied_handler{{ collector.firewall.access_denied_handler ?: '(none)' }}
access_denied_url{{ collector.firewall.access_denied_url ?: '(none)' }}
authenticators{{ collector.firewall.authenticators is empty ? '(none)' : profiler_dump(collector.firewall.authenticators, maxDepth=1) }}
listeners{{ collector.firewall.listeners is empty ? '(none)' : profiler_dump(collector.firewall.listeners, maxDepth=1) }}
+ {% endif %} {% endif %} - - - {% elseif collector.enabled %} -
-

There is no security token.

+
- {% endif %} +
+

Listeners

+
+ {% if collector.listeners|default([]) is empty %} +
+

No security listeners have been recorded. Check that debugging is enabled in the kernel.

+
+ {% else %} + + + + + + + + -

Security Firewall

+ {% set previous_event = (collector.listeners|first) %} + {% for listener in collector.listeners %} + {% if loop.first or listener != previous_event %} + {% if not loop.first %} + + {% endif %} - {% if collector.firewall %} -
-
- {{ collector.firewall.name }} - Name -
-
- {{ include('@WebProfiler/Icon/' ~ (collector.firewall.security_enabled ? 'yes' : 'no') ~ '.svg') }} - Security enabled -
-
- {{ include('@WebProfiler/Icon/' ~ (collector.firewall.stateless ? 'yes' : 'no') ~ '.svg') }} - Stateless -
- {% if collector.authenticatorManagerEnabled == false %} -
- {{ include('@WebProfiler/Icon/' ~ (collector.firewall.allows_anonymous ? 'yes' : 'no') ~ '.svg') }} - Allows anonymous -
- {% endif %} -
+ + {% set previous_event = listener %} + {% endif %} - {% if collector.firewall.security_enabled %} -

Configuration

- -
ListenerDurationResponse
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
KeyValue
provider{{ collector.firewall.provider ?: '(none)' }}
context{{ collector.firewall.context ?: '(none)' }}
entry_point{{ collector.firewall.entry_point ?: '(none)' }}
user_checker{{ collector.firewall.user_checker ?: '(none)' }}
access_denied_handler{{ collector.firewall.access_denied_handler ?: '(none)' }}
access_denied_url{{ collector.firewall.access_denied_url ?: '(none)' }}
listeners{{ collector.firewall.listeners is empty ? '(none)' : profiler_dump(collector.firewall.listeners, maxDepth=1) }}
- -

Listeners

- - {% if collector.listeners|default([]) is empty %} -
-

No security listeners have been recorded. Check that debugging is enabled in the kernel.

-
- {% else %} - - - - - - - - - - {% set previous_event = (collector.listeners|first) %} - {% for listener in collector.listeners %} - {% if loop.first or listener != previous_event %} - {% if not loop.first %} + + + + + + + {% if loop.last %} {% endif %} + {% endfor %} +
ListenerDurationResponse
{{ profiler_dump(listener.stub) }}{{ '%0.2f'|format(listener.time * 1000) }} ms{{ listener.response ? profiler_dump(listener.response) : '(none)' }}
+ {% endif %} +
+
- - {% set previous_event = listener %} - {% endif %} - +
+

Authenticators

+
+ {% if collector.authenticators|default([]) is not empty %} + + - - - + + + + + - {% if loop.last %} - - {% endif %} - {% endfor %} -
{{ profiler_dump(listener.stub) }}{{ '%0.2f'|format(listener.time * 1000) }} ms{{ listener.response ? profiler_dump(listener.response) : '(none)' }}AuthenticatorSupportsDurationPassport
- {% endif %} - {% endif %} - {% elseif collector.enabled %} -
-

This request was not covered by any firewall.

-
- {% endif %} - {% else %} -
-

The security component is disabled.

-
- {% endif %} + {% set previous_event = (collector.listeners|first) %} + {% for authenticator in collector.authenticators %} + {% if loop.first or authenticator != previous_event %} + {% if not loop.first %} + + {% endif %} - {% if collector.voters|default([]) is not empty %} -

Security Voters ({{ collector.voters|length }})

+ + {% set previous_event = authenticator %} + {% endif %} + + + {{ profiler_dump(authenticator.stub) }} + {{ include('@WebProfiler/Icon/' ~ (authenticator.supports ? 'yes' : 'no') ~ '.svg') }} + {{ '%0.2f'|format(authenticator.duration * 1000) }} ms + {{ authenticator.passport ? profiler_dump(authenticator.passport) : '(none)' }} + -
-
- {{ collector.voterStrategy|default('unknown') }} - Strategy + {% if loop.last %} + + {% endif %} + {% endfor %} + + {% else %} +
+

No authenticators have been recorded. Check previous profiles on your authentication endpoint.

+
+ {% endif %} +
-
- - - - - - - - - - {% for voter in collector.voters %} - - - - - {% endfor %} - -
#Voter class
{{ loop.index }}{{ profiler_dump(voter) }}
- {% endif %} +
+

Access Decision

+
+ {% if collector.voters|default([]) is not empty %} +
+
+ {{ collector.voterStrategy|default('unknown') }} + Strategy +
+
- {% if collector.accessDecisionLog|default([]) is not empty %} -

Access decision log

- - - - - - - - - - - - - - - - - - {% for decision in collector.accessDecisionLog %} - - - - - - - - - + + + + + + + {% endfor %} + +
#ResultAttributesObject
{{ loop.index }} - {{ decision.result - ? 'GRANTED' - : 'DENIED' - }} - - {% if decision.attributes|length == 1 %} - {% set attribute = decision.attributes|first %} - {% if attribute.expression is defined %} - Expression:
{{ attribute.expression }}
- {% elseif attribute.type == 'string' %} - {{ attribute }} - {% else %} - {{ profiler_dump(attribute) }} - {% endif %} - {% else %} - {{ profiler_dump(decision.attributes) }} - {% endif %} -
{{ profiler_dump(decision.seek('object')) }}
- {% if decision.voter_details is not empty %} - {% set voter_details_id = 'voter-details-' ~ loop.index %} -
- - - {% for voter_detail in decision.voter_details %} - - - {% if collector.voterStrategy == constant('Symfony\\Component\\Security\\Core\\Authorization\\AccessDecisionManager::STRATEGY_UNANIMOUS') %} - - {% endif %} - - - {% endfor %} - -
{{ profiler_dump(voter_detail['class']) }}attribute {{ voter_detail['attributes'][0] }} - {% if voter_detail['vote'] == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_GRANTED') %} - ACCESS GRANTED - {% elseif voter_detail['vote'] == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_ABSTAIN') %} - ACCESS ABSTAIN - {% elseif voter_detail['vote'] == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_DENIED') %} - ACCESS DENIED + + + + + + + + + + {% for voter in collector.voters %} + + + + + {% endfor %} + +
#Voter class
{{ loop.index }}{{ profiler_dump(voter) }}
+ {% endif %} + {% if collector.accessDecisionLog|default([]) is not empty %} +

Access decision log

+ + + + + + + + + + + + + + + + + + {% for decision in collector.accessDecisionLog %} + + + + - - {% endfor %} - -
#ResultAttributesObject
{{ loop.index }} + {{ decision.result + ? 'GRANTED' + : 'DENIED' + }} + + {% if decision.attributes|length == 1 %} + {% set attribute = decision.attributes|first %} + {% if attribute.expression is defined %} + Expression:
{{ attribute.expression }}
+ {% elseif attribute.type == 'string' %} + {{ attribute }} {% else %} - unknown ({{ voter_detail['vote'] }}) + {{ profiler_dump(attribute) }} {% endif %} -
- - Show voter details - {% endif %} -
+ {% else %} + {{ profiler_dump(decision.attributes) }} + {% endif %} +
{{ profiler_dump(decision.seek('object')) }}
+ {% if decision.voter_details is not empty %} + {% set voter_details_id = 'voter-details-' ~ loop.index %} +
+ + + {% for voter_detail in decision.voter_details %} + + + {% if collector.voterStrategy == constant('Symfony\\Component\\Security\\Core\\Authorization\\AccessDecisionManager::STRATEGY_UNANIMOUS') %} + + {% endif %} + + + {% endfor %} + +
{{ profiler_dump(voter_detail['class']) }}attribute {{ voter_detail['attributes'][0] }} + {% if voter_detail['vote'] == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_GRANTED') %} + ACCESS GRANTED + {% elseif voter_detail['vote'] == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_ABSTAIN') %} + ACCESS ABSTAIN + {% elseif voter_detail['vote'] == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_DENIED') %} + ACCESS DENIED + {% else %} + unknown ({{ voter_detail['vote'] }}) + {% endif %} +
+
+ Show voter details + {% endif %} +
+
+ {% endif %} +
+
{% endif %} {% endblock %} diff --git a/src/Symfony/Bundle/SecurityBundle/Security/FirewallConfig.php b/src/Symfony/Bundle/SecurityBundle/Security/FirewallConfig.php index 779920b5a4320..c9b1e9ca8f1fb 100644 --- a/src/Symfony/Bundle/SecurityBundle/Security/FirewallConfig.php +++ b/src/Symfony/Bundle/SecurityBundle/Security/FirewallConfig.php @@ -26,10 +26,10 @@ final class FirewallConfig private $entryPoint; private $accessDeniedHandler; private $accessDeniedUrl; - private $listeners; + private $authenticators; private $switchUser; - public function __construct(string $name, string $userChecker, string $requestMatcher = null, bool $securityEnabled = true, bool $stateless = false, string $provider = null, string $context = null, string $entryPoint = null, string $accessDeniedHandler = null, string $accessDeniedUrl = null, array $listeners = [], array $switchUser = null) + public function __construct(string $name, string $userChecker, string $requestMatcher = null, bool $securityEnabled = true, bool $stateless = false, string $provider = null, string $context = null, string $entryPoint = null, string $accessDeniedHandler = null, string $accessDeniedUrl = null, array $authenticators = [], array $switchUser = null) { $this->name = $name; $this->userChecker = $userChecker; @@ -41,7 +41,7 @@ public function __construct(string $name, string $userChecker, string $requestMa $this->entryPoint = $entryPoint; $this->accessDeniedHandler = $accessDeniedHandler; $this->accessDeniedUrl = $accessDeniedUrl; - $this->listeners = $listeners; + $this->authenticators = $authenticators; $this->switchUser = $switchUser; } @@ -71,7 +71,7 @@ public function allowsAnonymous(): bool { trigger_deprecation('symfony/security-bundle', '5.4', 'The "%s()" method is deprecated.', __METHOD__); - return \in_array('anonymous', $this->listeners, true); + return \in_array('anonymous', $this->authenticators, true); } public function isStateless(): bool @@ -112,9 +112,19 @@ public function getAccessDeniedUrl(): ?string return $this->accessDeniedUrl; } + /** + * @deprecated since Symfony 5.4, use {@see getListeners()} instead + */ public function getListeners(): array { - return $this->listeners; + trigger_deprecation('symfony/security-bundle', '5.4', 'Method "%s()" is deprecated, use "%s::getAuthenticators()" instead.', __METHOD__, __CLASS__); + + return $this->getAuthenticators(); + } + + public function getAuthenticators(): array + { + return $this->authenticators; } public function getSwitchUser(): ?array diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php index a25a43b53c53d..21f8c2e1525f4 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php @@ -150,7 +150,7 @@ public function testGetFirewall() $this->assertSame($firewallConfig->getAccessDeniedHandler(), $collected['access_denied_handler']); $this->assertSame($firewallConfig->getAccessDeniedUrl(), $collected['access_denied_url']); $this->assertSame($firewallConfig->getUserChecker(), $collected['user_checker']); - $this->assertSame($firewallConfig->getListeners(), $collected['listeners']->getValue()); + $this->assertSame($firewallConfig->getAuthenticators(), $collected['authenticators']->getValue()); $this->assertTrue($collector->isAuthenticatorManagerEnabled()); } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Debug/TraceableFirewallListenerTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Debug/TraceableFirewallListenerTest.php index 2e69efd08d633..83f72bd36c0bc 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Debug/TraceableFirewallListenerTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Debug/TraceableFirewallListenerTest.php @@ -12,6 +12,7 @@ namespace Symfony\Bundle\SecurityBundle\Tests\Debug; use PHPUnit\Framework\TestCase; +use Symfony\Bundle\SecurityBundle\Debug\Authenticator\TraceableAuthenticatorManagerListener; use Symfony\Bundle\SecurityBundle\Debug\TraceableFirewallListener; use Symfony\Bundle\SecurityBundle\Security\FirewallMap; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -19,6 +20,14 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Http\Authentication\AuthenticatorManager; +use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use Symfony\Component\Security\Http\Firewall\AuthenticatorManagerListener; use Symfony\Component\Security\Http\Logout\LogoutUrlGenerator; /** @@ -54,4 +63,78 @@ public function testOnKernelRequestRecordsListeners() $this->assertCount(1, $listeners); $this->assertSame($listener, $listeners[0]['stub']); } + + public function testOnKernelRequestRecordsAuthenticatorsInfo() + { + $request = new Request(); + + $event = new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST); + $event->setResponse($response = new Response()); + + $supportingAuthenticator = $this->createMock(DummyAuthenticator::class); + $supportingAuthenticator + ->method('supports') + ->with($request) + ->willReturn(true); + $supportingAuthenticator + ->expects($this->once()) + ->method('authenticate') + ->with($request) + ->willReturn(new SelfValidatingPassport(new UserBadge('robin', function () {}))); + $supportingAuthenticator + ->expects($this->once()) + ->method('onAuthenticationSuccess') + ->willReturn($response); + $supportingAuthenticator + ->expects($this->once()) + ->method('createToken') + ->willReturn($this->createMock(TokenInterface::class)); + + $notSupportingAuthenticator = $this->createMock(DummyAuthenticator::class); + $notSupportingAuthenticator + ->method('supports') + ->with($request) + ->willReturn(false); + + $tokenStorage = $this->createMock(TokenStorageInterface::class); + $dispatcher = new EventDispatcher(); + $authenticatorManager = new AuthenticatorManager( + [$notSupportingAuthenticator, $supportingAuthenticator], + $tokenStorage, + $dispatcher, + 'main' + ); + + $listener = new TraceableAuthenticatorManagerListener(new AuthenticatorManagerListener($authenticatorManager)); + $firewallMap = $this->createMock(FirewallMap::class); + $firewallMap + ->expects($this->once()) + ->method('getFirewallConfig') + ->with($request) + ->willReturn(null); + $firewallMap + ->expects($this->once()) + ->method('getListeners') + ->with($request) + ->willReturn([[$listener], null, null]); + + $firewall = new TraceableFirewallListener($firewallMap, $dispatcher, new LogoutUrlGenerator()); + $firewall->configureLogoutUrlGenerator($event); + $firewall->onKernelRequest($event); + + $this->assertCount(2, $authenticatorsInfo = $firewall->getAuthenticatorsInfo()); + + $this->assertFalse($authenticatorsInfo[0]['supports']); + $this->assertStringContainsString('DummyAuthenticator', $authenticatorsInfo[0]['stub']); + + $this->assertTrue($authenticatorsInfo[1]['supports']); + $this->assertStringContainsString('DummyAuthenticator', $authenticatorsInfo[1]['stub']); + } +} + +abstract class DummyAuthenticator implements InteractiveAuthenticatorInterface +{ + public function createToken(Passport $passport, string $firewallName): TokenInterface + { + } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Security/FirewallConfigTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Security/FirewallConfigTest.php index be741ecc30c41..59cb0fcc94e91 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Security/FirewallConfigTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Security/FirewallConfigTest.php @@ -18,7 +18,7 @@ class FirewallConfigTest extends TestCase { public function testGetters() { - $listeners = ['logout', 'remember_me']; + $authenticators = ['form_login', 'remember_me']; $options = [ 'request_matcher' => 'foo_request_matcher', 'security' => false, @@ -43,7 +43,7 @@ public function testGetters() $options['entry_point'], $options['access_denied_handler'], $options['access_denied_url'], - $listeners, + $authenticators, $options['switch_user'] ); @@ -57,7 +57,7 @@ public function testGetters() $this->assertSame($options['access_denied_handler'], $config->getAccessDeniedHandler()); $this->assertSame($options['access_denied_url'], $config->getAccessDeniedUrl()); $this->assertSame($options['user_checker'], $config->getUserChecker()); - $this->assertSame($listeners, $config->getListeners()); + $this->assertSame($authenticators, $config->getAuthenticators()); $this->assertSame($options['switch_user'], $config->getSwitchUser()); } } diff --git a/src/Symfony/Component/Security/Guard/Authenticator/GuardBridgeAuthenticator.php b/src/Symfony/Component/Security/Guard/Authenticator/GuardBridgeAuthenticator.php index a727da0eef774..5d447b49d5eaf 100644 --- a/src/Symfony/Component/Security/Guard/Authenticator/GuardBridgeAuthenticator.php +++ b/src/Symfony/Component/Security/Guard/Authenticator/GuardBridgeAuthenticator.php @@ -95,6 +95,11 @@ public function authenticate(Request $request): PassportInterface return $passport; } + public function getGuardAuthenticator(): GuardAuthenticatorInterface + { + return $this->guard; + } + private function getUser($credentials): UserInterface { $user = $this->guard->getUser($credentials, $this->userProvider); diff --git a/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php b/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php index 734378288c8e1..c502d026137ab 100644 --- a/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php +++ b/src/Symfony/Component/Security/Http/Authentication/AuthenticatorManager.php @@ -99,6 +99,7 @@ public function supports(Request $request): ?bool } $authenticators = []; + $skippedAuthenticators = []; $lazy = true; foreach ($this->authenticators as $authenticator) { if (null !== $this->logger) { @@ -108,8 +109,11 @@ public function supports(Request $request): ?bool if (false !== $supports = $authenticator->supports($request)) { $authenticators[] = $authenticator; $lazy = $lazy && null === $supports; - } elseif (null !== $this->logger) { - $this->logger->debug('Authenticator does not support the request.', ['firewall_name' => $this->firewallName, 'authenticator' => \get_class($authenticator)]); + } else { + if (null !== $this->logger) { + $this->logger->debug('Authenticator does not support the request.', ['firewall_name' => $this->firewallName, 'authenticator' => \get_class($authenticator)]); + } + $skippedAuthenticators[] = $authenticator; } } @@ -118,6 +122,7 @@ public function supports(Request $request): ?bool } $request->attributes->set('_security_authenticators', $authenticators); + $request->attributes->set('_security_skipped_authenticators', $skippedAuthenticators); return $lazy ? null : true; } @@ -126,6 +131,8 @@ public function authenticateRequest(Request $request): ?Response { $authenticators = $request->attributes->get('_security_authenticators'); $request->attributes->remove('_security_authenticators'); + $request->attributes->remove('_security_skipped_authenticators'); + if (!$authenticators) { return null; }