Advanced user switching

Posted on 24 Feb 2015
Tagged with: [ PHP ]  [ security ]  [ switch user ]  [ symfony2

A really neat trick in the Symfony Security component is the fact that you can impersonate or “switch” users. This allows you to login as another user, without supplying their password. Suppose a client of your application has a problem at a certain page which you want to investigate. Sometimes this is not possible under your own account, as you don’t have the same data as the user, so the issue might not even occur in your account. Instead of asking the password from the user itself, which is cumbersome, and not a very safe thing to begin with, you can use the switch-user feature.

Obviously, switching users is not something you like to enable for all users. By default this feature is disabled and you must explicitly turn it on by adding switch_user: true in your firewall configuration. From that point on, you can switch to another user by supplying the following in your URLs: http://myapp.com/foo/bar?_switch_user=johndoe. Symfony will run in the context for the “johndoe” until you  to log out.

Again, it’s not a feature you want to enable for everybody, so as a secondary barrier, it will only work when the user who wants to make the switch has the ROLE_ALLOWED_TO_SWITCH role.

One of the issues with this system, is that there is no way of restricting to which users somebody can switch to. If you are allowed to switch users, you are allowed to switch to everybody. This is not always a good thing. For instance, let’s assume that you have three types of users in your application: users, which are your normal (paid) clients. *support, which are users working for the company that deal with issues from users, and **admins, which are users that administer and maintain the application itself. They for instance can change or add support users, while support users can change or add only regular users.

Having a switch-user feature can really be a major benefit in such an application. Whenever users are in trouble, a support user can login as that user and fix the problem. However, when a support user is allowed to switch, they are also allowed to switch to administrators accounts. In this case, we only want support users to be able to switch to regular users, but not administrators (and even other support users).

The new listener

We solve this problem by creating a new switch user listener. While normally it would be easy enough to extend functionality, we cannot do so in this case, because the functionality we need to adapt is stored inside a private method. There is no way we could extend it, or even create a decent composite class from it. We are forced to copy/paste the class in order to add a few single lines. If there are other ways of doing this, please let me know!

The listener that deals with switching users is the SwitchUserListener. This listener checks if you have specified the _switch_user option in your querylist, and if so, it will call the attemptSwitchUser() method. This method does all the work: it checks if the user exists, if you have the ROLE_ALLOWED_TO_SWITCH role and finally dispatches a SWITCH_USER event.

Unfortunately, we cannot add our functionality inside the SWITCH_USER event, as it will not allow us to change the outcome of the result (it’s just for notification purposes). We cannot create a decorator class, that will call attemptSwitchUser() and add functionality before or after, because the SWITCH_USER will be called, even when we do not have enough rights to switch. We only want this event to be dispatched when we are sure we can switch the user.

Our only option is to copy/paste the whole attemptSwitchUser() method into extended class, and add a bit of checking. But, we will be changing the constructor a bit too. So all in all, i’ve decided to copy/paste the WHOLE class instead :(

At the end of the argument list of our constructor, we add a $switchableRole parameter. This is the name of the role a user must have in order to be able to switch to. By default this will be ROLE_SWITCHABLE. If a user does not have this role, you cannot switch to it. You don’t need to add this role to every user, you can use role inheritance for this purpose to make it easier to maintain.

public function __construct(
    SecurityContextInterface $securityContext, 
    UserProviderInterface $provider, 
    UserCheckerInterface $userChecker, 
    $providerKey, 
    AccessDecisionManagerInterface $accessDecisionManager, 
    LoggerInterface $logger = null, 
    $usernameParameter = '_switch_user', 
    $role = 'ROLE_ALLOWED_TO_SWITCH', 
    EventDispatcherInterface $dispatcher = null, 
    $switchableRole = 'ROLE_SWITCHABLE')
{

The only other change is inside the attemptSwitchUser() method. Most of this class is regular copy/paste:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
private function attemptSwitchUser(Request $request)
{
    $token = $this->securityContext->getToken();
    $originalToken = $this->getOriginalToken($token);

    if (false !== $originalToken) {
        if ($token->getUsername() === $request->get($this->usernameParameter)) {
            return $token;
        } else {
            throw new \LogicException(
                sprintf('You are already switched to "%s" user.', $token->getUsername())
            );
        }
    }

    if (false === $this->accessDecisionManager->decide($token, array($this->role))) {
        throw new AccessDeniedException();
    }

    $username = $request->get($this->usernameParameter);


    if (null !== $this->logger) {
        $this->logger->info(sprintf('Attempt to switch to user "%s"', $username));
    }

    $user = $this->provider->loadUserByUsername($username);
    $this->userChecker->checkPostAuth($user);

    $roles = $user->getRoles();
    $roles[] = new SwitchUserRole('ROLE_PREVIOUS_ADMIN', $this->securityContext->getToken());

    $token = new UsernamePasswordToken(
        $user, 
        $user->getPassword(), 
        $this->providerKey, 
        $roles
    );

// START: THIS IS WHAT WE NEED TO ADD
    // Do not allow access when the user we switch to has not the switchable role
    if (false === $this->accessDecisionManager->decide($token, array($this->switchableRole))) {
        throw new AccessDeniedException();
    }
// END: THIS IS WHAT WE NEED TO ADD

    if (null !== $this->dispatcher) {
        $switchEvent = new SwitchUserEvent($request, $token->getUser());
        $this->dispatcher->dispatch(SecurityEvents::SWITCH_USER, $switchEvent);
    }

    return $token;
}

The only check we add, just before we call the dispatcher, is to see if the user we are switching to, has the switchable role. If not, we throw an accessDeniedExecption.

All that remains is tying this code into your application. In Symfony2 this means adding it to your services.yml:

services:
    security.authentication.switchuser_listener:
        class: Rainbow\SecurityBundle\Security\Firewall\SwitchUserListener
        public: false
        abstract: true
        arguments: [ "@security.context", 
                     "", 
                     "@security.user_checker", 
                     "", 
                     "@security.access.decision_manager", 
                     "@?logger", 
                     "_switch_user", 
                     "ROLE_ALLOWED_TO_SWITCH", 
                     "@?event_dispatcher", 
                     "ROLE_SWITCHABLE" 
                   ]
        tags:
            - { name: monolog.logger, channel: security }

Make sure the name of the service is security.authentication.switchuser_listener. This is because it will override the default switch user listener. That way, it will be used by default by the security component without additional work. Inside the arguments there are some empty arguments, and that is ok: it will be filled in by Symfony during the creation of the firewalls. This class is only used as a “template” for the real switch user listeners.

And that’s it! Whenever you have the ROLE_IS_ALLOWED_TO_SWITCH, you can actually change to any user, provided they have the ROLE_SWITCHABLE. It’s easy to configure and even extend this functionality. For instance, have users with ROLE_ADMIN be able to switch to any user regardless of the switchable role.

You can find the complete class and service definition in this gist: https://gist.github.com/jaytaph/c75ea98dbefea9e83790


Did you find this blogpost interesting?

Check out the security edition of the Symfony Rainbow Deepdive series! It contains an in-depth explanation of the Security component, the different bundles and bridges and detailed explaination of all configuration options.

The book, and free sample book are available as PDF, EPUB and MOBI on Leanpub: https://leanpub.com/symfonyframeworkdeepdive-security.