Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Impersonation (switch_user)

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

While we're inside security.yaml, I want to talk about another really cool feature called switch_user. Imagine you're an admin user and you're trying to debug an issue that a customer saw. But, dang it! The feature works perfectly for you! Is the customer wrong? Or is there something unique to their account? We'll never know! Time to find a different career! The end is nigh!

Suddenly, a super-hero swoops in to save the day! This hero's name? switch_user.

In security.yaml, under your firewall, activate our hero with a new key: switch_user set to true:

security:
... lines 2 - 16
firewalls:
... lines 18 - 20
main:
... lines 22 - 34
switch_user: true
... lines 36 - 57

As soon as you do this, you can go to any URL and add ?_switch_user= and the email address of a user that you want to impersonate. Let's try spacebar1@example.com.

And... access denied! Of course! To prevent any user from taking advantage of this little trick, the switch_user feature requires you to have a special role called ROLE_ALLOWED_TO_SWITCH. Go back to security.yaml and give ROLE_ADMIN users this new role under role_hierarchy:

security:
... lines 2 - 13
role_hierarchy:
ROLE_ADMIN: [ROLE_ADMIN_COMMENT, ROLE_ADMIN_ARTICLE, ROLE_ALLOWED_TO_SWITCH]
... lines 16 - 57

Ok, watch closely: we still have the magic ?_switch_user= in the URL. Hit enter. That's gone, yea! I'm logged in as spacebar1@example.com! You can see this down in the web debug toolbar. Of course, this normal user can't access this page. But if you go back to the homepage, you can surf around as the spacebar1 user.

User Provider & _switch_user

Oh, by the way, the reason that we use the email address with _switch_user, and not some other field like the id, is due to the user provider. Remember, this is the code inside Symfony that helps reload the user from the session at the beginning of each request. But it is also used by a few other features to load the user, like remember_me and switch_user. If you're using the Doctrine user provider like we are, then this property key determines which field will be used for all of this:

security:
... lines 2 - 6
providers:
# used to reload user from session & other features (e.g. switch_user)
app_user_provider:
entity:
... line 11
property: email
... lines 13 - 57

If you changed this to id, we would need to use the id with switch user.

Adding a Banner when you are Impersonating

Anyways, to exit and return to your normal identity, find a phone booth, close the door, and add ?_switch_user=_exit to any URL. And... we're back to being us!

Switch one more time back to spacebar1@example.com. One of the only issues with _switch_user is that it's not super obvious that we're switched! Yep, you might switch to a user, go check Facebook, then come back, forget that you're still switched to them, and start commenting on their behalf. What? No, I've definitely never done this... I'm just saying it's possible.

To prevent these... awkward situations, let's put a big banner on top when we're switched. Open base.html.twig and find the body tag. Here's the key: when we are switched to another user, Symfony gives us a special role called ROLE_PREVIOUS_ADMIN. We can use that to our advantage: if is_granted('ROLE_PREVIOUS_ADMIN'), then print an alert block. Inside, say:

You are currently switched to this user

... line 1
<html lang="en">
... lines 3 - 15
<body>
{% if is_granted('ROLE_PREVIOUS_ADMIN') %}
<div class="alert alert-warning" style="margin-bottom: 0;">
You are currently switched to this user.
... line 20
</div>
{% endif %}
... lines 23 - 80
</body>
</html>

And, to maximize our fanciness, let's add a link to exit. Use the path function to point to app_homepage. For the second argument, pass an array with the necessary _switch_user set to _exit. At the end, say "Exit Impersonation":

... line 1
<html lang="en">
... lines 3 - 15
<body>
{% if is_granted('ROLE_PREVIOUS_ADMIN') %}
<div class="alert alert-warning" style="margin-bottom: 0;">
You are currently switched to this user.
<a href="{{ path('app_homepage', {'_switch_user': '_exit'}) }}">Exit Impersonation</a>
</div>
{% endif %}
... lines 23 - 80
</body>
</html>

Adding Query Parameters with path()

Let's see how it looks! Move over and refresh! Nice! Even I won't forget when I'm impersonating. And, check out the URL on the link: it's perfect - ?_switch_user=_exit. But... wait... the way we just used the path() function was a bit weird.

Why? Open templates/article/homepage.html.twig and find the article list. You might remember that the second argument of the path() function is normally used to fill in the "wild card" values for a route:

... lines 1 - 2
{% block body %}
<div class="container">
<div class="row">
... lines 6 - 8
<div class="col-sm-12 col-md-8">
... lines 10 - 21
<div class="article-container my-1">
<a href="{{ path('article_show', {slug: article.slug}) }}">
... lines 24 - 38
</div>
... line 40
</div>
... lines 42 - 61
</div>
</div>
{% endblock %}

Hold Command or Control and click article_show. Yep! This route has a {slug} wild card:

... lines 1 - 13
class ArticleController extends AbstractController
{
... lines 16 - 37
/**
* @Route("/news/{slug}", name="article_show")
*/
public function show(Article $article, SlackClient $slack)
{
... lines 43 - 49
}
... lines 51 - 63
}

