Skip to content

Commit

Permalink
feature #33558 [Security] AuthenticatorManager to make "authenticator…
Browse files Browse the repository at this point in the history
…s" first-class security (wouterj)

This PR was squashed before being merged into the 5.1-dev branch.

Discussion
----------

[Security] AuthenticatorManager to make "authenticators" first-class security

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | -
| License       | MIT
| Doc PR        | tbd

The tl;dr
---

The old authentication listener + authentication provider system was replaced by a new "authenticator" system (similar to Guard authentication). All existing "auth systems" (e.g. `form_login` are now written as an "authenticator" in core).

Instead of each "authentication system" registering its own listener in the `Firewall`, there is now only one listener: `AuthenticatorManagerListener`

* `Firewall` -> executes `AuthenticatorManagerListener`
* `AuthenticatorManagerListener` -> calls `AuthenticatorManager`
* `AuthenticatorManager` -> calls each authenticator

This PR contains *no deprecations* and the "new system" is *marked as experimental*. This allows to continue to develop the new Security system during the 5.x release cycle without disturbing Symfony users. In 5.4, we can deprecate "old" Security and remove it completely in 6.0.

Important Decisions
---

* A) **The new authentication manager - `AuthenticatorManager` - now dispatches 3 important "hook" events**:

  * `VerifyAuthenticatorCredentialsEvent`: occurs at the point when a "password" needs to be checked. Allows us to centralize password checking, CSRF validation, password upgrading and the "user checker" logic.
  * `LoginSuccessEvent`: Dispatched after a successful authentication. E.g. used by remember me listener.
  * `LoginFailedEvent`: Dispatched after an unsuccessful authentication. Also used by remember me (and in theory could be used for login throttling).

* B) **`getCredentials()`, `getUser()` and `checkCredentials()` methods from old Guard are gone: their logic is centralized**.
   Authenticators now have an `authenticate(Request $request): PassportInterface` method. A passport contains the user object, the credentials and any other add-in Security badges (e.g. CSRF):

   ```php
   public function authenticate(Request $request): PassportInterface
   {
       return new Passport(
           $user,
           new PasswordCredentials($request->get('_password')),
           [
               new CsrfBadge($request->get('_token'))
           ]
       );
   }
   ```

   All badges (including the credentials) need to be resolved by listeners to `VerifyAuthenticatorCredentialsEvent`. There is build-in core support for the following badges/credentials:

   * `PasswordCredentials`: validated using the password encoder factory
   * `CustomCredentials`: allows a closure to do credentials checking
   * `CsrfTokenBadge`: automatic CSRF token verification
   * `PasswordUpgradeBadge`: enables password migration
   * `RememberMeBadge`: enables remember-me support for this authenticator

* C) **`AuthenticatorManager` contains all logic to authenticate**
  As authenticators always relate to HTTP, the `AuthenticatorManager` contains all logic to authenticate. It has three methods, the most important two are:

  * `authenticateRequest(Request $request): TokenInterface`: Doing what is previously done by a listener and an authentication provider;
  * `authenticateUser(UserInterface $user, AuthenticatorInterface $authenticator, Request $request, array $badges = [])` for manual login in e.g. a controller.

* D) **One AuthenticatorManager per firewall**
  In the old system, there was 1 authentication manager containing all providers and each firewall had a specific firewall listener. In the new system, each firewall has a specific authentication manager.

* E) **Pre-authentication tokens are dropped.**
  As everything is now handled inside `AuthenticatorManager` and everything is stored in the Security `Passport`, there was no need for a token anymore (removing lots of confusion about what information is inside the token).

  This change deprecates 2 authentication calls: one in `AuthorizationChecker#isGranted()` and one in `AccessListener`.  These seem now to be mis-used to reload users (e.g. re-authenticate the user after you change their roles). This (some "way" to change a user's roles *without* logging them out) needs to be "fixed"/added in another PR.

* F) **The remember me service now uses *all* user providers**
  Previously, only user providers of authentication providers listening on that firewall were used. This change is due to practical reasons and we don't think it is common to have 2 user providers supporting the same user instance. In any case, you can always explicitly configure the user provider under `remember_me`.

