feature #24260 [Security] Add impersonation support for stateless authentication (chalasr)

This PR was merged into the 3.4 branch.

Discussion
----------

[Security] Add impersonation support for stateless authentication

| Q             | A
| ------------- | ---
| Branch?       | 3.4
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | https://github.com/lafourchette/SwitchUserStatelessBundle/issues/10#issuecomment-330434589
| License       | MIT
| Doc PR        | n/a

The `switch_user` listener triggers a redirection in case of success and thus does not play well with stateless authentication which is common nowadays (as opposed to other listeners like the [exception one](https://github.com/symfony/symfony/blob/master/src/Symfony/Component/Security/Http/Firewall/ExceptionListener.php#L187..#L189)).
This adds a new `stateless` option to the `switch_user` listener, if set to true then no redirection is triggered during user switching.

This will avoid the need for [lafourchette/SwitchUserStatelessBundle](https://github.com/lafourchette/SwitchUserStatelessBundle) which just duplicated the symfony SwitchUserListener (with config factory) at a given state to avoid the 2 LOC which are causing the redirection.
The bundle is not actively maintained and the listener it provides is out of date due to the missing upstream additions and bug fixes (see https://github.com/lafourchette/SwitchUserStatelessBundle/issues/10).

Commits
-------

e7a5803e2e [Security] Add user impersonation support for stateless authentication
This commit is contained in:
Fabien Potencier 2017-09-30 06:39:03 -07:00
commit b1e2d21213
24 changed files with 120 additions and 20 deletions

View File

@ -316,6 +316,9 @@ SecurityBundle
* Deprecated the HTTP digest authentication: `HttpDigestFactory` will be removed in 4.0.
Use another authentication system like `http_basic` instead.
* Deprecated setting the `switch_user.stateless` option to false when the firewall is `stateless`.
Setting it to false will have no effect in 4.0.
Translation
-----------

View File

@ -693,6 +693,8 @@ SecurityBundle
* Removed the HTTP digest authentication system. The `HttpDigestFactory` class
has been removed. Use another authentication system like `http_basic` instead.
* The `switch_user.stateless` option is now always true if the firewall is stateless.
Serializer
----------

View File

@ -17,6 +17,7 @@ CHANGELOG
* deprecated command `acl:set` along with `SetAclCommand` class
* deprecated command `init:acl` along with `InitAclCommand` class
* Added support for the new Argon2i password encoder
* added `stateless` option to the `switch_user` listener
3.3.0
-----

View File

@ -304,6 +304,7 @@ class MainConfiguration implements ConfigurationInterface
->scalarNode('provider')->end()
->scalarNode('parameter')->defaultValue('_switch_user')->end()
->scalarNode('role')->defaultValue('ROLE_ALLOWED_TO_SWITCH')->end()
->booleanNode('stateless')->defaultValue(false)->end()
->end()
->end()
;

View File

@ -456,7 +456,7 @@ class SecurityExtension extends Extension
// Switch user listener
if (isset($firewall['switch_user'])) {
$listenerKeys[] = 'switch_user';
$listeners[] = new Reference($this->createSwitchUserListener($container, $id, $firewall['switch_user'], $defaultProvider));
$listeners[] = new Reference($this->createSwitchUserListener($container, $id, $firewall['switch_user'], $defaultProvider, $firewall['stateless']));
}
// Access listener
@ -699,10 +699,15 @@ class SecurityExtension extends Extension
return $exceptionListenerId;
}
private function createSwitchUserListener($container, $id, $config, $defaultProvider)
private function createSwitchUserListener($container, $id, $config, $defaultProvider, $stateless)
{
$userProvider = isset($config['provider']) ? $this->getUserProviderId($config['provider']) : $defaultProvider;
// in 4.0, ignore the `switch_user.stateless` key if $stateless is `true`
if ($stateless && false === $config['stateless']) {
@trigger_error(sprintf('Firewall "%s" is configured as "stateless" but the "switch_user.stateless" key is set to false. Both should have the same value, the firewall\'s "stateless" value will be used as default value for the "switch_user.stateless" key in 4.0.', $id), E_USER_DEPRECATED);
}
$switchUserListenerId = 'security.authentication.switchuser_listener.'.$id;
$listener = $container->setDefinition($switchUserListenerId, new ChildDefinition('security.authentication.switchuser_listener'));
$listener->replaceArgument(1, new Reference($userProvider));
@ -710,6 +715,7 @@ class SecurityExtension extends Extension
$listener->replaceArgument(3, $id);
$listener->replaceArgument(6, $config['parameter']);
$listener->replaceArgument(7, $config['role']);
$listener->replaceArgument(9, $config['stateless']);
return $switchUserListenerId;
}

View File

@ -241,6 +241,7 @@
<argument>_switch_user</argument>
<argument>ROLE_ALLOWED_TO_SWITCH</argument>
<argument type="service" id="event_dispatcher" on-invalid="null"/>
<argument>false</argument> <!-- Stateless -->
</service>
<service id="security.access_listener" class="Symfony\Component\Security\Http\Firewall\AccessListener">

View File

@ -130,6 +130,7 @@ abstract class CompleteConfigurationTest extends TestCase
array(
'parameter' => '_switch_user',
'role' => 'ROLE_ALLOWED_TO_SWITCH',
'stateless' => true,
),
),
array(
@ -256,6 +257,7 @@ abstract class CompleteConfigurationTest extends TestCase
array(
'parameter' => '_switch_user',
'role' => 'ROLE_ALLOWED_TO_SWITCH',
'stateless' => true,
),
),
array(

View File

@ -65,7 +65,7 @@ $container->loadFromExtension('security', array(
'http_basic' => true,
'form_login' => true,
'anonymous' => true,
'switch_user' => true,
'switch_user' => array('stateless' => true),
'x509' => true,
'remote_user' => true,
'logout' => true,

View File

@ -67,7 +67,7 @@ $container->loadFromExtension('security', array(
'http_digest' => array('secret' => 'TheSecret'),
'form_login' => true,
'anonymous' => true,
'switch_user' => true,
'switch_user' => array('stateless' => true),
'x509' => true,
'remote_user' => true,
'logout' => true,

View File

@ -67,7 +67,7 @@ $container->loadFromExtension('security', array(
'http_digest' => array('secret' => 'TheSecret'),
'form_login' => true,
'anonymous' => true,
'switch_user' => true,
'switch_user' => array('stateless' => true),
'x509' => true,
'remote_user' => true,
'logout' => true,

View File

@ -17,7 +17,7 @@ $container->loadFromExtension('security', array(
'http_basic' => true,
'form_login' => true,
'anonymous' => true,
'switch_user' => true,
'switch_user' => array('stateless' => true),
'x509' => true,
'remote_user' => true,
'logout' => true,

View File

@ -49,7 +49,7 @@
<http-basic />
<form-login />
<anonymous />
<switch-user />
<switch-user stateless="true" />
<x509 />
<remote-user />
<user-checker />

View File

@ -51,7 +51,7 @@
<http-digest secret="TheSecret" />
<form-login />
<anonymous />
<switch-user />
<switch-user stateless="true" />
<x509 />
<remote-user />
<user-checker />

View File

@ -52,7 +52,7 @@
<http-digest secret="TheSecret" />
<form-login />
<anonymous />
<switch-user />
<switch-user stateless="true" />
<x509 />
<remote-user />
<user-checker />

View File

@ -17,7 +17,7 @@
<http-basic />
<form-login />
<anonymous />
<switch-user />
<switch-user stateless="true" />
<x509 />
<remote-user />
<user-checker />

View File

@ -47,7 +47,8 @@ security:
http_basic: true
form_login: true
anonymous: true
switch_user: true
switch_user:
stateless: true
x509: true
remote_user: true
logout: true

View File

@ -50,7 +50,8 @@ security:
secret: TheSecret
form_login: true
anonymous: true
switch_user: true
switch_user:
stateless: true
x509: true
remote_user: true
logout: true

View File

@ -50,7 +50,8 @@ security:
secret: TheSecret
form_login: true
anonymous: true
switch_user: true
switch_user:
stateless: true
x509: true
remote_user: true
logout: true

View File

@ -12,7 +12,8 @@ security:
http_basic: true
form_login: true
anonymous: true
switch_user: true
switch_user:
stateless: true
x509: true
remote_user: true
logout: true

View File

@ -148,6 +148,32 @@ class SecurityExtensionTest extends TestCase
$container->compile();
}
/**
* @group legacy
* @expectedDeprecation Firewall "some_firewall" is configured as "stateless" but the "switch_user.stateless" key is set to false. Both should have the same value, the firewall's "stateless" value will be used as default value for the "switch_user.stateless" key in 4.0.
*/
public function testSwitchUserNotStatelessOnStatelessFirewall()
{
$container = $this->getRawContainer();
$container->loadFromExtension('security', array(
'providers' => array(
'default' => array('id' => 'foo'),
),
'firewalls' => array(
'some_firewall' => array(
'stateless' => true,
'http_basic' => null,
'switch_user' => array('stateless' => false),
'logout_on_user_change' => true,
),
),
));
$container->compile();
}
protected function getRawContainer()
{
$container = new ContainerBuilder();

View File

@ -11,6 +11,7 @@
namespace Symfony\Bundle\SecurityBundle\Tests\Functional;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Security\Http\Firewall\SwitchUserListener;
class SwitchUserTest extends WebTestCase
@ -50,6 +51,18 @@ class SwitchUserTest extends WebTestCase
$this->assertEquals('user_can_switch', $client->getProfile()->getCollector('security')->getUser());
}
public function testSwitchUserStateless()
{
$client = $this->createClient(array('test_case' => 'JsonLogin', 'root_config' => 'switchuser_stateless.yml'));
$client->request('POST', '/chk', array('_switch_user' => 'dunglas'), array(), array('CONTENT_TYPE' => 'application/json'), '{"user": {"login": "user_can_switch", "password": "test"}}');
$response = $client->getResponse();
$this->assertInstanceOf(JsonResponse::class, $response);
$this->assertSame(200, $response->getStatusCode());
$this->assertSame(array('message' => 'Welcome @dunglas!'), json_decode($response->getContent(), true));
$this->assertSame('dunglas', $client->getProfile()->getCollector('security')->getUser());
}
public function getTestParameters()
{
return array(

View File

@ -0,0 +1,13 @@
imports:
- { resource: ./config.yml }
security:
providers:
in_memory:
memory:
users:
user_can_switch: { password: test, roles: [ROLE_USER, ROLE_ALLOWED_TO_SWITCH] }
firewalls:
main:
switch_user:
stateless: true

View File

@ -49,8 +49,9 @@ class SwitchUserListener implements ListenerInterface
private $role;
private $logger;
private $dispatcher;
private $stateless;
public function __construct(TokenStorageInterface $tokenStorage, UserProviderInterface $provider, UserCheckerInterface $userChecker, $providerKey, AccessDecisionManagerInterface $accessDecisionManager, LoggerInterface $logger = null, $usernameParameter = '_switch_user', $role = 'ROLE_ALLOWED_TO_SWITCH', EventDispatcherInterface $dispatcher = null)
public function __construct(TokenStorageInterface $tokenStorage, UserProviderInterface $provider, UserCheckerInterface $userChecker, $providerKey, AccessDecisionManagerInterface $accessDecisionManager, LoggerInterface $logger = null, $usernameParameter = '_switch_user', $role = 'ROLE_ALLOWED_TO_SWITCH', EventDispatcherInterface $dispatcher = null, $stateless = false)
{
if (empty($providerKey)) {
throw new \InvalidArgumentException('$providerKey must not be empty.');
@ -65,6 +66,7 @@ class SwitchUserListener implements ListenerInterface
$this->role = $role;
$this->logger = $logger;
$this->dispatcher = $dispatcher;
$this->stateless = $stateless;
}
/**
@ -92,12 +94,13 @@ class SwitchUserListener implements ListenerInterface
}
}
$request->query->remove($this->usernameParameter);
$request->server->set('QUERY_STRING', http_build_query($request->query->all()));
if (!$this->stateless) {
$request->query->remove($this->usernameParameter);
$request->server->set('QUERY_STRING', http_build_query($request->query->all()));
$response = new RedirectResponse($request->getUri(), 302);
$response = new RedirectResponse($request->getUri(), 302);
$event->setResponse($response);
$event->setResponse($response);
}
}
/**

View File

@ -266,4 +266,29 @@ class SwitchUserListenerTest extends TestCase
$this->assertSame($replacedToken, $this->tokenStorage->getToken());
}
public function testSwitchUserStateless()
{
$token = new UsernamePasswordToken('username', '', 'key', array('ROLE_FOO'));
$user = new User('username', 'password', array());
$this->tokenStorage->setToken($token);
$this->request->query->set('_switch_user', 'kuba');
$this->accessDecisionManager->expects($this->once())
->method('decide')->with($token, array('ROLE_ALLOWED_TO_SWITCH'))
->will($this->returnValue(true));
$this->userProvider->expects($this->once())
->method('loadUserByUsername')->with('kuba')
->will($this->returnValue($user));
$this->userChecker->expects($this->once())
->method('checkPostAuth')->with($user);
$listener = new SwitchUserListener($this->tokenStorage, $this->userProvider, $this->userChecker, 'provider123', $this->accessDecisionManager, null, '_switch_user', 'ROLE_ALLOWED_TO_SWITCH', null, true);
$listener->handle($this->event);
$this->assertInstanceOf('Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken', $this->tokenStorage->getToken());
$this->assertFalse($this->event->hasResponse());
}
}