And so, when we link to it, we need to pass a value for that slug wildcard via the 2nd argument to path().

We already knew that. And this is the normal purpose of the second argument to path(). However, if you pass a key to the second argument, and that route does not have a wildcard with that name, Symfony just adds it as a query parameter.

That is why we can click this link to exit impersonation.

Next - let's build an API endpoint with Symfony's serializer! That will be our first step towards API authentication.

Leave a comment!

15
Login or Register to join the conversation
Tim-K Avatar
Tim-K Avatar Tim-K | posted 2 years ago | edited

Hey SymfonyCast-Team,

I have the same issue as Manoj (see below). I implemented it mainly as described, but I am using the interface UserLoaderInterface in my UserRepository (and removed propery: email in my yaml-file). Reason for this is that the email-address (to identify a user) is not at the USER-Entity itself, but with a 1:1 relationship at the PERSON-Enity). This is working perfectly until it comes to the user-switch.

So it seems to work in general (see my "tests"):

  • If I use a non existing email I get ("Switch User Failed: User "test@test.com" not found.")-Error
  • If I use an existing email, this error is not thrown.
  • If I remove the role "ROLE_ALLOWED_TO_SWITCH", I get an "Access denied"-Error
  • If I add the role "ROLE_ALLOWED_TO_SWITCH", I don't get this error.

But once I try to switch the user (by a valid email-address), I am redirected to the login-page and I am logged out by the system.

Any tip how to solve this or how to inverstigate the issue?

Best regards!!!
Tim

Reply

Hey Tim,

Sorry, it's not clear enough for me what exactly does not work as expected? The 4 use cases you described sound ok to me. The problem is that you're logged out by the system automatically? Well, if you change the user data directly in the DB - it may happen. Could you try to add all required roles in the DB you need for the user you want to test first? Make sure that this user has that ROLE_ALLOWED_TO_SWITCH role too. Then, logout, go to the login page, log in as this user (with ROLE_ALLOWED_TO_SWITCH) using valid credentials, make sure you're successfully logged in. Refresh the page just in case to make sure you're not logged out due to some problems on the next request. And try now to impersonate someone (another user that exist in the DB). Does it work for you now?

Cheers!

Reply
Tim-K Avatar
Tim-K Avatar Tim-K | Victor | posted 2 years ago | edited

Hello Victor, sorry for not being clear enough.

Person A has the role 'ROLE_ALLOWED_TO_SWITCH' and 'ROLE_ADMIN. Cache cleaned. Browser closed. Person A can login as expected and use the web-application on multiple levels and pages as expected. This behavior is implemented since month and no issue.

Now Person A is logged in and tries to impersonate (https://dev.website.local/some_name?_switch_user=person_b@test.com). Now the user is redirected to the login-page and Person A is logged out.

Here is some of my code


security:
    # ...
    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                # 'property' replaced by function 'UserRepository::loadUserByUsername()'
                # property: email
    firewalls:
        # ...
        main:
            # ...
            switch_user: true

class UserRepository extends ServiceEntityRepository implements UserLoaderInterface {

// ...
public function loadUserByUsername(string $username) {
    return $this->createQueryBuilder('user')
            ->innerJoin('user.person', 'person')
            ->andWhere('person.email = :email')->setParameter('email', $username);
            ->getQuery();
            ->getOneOrNullResult();
}

}


class User implements UserInterface {
    // ...
    public function getUsername(): string {
        return (string) $this->person->getEmail();
    }
    // ...
}

class LoginFormAuthenticator extends AbstractFormLoginAuthenticator {

// ...
public function getUser($credentials, UserProviderInterface $userProvider): ?UserInterface {
    // validate CSRF-token
    $token = new CsrfToken('authenticate', $credentials['csrf_token']);
    if (!$this->csrfTokenManager->isTokenValid($token)) {
        throw new InvalidCsrfTokenException();
    }
    // get the USER-Object
    $email = $credentials['email'];
    $user  = $userProvider->loadUserByUsername($email);
    return $user;
}
// ...

}


Reply

Hey Tim K. !

Hmm. When a user is suddenly logged out like this, I always think of one specific cause. Before I explain it (and end up being wrong about the cause... and wasting all or our times ;) ), try an experiment:

A) Make your User class implement EquatableInterface - https://github.com/symfony/symfony/blob/712100924d9dc875b536b8c25f88b6a2d9d40169/src/Symfony/Component/Security/Core/User/EquatableInterface.php

B) And return true from the method:


public function isEqualTo(UserInterface $user)
{
    return true;
}

Let me know if that fixes the issue. If it does, I can explain and we can find the root cause. If it does not, then we'll know to look for something else :).

Cheers!

Reply
Tim-K Avatar

Hey Ryan,

cool. It worked with these small lines of code.
Curious to know why...

Can I keep it like this or do I need to replace this test by something else?

Cheers
Tim

Reply

Hey Tim K.!

Woohoo! Ok, now that we know this fixes things, let's talk about what's *really* going on and the "proper" fix for this. Here is an ancient - but still totally relevant - conversation about this - https://symfonycasts.com/sc...