* G) **Auth Providers No Longer Clear the Token on Auth Failure**
  Previously, authentication providers did `$this->tokenStorage->setToken(null)` upon authentication failure. This is not yet implemented: our reasoning is that if you've authenticated successfully using e.g. the login form, why should you be logged out if you visit the same login form and enter wrong credentials?
  The pre-authenticated authenticators are an exception here, they do reset the token upon authentication failure, just like the old system.

* H) **CSRF Generator Service ID No Longer Configurable**
  The old Form login authentication provider allowed you to configure the CSRF generator service ID. This is no longer possible with the automated CSRF listener. This feature was introduced in the first CSRF commit and didn't get any updates ever since, so we don't think this feature is required. This could also be accomplished by checking CSRF manually in your authenticator, instead of using the automated check.

Future Considerations
---

* Remove Security sub-components: Move CSRF to `Symfony\Component\Csrf` (just like mime); Deprecated Guard; Put HTTP + Core as `symfony/security`. This means moving the new classes to `Symfony\Component\Security`

* Convert LDAP to the new system

* This is fixed (and merged) by #36243 <s>There is a need for some listeners to listen for events on one firewall, but not another (e.g. `RememberMeListener`). This is now fixed by checking the `$providerKey`. We thought it might be nice to introduce a feature to the event dispatcher:</s>

  * <s>Create one event dispatcher per firewall;</s>
  * <s>Extend the `kernel.event_subscriber` tag, so that you can optionally specify the dispatcher service ID (to allow listening on events for a specific dispatcher);</s>
  * <s>Add a listener that always also triggers the events on the main event dispatcher, in case you want a listener that is listening on all firewalls.</s>

* Drop the `AnonymousToken` and `AnonymousAuthenticator`: Anonymous authentication has never made much sense and complicates things (e.g. the user can be a string). For access control, an anonymous user has the same meaning as an un-authenticated one (`null`). This require changes in the `AccessListener` and `AuthorizationChecker` and probably also a new Security attribute (to replace `IS_AUTHENTICATED_ANONYMOUSLY`). Related issues: #34909, #30609

> **How to test**
> 1. Install the Symfony demo application (or any Symfony application)
> 2. Clone my Symfony fork (`git clone git@github.com:wouterj/symfony`) and checkout my branch (`git checkout security/deprecate-providers-listeners`)
> 3. Use the link utility to link my fork to the Symfony application: `/path/to/symfony-fork/link /path/to/project`
> 4. Enable the new system by setting `security.enable_authenticator_manager` to `true`

Commits
-------

b1e040f Rename providerKey to firewallName for more consistent naming
50224aa Introduce Passport & Badges to extend authenticators
9ea32c4 Also use authentication failure/success handlers in FormLoginAuthenticator
0fe5083 Added JSON login authenticator
7ef6a7a Use the firewall event dispatcher
95edc80 Added pre-authenticated authenticators (X.509 & REMOTE_USER)
f5e11e5 Reverted changes to the Guard component
ba3754a Differentiate between interactive and non-interactive authenticators
6b9d78d Added tests
59f49b2 Rename AuthenticatingListener
60d396f Added automatically CSRF protected authenticators
bf1a452 Merge AuthenticatorManager and AuthenticatorHandler
44cc76f Use one AuthenticatorManager per firewall
09bed16 Only load old manager if new system is disabled
ddf430f Added remember me functionality
1c810d5 Added support for lazy firewalls
7859977 Removed all mentions of 'guard' in the new system
999ec27 Refactor to an event based authentication approach
b14a5e8 Moved new authenticator to the HTTP namespace
b923e4c Enabled remember me for the GuardManagerListener
873b949 Mark new core authenticators as experimental
4c06236 Fixes after testing in Demo application
fa4b3ec Implemented password migration for the new authenticators
5efa892 Create a new core AuthenticatorInterface
5013258 Add provider key in PreAuthenticationGuardToken
526f756 Added GuardManagerListener
a172bac Added FormLogin and Anonymous authenticators
9b7fddd Integrated GuardAuthenticationManager in the SecurityBundle
a6890db Created HttpBasicAuthenticator and some Guard traits
c321f4d Created GuardAuthenticationManager to make Guard first-class Security
  • Loading branch information
