Impersonating Users on a JSON API (Without FOS User Bundle)


In order to make sure we are all starting from the same place, please do a git checkout on vid-2-starting:

git checkout tags/vid-2-starting -b vid-2

The -b allows us to combine a git checkout and git branch into one command.

Run a make dev if you haven't done so already.

Next, it's time to jump on to your running Docker PHP container:

docker-compose exec php /bin/bash

Now we need to download the third party dependencies as usual:

composer install --prefer-dist

I'm using --prefer-dist as this will download the third party code, rather than clone each repository. As best I am aware, cloning each repository is a side effect of running our project with the minimum stability of dev (because at the time of recording, Symfony 3.4 is in beta).

You will be prompted to enter values for each of the entries in parameters.yml.dist in order to create your parameters.yml file. You can safely accept all the defaults, as all values are taken from the .env file.

There is a touch of further setup required to generate your own SSH key pair, in order for Lexik JWT Authentication Bundle to operate properly.

mkdir -p var/jwt
openssl genrsa -out var/jwt/private.pem -aes256 4096
openssl rsa -pubout -in var/jwt/private.pem -out var/jwt/public.pem

You will be asked to give a passphrase, then repeat it. I chose 'qwerty', but feel free to use anything you like.

If you are not using the passphrase of 'qwerty' then please update the value JWT_KEY_PASS_PHRASE=qwerty in the .env file accordingly, and re-run make dev to reinitialize your stack with the new environment variable set.

Logging In

With our stack up and running, we can now log in.

The example comes with three pre-configured users:

# app/config/security.yml

    providers:
        in_memory:
            memory:
                users:
                    admin:
                        # password = admin
                        password: $2y$13$nEScVrvxLrhruPnyC5ZYr.IotRyae.Iv2tZtfoeXnslda9Uu.39Qi
                        roles: 'ROLE_ADMIN'
                    bob:
                        # password = testpass
                        password: $2y$13$2f1m7cBKaMllfHPLOHW6X.1VtnNf8ZtnaGPshlVbnrTiSGdudICy6
                    dave:
                        # password = davepass
                        password: $2y$13$.ueuTRhPEf8TFEpHKIWM5ezK1fPpZtoLoBNzAZcZCyjIuxAWNrwVO

All users should work just fine for login.

Personally I like to use the Postman client when working with any JSON API. Here's the cURL equivalent:

curl -X POST \
  http://127.0.0.1:81/app_dev.php/login \
  -H 'cache-control: no-cache' \
  -H 'content-type: application/json' \
  -d '{ "username": "bob", "password": "testpass" }'

Assuming everything went to plan, we should receive a JWT (JSON Web Token) in the response:

{
    "token": "eyJhbGciOiJSUzI1NiJ9.eyJyb2xlcyI6W10sInVzZXJuYW1lIjoiYm9iIiwiaWF0IjoxNTA5MTgxNzY4LCJleHAiOjE1MDkyNjgxNjh9.E8Zcw9oqSwc9UvCw00pTt8tgBnKI8ktXJwiQ16rVj42hDp4qw8Xplfhl68OTO33BPd_5BSoLfWY4W_SUEI-pM3__9LvBjkUtTQSeS6-SD8QH3Xa-AFnJjyw-go1oERcIcHOi_kjCrl3SLWgoI0AmjLMnAGogoNrZBrMAIwzFIVkHog2ec7GUi3clSPQDOQFLelANQQUa-7ejQb2JcVr0BaxdTfDggb38-_tBv8dsT5htE1IBDhC8yhd1gBBlrjGSB3pLVJu3bkFFmq4SWiJM2w3Q-XHxJOGqNzTAXoD30j69oJOQEe1MurXSsqKoCJ8cmrV1o1d4f5G94jPoyIoQWj7clYLeOl4FILY7BpMGud0RqdXWfnziPISN7_AGPAWZtoqZIiP6OXzCW1-quX40kutJZUdpn19I6qFSDdBP77BYhg3MfLl1yBqLZvvQLC1lrlB80iszL-0p0kAilrhpbH0vww12_RVvGR1df8lJ7r3lsgLQiMEkRR9-KGZkwCAdFE_bz8zGVSKrCgfU46avBLMRkKwDaWAU4A5qKqSuNXMlCaNBrP9LO13bkKRY1AKEkeLGW7Joa8mh2WeM8kNN8Ko53ZRGwvMVQInOnJHbRIC86VFmgu4RcRDn4bWhs3Dc9hdot6awxbrU4g9H2xkXExK7XGoL6iXXMrT2DFMs0kg"
}

Great.