So basically, you should not keep this isEqualTo() "fix"... it could have a bad security edge case. What we need to do is figure out why - when you switch users - your User seems to suddenly "change". What I think is happening is this:

A) You switch to person_b@test.com. This works and Symfony then redirects
B) When the first page loads after switching, the User object (for person_b) is loaded from the session. Then, this hasUserChanged() method is called: https://github.com/symfony/...

You can see how - if your User implements EquatableInterface - you "short circuit" this method and take full control of it. What we need to do now is remove that method & interface, and try to figure out what part of that rest of that function is causing it to return "true". You could add some debug code right before each "return true" to see which is triggered.

One related question is: does your User class have a custom serialize() method? Usually your User class does *not* need this method... and it is often the cause of the problem (usually you are missing serializing a field that is then checked in this hasUserChanged() method... which means that it is "missing" from the deserialized User object and so it looks "changed" when comparing that field to the object stored in the database).

Phew! Let me know what you find out!

P.S. - I hope you see you also in a few days for Germany Symfony Live ;).

Cheers!

Reply
Tim-K Avatar
Tim-K Avatar Tim-K | weaverryan | posted 2 years ago | edited

Good Morning Ryan,

I really appreciate your support. Many Thanks!

I have checked the function and it returns indeed "true" at the last IF-statement.It seems that not all required data is loaded in the USER-object, as the SubObject PERSON is sort of empty (it has the ID-value, but all other values are NULL). But this object is delivering the email-address (=getUsername()).

<b>Symfony\Component\Security\Core\Authentication\Token</b>
`
private function hasUserChanged(UserInterface $user): bool {
// ...

if ($this->user->getUsername() !== $user->getUsername()) {

dump($this->user->getUsername());   // retruns an empty string
dump($user->getUsername());            // returns the email of switched user ("person_b@test.com")
dump($this->user); // returns the correct user-object, but the sub-object PERSON has "__isInitialized__=false" (lazy loading?)

dd("checkpoint 7");
return true;

}
//...
}
`

<b>App\Entity\User (Reminder see above)</b>

`
//..
public function getUsername(): string {
return (string) $this->person->getEmail();

}

//..

`
... and no, App\Entity\User-class has no serialize()-methode.

What can I do now?

Cheers!


<b>Addendum</b>

I added "fetch='EAGER'" to to the PERSON-property in the USER-class, which seems to solve the problem.

<b>App\Entity\User</b>
`

// ...

/**
 *
 * @ORM\OneToOne(targetEntity=Person::class, inversedBy="user", fetch="EAGER", cascade={"persist", "remove"})
 * @ORM\JoinColumn(
 *     nullable=false,
 *     referencedColumnName="myID"
 *     )
 */
private $person;

// ...
`

Is this a valid solution or are there any disadvantages of this?

Cheers!
Tim

Reply

Hey Tim K.!

Sorry for the slow reply - I had a personal issue. And, excellent debugging!

> I added "fetch='EAGER'" to to the PERSON-property in the USER-class, which seems to solve the problem.

This is totally fine. The only disadvantage (which might really be fine) is that whenever you query for your User object, Doctrine will automatically also query for your Person object. But if you're routinely using that data anyways... that will make no difference. We could think of other solutions... but I think this is probably just. fine :).

Cheers!

Reply
Tim-K Avatar

👍

many thanks!

Reply

I have implemented impersonation feature. And it works on the local machine. But at my hosting it doesn't works. When I trying to switch user on real hosting - nothing happens. No errors and no switching. How can I debug this?

Reply

Hey Eugem

I believe the user you are using on your production site does not have the role required for impersonating users. Could you double check that?

Cheers!

Reply

Hi! It were settings of Apache web server

1 Reply
infete Avatar

Hi, This was first time I came to know about impersonation feature in Symfony. I have implemented it. but sadly it doesn't work in my app. Whenever I try, it just redirect me back to login page.
You can see the code here:
https://github.com/napester...

Reply

Hey infete

Hmm, everything looks fine to me. If it's redirecting you back to the login page is because you don't have the permission to switch users. Clear the case and try again but with an admin account

Cheers!

Reply
infete Avatar

Okay, makes sense. Thanks

7 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.8.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.0.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.0
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.1.4
        "symfony/console": "^4.0", // v4.1.4
        "symfony/flex": "^1.0", // v1.17.6
        "symfony/framework-bundle": "^4.0", // v4.1.4
        "symfony/lts": "^4@dev", // dev-master
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.1.4
        "symfony/serializer-pack": "^1.0", // v1.0.1
        "symfony/twig-bundle": "^4.0", // v4.1.4
        "symfony/web-server-bundle": "^4.0", // v4.1.4
        "symfony/yaml": "^4.0", // v4.1.4
        "twig/extensions": "^1.5" // v1.5.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.0.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.1.4
        "symfony/dotenv": "^4.0", // v4.1.4
        "symfony/maker-bundle": "^1.0", // v1.7.0
        "symfony/monolog-bundle": "^3.0", // v3.3.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.1.4
        "symfony/profiler-pack": "^1.0", // v1.0.3
        "symfony/var-dumper": "^3.3|^4.0" // v4.1.4
    }
}
userVoice