fabpot committed Apr 21, 2020
2 parents 01794d0 + b1e040f commit 1abdcbb
Show file tree
Hide file tree
Showing 77 changed files with 4,857 additions and 82 deletions.
Expand Up @@ -73,6 +73,7 @@ public function getConfigTreeBuilder()
->booleanNode('hide_user_not_found')->defaultTrue()->end()
->booleanNode('always_authenticate_before_granting')->defaultFalse()->end()
->booleanNode('erase_credentials')->defaultTrue()->end()
->booleanNode('enable_authenticator_manager')->defaultFalse()->info('Enables the new Symfony Security system based on Authenticators, all used authenticators must support this before enabling this.')->end()
->arrayNode('access_decision_manager')
->addDefaultsIfNotSet()
->children()
Expand Down
Expand Up @@ -30,6 +30,7 @@ abstract class AbstractFactory implements SecurityFactoryInterface
'check_path' => '/login_check',
'use_forward' => false,
'require_previous_session' => false,
'login_path' => '/login',
];

protected $defaultSuccessHandlerOptions = [
Expand Down
Expand Up @@ -19,7 +19,7 @@
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*/
class AnonymousFactory implements SecurityFactoryInterface
class AnonymousFactory implements SecurityFactoryInterface, AuthenticatorFactoryInterface
{
public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint)
{
Expand All @@ -42,6 +42,20 @@ public function create(ContainerBuilder $container, $id, $config, $userProvider,
return [$providerId, $listenerId, $defaultEntryPoint];
}

public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string
{
if (null === $config['secret']) {
$config['secret'] = new Parameter('container.build_hash');
}

$authenticatorId = 'security.authenticator.anonymous.'.$firewallName;
$container
->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.anonymous'))
->replaceArgument(0, $config['secret']);

return $authenticatorId;
}

public function getPosition()
{
return 'anonymous';
Expand Down
@@ -0,0 +1,29 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory;

use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
* @author Wouter de Jong <wouter@wouterj.nl>
*
* @experimental in 5.1
*/
interface AuthenticatorFactoryInterface
{
/**
* Creates the authenticator service(s) for the provided configuration.
*
* @return string|string[] The authenticator service ID(s) to be used by the firewall
*/
public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId);
}
@@ -0,0 +1,56 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory;

use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;

class CustomAuthenticatorFactory implements AuthenticatorFactoryInterface, SecurityFactoryInterface
{
public function create(ContainerBuilder $container, string $id, array $config, string $userProvider, ?string $defaultEntryPoint)
{
throw new \LogicException('Custom authenticators are not supported when "security.enable_authenticator_manager" is not set to true.');
}

public function getPosition(): string
{
return 'pre_auth';
}

public function getKey(): string
{
return 'custom_authenticator';
}

/**
* @param ArrayNodeDefinition $builder
*/
public function addConfiguration(NodeDefinition $builder)
{
$builder
->fixXmlConfig('service')
->children()
->arrayNode('services')
->info('An array of service ids for all of your "authenticators"')
->requiresAtLeastOneElement()
->prototype('scalar')->end()
->end()
->end()
;
}

public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): array
{
return $config['services'];
}
}
@@ -0,0 +1,27 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory;

use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
* @author Wouter de Jong <wouter@wouterj.nl>
*
* @experimental in 5.1
*/
interface EntryPointFactoryInterface
{
/**
* Creates the entry point and returns the service ID.
*/
public function createEntryPoint(ContainerBuilder $container, string $id, array $config, ?string $defaultEntryPointId): string;
}
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory;

use Symfony\Component\Config\Definition\Builder\NodeDefinition;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
Expand All @@ -22,14 +23,15 @@
* @author Fabien Potencier <fabien@symfony.com>
* @author Johannes M. Schmitt <schmittjoh@gmail.com>
*/
class FormLoginFactory extends AbstractFactory
class FormLoginFactory extends AbstractFactory implements AuthenticatorFactoryInterface, EntryPointFactoryInterface
{
public function __construct()
{
$this->addOption('username_parameter', '_username');
$this->addOption('password_parameter', '_password');
$this->addOption('csrf_parameter', '_csrf_token');
$this->addOption('csrf_token_id', 'authenticate');
$this->addOption('enable_csrf', false);
$this->addOption('post_only', true);
}

Expand Down Expand Up @@ -61,6 +63,10 @@ protected function getListenerId()

protected function createAuthProvider(ContainerBuilder $container, string $id, array $config, string $userProviderId)
{
if ($config['enable_csrf'] ?? false) {
throw new InvalidConfigurationException('The "enable_csrf" option of "form_login" is only available when "security.enable_authenticator_manager" is set to "true", use "csrf_token_generator" instead.');
}

$provider = 'security.authentication.provider.dao.'.$id;
$container
->setDefinition($provider, new ChildDefinition('security.authentication.provider.dao'))
Expand All @@ -84,7 +90,7 @@ protected function createListener(ContainerBuilder $container, string $id, array
return $listenerId;
}

protected function createEntryPoint(ContainerBuilder $container, string $id, array $config, ?string $defaultEntryPoint)
public function createEntryPoint(ContainerBuilder $container, string $id, array $config, ?string $defaultEntryPoint): string
{
$entryPointId = 'security.authentication.form_entry_point.'.$id;
$container
Expand All @@ -96,4 +102,22 @@ protected function createEntryPoint(ContainerBuilder $container, string $id, arr

return $entryPointId;
}

public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string
{
if (isset($config['csrf_token_generator'])) {
throw new InvalidConfigurationException('The "csrf_token_generator" option of "form_login" is only available when "security.enable_authenticator_manager" is set to "false", use "enable_csrf" instead.');
}

$authenticatorId = 'security.authenticator.form_login.'.$firewallName;
$options = array_intersect_key($config, $this->options);
$container
->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.form_login'))
->replaceArgument(1, new Reference($userProviderId))
->replaceArgument(2, new Reference($this->createAuthenticationSuccessHandler($container, $firewallName, $config)))
->replaceArgument(3, new Reference($this->createAuthenticationFailureHandler($container, $firewallName, $config)))
->replaceArgument(4, $options);

return $authenticatorId;
}
}
Expand Up @@ -21,7 +21,7 @@
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class HttpBasicFactory implements SecurityFactoryInterface
class HttpBasicFactory implements SecurityFactoryInterface, AuthenticatorFactoryInterface
{
public function create(ContainerBuilder $container, string $id, array $config, string $userProvider, ?string $defaultEntryPoint)
{
Expand All @@ -46,6 +46,17 @@ public function create(ContainerBuilder $container, string $id, array $config, s
return [$provider, $listenerId, $entryPointId];
}

public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string
{
$authenticatorId = 'security.authenticator.http_basic.'.$firewallName;
$container
->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.http_basic'))
->replaceArgument(0, $config['realm'])
->replaceArgument(1, new Reference($userProviderId));

return $authenticatorId;
}

public function getPosition()
{
return 'http';
Expand Down
Expand Up @@ -20,7 +20,7 @@
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class JsonLoginFactory extends AbstractFactory
class JsonLoginFactory extends AbstractFactory implements AuthenticatorFactoryInterface
{
public function __construct()
{
Expand Down Expand Up @@ -96,4 +96,18 @@ protected function createListener(ContainerBuilder $container, string $id, array

return $listenerId;
}

public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId)
{
$authenticatorId = 'security.authenticator.json_login.'.$firewallName;
$options = array_intersect_key($config, $this->options);
$container
->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.json_login'))
->replaceArgument(1, new Reference($userProviderId))
->replaceArgument(2, isset($config['success_handler']) ? new Reference($this->createAuthenticationSuccessHandler($container, $firewallName, $config)) : null)
->replaceArgument(3, isset($config['failure_handler']) ? new Reference($this->createAuthenticationFailureHandler($container, $firewallName, $config)) : null)
->replaceArgument(4, $options);

return $authenticatorId;
}
}

1 comment on commit 1abdcbb

@AkashicSeer
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "enable_csrf" option of "form_login" is only available when "security.enable_authenticator_manager" is set to "true", use "csrf_token_generator" instead.

Can someone update the docs? Because following the docs leads to this error. And how do I fix this error?

Please sign in to comment.