Now, let's use this token to make a request to a secure endpoint. The /profile endpoint allows us to get access to our profile, and only our profile.

    /**
     * @Annotations\Get("/profile")
     *
     * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
     */
    public function getAction()
    {
        if (null === $this->getUser()) {
            return new JsonResponse(
                'Access denied',
                JsonResponse::HTTP_FORBIDDEN
            );
        }

        return $this->getUser();
    }

I'm not a big fan of generic endpoints. I would much prefer if the route here was /profile/{some-user-identifying-factor}, but stick with me as we will get to this shortly.

This route can be generic because $this->getUser() will return the object representing whatever user we are currently logged in as.

If we aren't logged in - as in, we have not got a valid Authorization header set - then $this->getUser() returns null, which returns our 403 / forbidden error.

A logged in request looks like this:

curl -X GET \
  http://127.0.0.1:81/app_dev.php/profile \
  -H 'authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJyb2xlcyI6W10sInVzZXJuYW1lIjoiYm9iIiwiaWF0IjoxNTA5MTgyNjQ5LCJleHAiOjE1MDkyNjkwNDl9.j5Yv-qO8LDhoR_pCniue6q6ISGlk2bxqlINd9OxAWrqY8YZu-6bmY2j5cH36qJzKA27VOpBC68rLo7Z_zj79szxNjTwfWvDe47__ciuLTi2NM10Al9G9hRbU8V9rpuhl6k9TmzR24vMOXfWy7dnquS030QehASr6cvZV35k_DTmdpUm06BRwJCYotHwtQfTntJOHIC2QobeR-6X64A7f2CU1mttGQS4WavSsRjKssTB2002p1w4GQpaTpbVWxX9t2SFjWSiCJuDHsZKviVd5b90rPpnKzJW38kP6VPJZ-MF7rvXzjNaX3kXjEbXYVmlHtmsi-7MKClacA8PBZBFCNfPW0av-fRNIEWMwb-dQdWx2iEmKtKY1QwR5YsuBALMSh6aOCXhiQUTT9V7qUA_75ymmnn8jf_xKBTyC8lLG6eAugyXkU-_oJEGpqU_mm3O30TvJwCnfOl2t4okT3Am1JpCNI4w260smc5c_7GCtI4EH0xrf_fpeF6EicavNteC5U2dwXoqm_X66WU6Z1jGczstMojcQPT-lAKWaKsYHSAOcMx2-MPOHJOIARgeiWXBvTq2ZwVRp8lcN_nFGva6WvbnnFCROs2aygYJ5SsOfUQMA7BZHLCjiIUhoHW-dOL7mzN6yu44eg7hLEjGO2QtgNHrzb89ei26YFsSnfbQkxdo' \
  -H 'cache-control: no-cache' \
  -H 'content-type: application/json'

And returns us a response similar too:

{
    "roles": [],
    "password": "$2y$13$2f1m7cBKaMllfHPLOHW6X.1VtnNf8ZtnaGPshlVbnrTiSGdudICy6",
    "salt": null,
    "username": "bob",
    "accountNonExpired": true,
    "accountNonLocked": true,
    "credentialsNonExpired": true,
    "enabled": true
}

Ok, so we're exposing far too much information here. That's not really the concern of this video. If this is a concern of yours, please see this video.

Implementing Stateless User Impersonation

A really useful feature with Symfony is the ability to easily implement user impersonation.

I use this feature right here on CodeReviewVideos, if a site member has a specific problem that I can't easily replicate. I've used this same feature on numerous other Symfony sites in the past, and likely will do far in to the future.

Recently I've been wanting to add similar functionality to a Symfony-powered JSON API.

There is a bundle that enables this. However, as covered in this ticket, this functionality was deemed to be a widely required feature, and as of Symfony 3.4, has been added to the framework.

The Problem

Let's start by grabbing ourselves a JWT for our admin user:

curl -X POST \
  http://127.0.0.1:81/app_dev.php/login \
  -H 'cache-control: no-cache' \
  -H 'content-type: application/json' \
  -d '{ "username": "admin", "password": "admin" }'

This is successful and returns a new JWT:

{
    "token": "eyJhbGciOiJSUzI1NiJ9.eyJyb2xlcyI6WyJST0xFX0FETUlOIl0sInVzZXJuYW1lIjoiYWRtaW4iLCJpYXQiOjE1MDkxODUyODYsImV4cCI6MTUwOTI3MTY4Nn0.S6jNauzQFfgtNNbeIHKlWr0ULt-UdBrHKjASJpkq4yF2XPpDEvQ0BsjnxwDu3Cex1hDqUa9v4CC-Pa2LOjf8Z7h9miwc0tXZVeDMuqpfDPB2_rARHamBWbUP6hz7WvgdEEdQcI4ZiR65uVywZZAB4nsEdZmiI9ym1vO6slqi_6Jlfd5qB9Ni9nZFUrAdpJRPICrnSDJh88tSYssj2p0YVBdoFw5y0AIjbL3wcma6n-CSjfFHOa8nmIU5HsHXYOmm4_NoIsfi1GaKJFX9eb2_bXljmJ9s5Tws1F6E_ScMXSO4uRLcHpKicKwUsGZ02YU_psPwFh5F3XsZ7wDwS6WIenVlAfdV0uyys4nSXNMSKFdfoqeyvxLwAev_ugvVvR6wawMNwjjNf0ZapffKG8SLOdscr9NlrM08wEM7PGEKAc9R0u_3BBBZ09Ta_roikjwHfjKRj0Ka4ZjLVbmOWtjDKEuI_wJ73tGNewpGdp9KQYmKFS49VdRD8bJMP8VnInY7WPb3x_7e4VjIEOJoDz2pULy32Rqc4DrAuDL7s8yS0WTPhX2sq7Fc7wM6mdbSqv_T0Q0rfMUcOGRrlZOGyCHUNaHCO0mmV2MYZrntbKEgqSk80T6IaTkYCVm32ybzynJg0ZtUfKCFDQIEyDe_0nZ76bqebrJAGgFZYqZWMPPYvIE"
}

It should go without saying that this JWT is not the same as the JWT we got back when logging in as bob.

Now, let's imagine that user Bob is experiencing a problem, and we want to view the output that Bob sees when hitting /profile.

We could reset Bob's password and run through the login process, then hit /profile, and see what happens.

This isn't so great as it inconveniences Bob more than he is already inconvenienced.

We could opt for User Impersonation. But what happens currently if we hit the /profile endpoint with our shiny new admin token?

{
    "roles": [
        "ROLE_ADMIN"
    ],
    "password": "$2y$13$nEScVrvxLrhruPnyC5ZYr.IotRyae.Iv2tZtfoeXnslda9Uu.39Qi",
    "salt": null,
    "username": "admin",
    "accountNonExpired": true,
    "accountNonLocked": true,
    "credentialsNonExpired": true,
    "enabled": true
}

Awww rats.

That's right, we just get back the 'profile' of whatever user matches the given JWT. In this case: admin.

The Solution

How can we implement user impersonation in a stateless setup then?

Ok, some quick changes:

# app/config/security.yml

    firewalls:

        # ...

        api:
            pattern:   ^/
            stateless: true
            switch_user:
                stateless: true
                parameter: "x-switch-user"
                role: ROLE_ALLOWED_TO_SWITCH
            anonymous: true
            guard:
                authenticators:
                    - lexik_jwt_authentication.jwt_token_authenticator

We're specifying a parameter of x-switch-user.

We will need to set this as a header on our incoming request.

We also specify we need the role of ROLE_ALLOWED_TO_SWITCH.

ROLE_ALLOWED_TO_SWITCH is something new and arbitrary we just invented, though is a convention followed in Symfony. In other words, we could call this ROLE_BACON, and as long as we have ROLE_BACON then we can switch users.

Let's update the role_hierarchy to ensure any user with ROLE_ADMIN also gets ROLE_ALLOWED_TO_SWITCH:

# app/config/security.yml

security:
    role_hierarchy:
        ROLE_ADMIN:
            - ROLE_USER
            - ROLE_ALLOWED_TO_SWITCH

Now, let's send in our request with user impersonation:

curl -X GET \
  http://127.0.0.1:81/app_dev.php/profile \
  -H 'authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJyb2xlcyI6WyJST0xFX0FETUlOIl0sInVzZXJuYW1lIjoiYWRtaW4iLCJpYXQiOjE1MDkxODUyODYsImV4cCI6MTUwOTI3MTY4Nn0.S6jNauzQFfgtNNbeIHKlWr0ULt-UdBrHKjASJpkq4yF2XPpDEvQ0BsjnxwDu3Cex1hDqUa9v4CC-Pa2LOjf8Z7h9miwc0tXZVeDMuqpfDPB2_rARHamBWbUP6hz7WvgdEEdQcI4ZiR65uVywZZAB4nsEdZmiI9ym1vO6slqi_6Jlfd5qB9Ni9nZFUrAdpJRPICrnSDJh88tSYssj2p0YVBdoFw5y0AIjbL3wcma6n-CSjfFHOa8nmIU5HsHXYOmm4_NoIsfi1GaKJFX9eb2_bXljmJ9s5Tws1F6E_ScMXSO4uRLcHpKicKwUsGZ02YU_psPwFh5F3XsZ7wDwS6WIenVlAfdV0uyys4nSXNMSKFdfoqeyvxLwAev_ugvVvR6wawMNwjjNf0ZapffKG8SLOdscr9NlrM08wEM7PGEKAc9R0u_3BBBZ09Ta_roikjwHfjKRj0Ka4ZjLVbmOWtjDKEuI_wJ73tGNewpGdp9KQYmKFS49VdRD8bJMP8VnInY7WPb3x_7e4VjIEOJoDz2pULy32Rqc4DrAuDL7s8yS0WTPhX2sq7Fc7wM6mdbSqv_T0Q0rfMUcOGRrlZOGyCHUNaHCO0mmV2MYZrntbKEgqSk80T6IaTkYCVm32ybzynJg0ZtUfKCFDQIEyDe_0nZ76bqebrJAGgFZYqZWMPPYvIE' \
  -H 'cache-control: no-cache' \
  -H 'content-type: application/json' \
  -H 'x-switch-user: bob'

For complete clarity, we use our admin's JWT, but provide the extra header with the key of x-switch-user, and the value of bob.

And lo-and-behold, we see Bob's profile:

{
    "roles": [],
    "password": "$2y$13$2f1m7cBKaMllfHPLOHW6X.1VtnNf8ZtnaGPshlVbnrTiSGdudICy6",
    "salt": null,
    "username": "bob",
    "accountNonExpired": true,
    "accountNonLocked": true,
    "credentialsNonExpired": true,
    "enabled": true
}

And we can switch out to look at Dave's profile also:

curl -X GET \
  http://127.0.0.1:81/app_dev.php/profile \
  -H 'authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJyb2xlcyI6WyJST0xFX0FETUlOIl0sInVzZXJuYW1lIjoiYWRtaW4iLCJpYXQiOjE1MDkxODUyODYsImV4cCI6MTUwOTI3MTY4Nn0.S6jNauzQFfgtNNbeIHKlWr0ULt-UdBrHKjASJpkq4yF2XPpDEvQ0BsjnxwDu3Cex1hDqUa9v4CC-Pa2LOjf8Z7h9miwc0tXZVeDMuqpfDPB2_rARHamBWbUP6hz7WvgdEEdQcI4ZiR65uVywZZAB4nsEdZmiI9ym1vO6slqi_6Jlfd5qB9Ni9nZFUrAdpJRPICrnSDJh88tSYssj2p0YVBdoFw5y0AIjbL3wcma6n-CSjfFHOa8nmIU5HsHXYOmm4_NoIsfi1GaKJFX9eb2_bXljmJ9s5Tws1F6E_ScMXSO4uRLcHpKicKwUsGZ02YU_psPwFh5F3XsZ7wDwS6WIenVlAfdV0uyys4nSXNMSKFdfoqeyvxLwAev_ugvVvR6wawMNwjjNf0ZapffKG8SLOdscr9NlrM08wEM7PGEKAc9R0u_3BBBZ09Ta_roikjwHfjKRj0Ka4ZjLVbmOWtjDKEuI_wJ73tGNewpGdp9KQYmKFS49VdRD8bJMP8VnInY7WPb3x_7e4VjIEOJoDz2pULy32Rqc4DrAuDL7s8yS0WTPhX2sq7Fc7wM6mdbSqv_T0Q0rfMUcOGRrlZOGyCHUNaHCO0mmV2MYZrntbKEgqSk80T6IaTkYCVm32ybzynJg0ZtUfKCFDQIEyDe_0nZ76bqebrJAGgFZYqZWMPPYvIE' \
  -H 'cache-control: no-cache' \
  -H 'content-type: application/json' \
  -H 'x-switch-user: dave'

And now we get Dave's profile:

{
    "roles": [],
    "password": "$2y$13$.ueuTRhPEf8TFEpHKIWM5ezK1fPpZtoLoBNzAZcZCyjIuxAWNrwVO",
    "salt": null,
    "username": "dave",
    "accountNonExpired": true,
    "accountNonLocked": true,
    "credentialsNonExpired": true,
    "enabled": true
}

Cool.

How do we leave impersonation mode?

Simple: don't send the x-switch-user header.

Code For This Course

Get the code for this course.

Episodes

# Title Duration
1 Tutorial Setup - Getting Docker Up and Running 02:57
2 Impersonating Users on a JSON API (Without FOS User Bundle) 05:40
3 Impersonating Users on a JSON API (With FOS User Bundle) 03:40
4 Digging A Little Deeper 06:19