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.