Log In To A Symfony API With JWTs (LexikJWTAuthenticationBundle)
In this video we will install and configure LexikJWTAuthenticationBundle to enable JWT (JSON Web Tokens) authentication for our Symfony REST API.
Throughout this video, and the rest of the series you will hear me talking about "jots", which is how you pronounce JWT... apparently! :)
Configuring this bundle is remarkably straightforward, especially when compared to the amount of functionality it provides for so little configuration. Perhaps the biggest hurdle will be the requirement for OpenSSL being installed. If you don't have OpenSSL, on Ubuntu it is as simple as:
sudo apt-get install openssl
All other installation instructions can be copied directly from the installation instructions provided by LexikJWTAuthenticationBundle.
Test Code
Way back when we first started looking at the Behat feature for User, we wrote - but commented out - the following:
Background:
Given there are Users with the following details:
| uid | username | email | password |
| u1 | peter | peter@test.com | testpass |
| u2 | john | john@test.org | johnpass |
# And I am successfully logged in with username: "peter", and password: "testpass"
(I've removed the extra steps that aren't relevant here.)
Now we want to uncomment the line that logs our User in, otherwise the UserVoter
we created in the previous video is going to deny access (correctly) as we aren't logged in:
// src/AppBundle/Security/Authorization/Voter/UserVoter.php
public function vote(TokenInterface $token, $requestedUser, array $attributes)
{
// * snip *
// get current logged in user
$loggedInUser = $token->getUser();
// make sure there is a user object (i.e. that the user is logged in)
if ($loggedInUser === $requestedUser) {
return VoterInterface::ACCESS_GRANTED;
}
return VoterInterface::ACCESS_DENIED;
}
To clarify - if we aren't logged in, $token->getUser();
will either return a TokenInterface
if we are authenticated, or null
if not (docs for reference). Our test will see if null
is equal to the User we are requesting - which will be false, so we will skip over to the return VoterInterface::ACCESS_DENIED;
statement.
Hence, our test of:
Scenario: User can GET their personal data by their unique ID
When I send a "GET" request to "/users/u1"
Then the response code should 200
And the response header "Content-Type" should be equal to "application/json; charset=utf-8"
And the response should contain json:
"""
{
"id": "u1",
"email": "peter@test.com",
"username": "peter",
"accounts": [
{
"id": "a1",
"name": "account1"
}
]
}
"""
Will switch from passing to failing as soon as we implement login. To fix this, we must implement / uncomment the log in step in our Background section.
Fortunately, the code is already written, but let's take a look at it so we aren't flying blind:
// src/AppBundle/Features/Context/RestApiContext.php
/**
* Adds JWT Token to Authentication header for next request
*
* @param string $username
* @param string $password
*
* @Given /^I am successfully logged in with username: "([^"]*)", and password: "([^"]*)"$/
*/
public function iAmSuccessfullyLoggedInWithUsernameAndPassword($username, $password)
{
$response = $this->client->post('login', [
'json' => [
'username' => $username,
'password' => $password,
]
]);
\PHPUnit_Framework_Assert::assertEquals(200, $response->getStatusCode());
$responseBody = json_decode($response->getBody(), true);
$this->addHeader('Authorization', 'Bearer ' . $responseBody['token']);
}
Firstly we create a POST
request to /login
sending in our username and password data as JSON:
{"username":"peter", "password":"testpass"}
Because we configured FOSUserBundle in such a way that we can use either the email address or username (if different), both are valid as the username. Be sure to check out this video if unsure how to do this for your project.
Perhaps the one strange part of sending JSON with Guzzle is that we need to pass in the data inside a json
'option':
[
'json' => [
'username' => $username,
'password' => $password,
]
]
If unsure, be sure to check the Guzzle docs.
Whatever the outcome of this POST
request is, will be stored on the $response
variable.
We can then use the standard \PHPUnit_Framework_Assert
asserttions to check that the response code was a 200
- OK. If this fails, the process will end here. If it doesn't end here, we can assume things went well.
Knowing this, we can decode the JSON response - passing in true
to json_decode
means we can make PHP convert the JSON into a PHP compatible array. This is immediately handy, as on the next line we grab the token
from that array and add it as a security Authorization
header, with the value of Bearer {long-string-of-fun-here}
.
Because this is a background step, this will occur for every single scenario in our feature.
From Background To Foreground
With our background step ensuring we are logged in as the expected user ('peter'), we can then assume that our test should work:
Scenario: User can GET their personal data by their unique ID
When I send a "GET" request to "/users/u1"
Then the response code should 200
And so on...
The nice thing about this test is that not only does it cover whether various parts of our system are behaving as expected (Controllers, Handlers, Voters, Queries, etc), it also brings confidence to our project.
We have seen that things break when they should. If we aren't logged in then absolutely we shouldn't have access. We know that log in works because we test it on every single scenario.
The crucial parts of our system are being continually and reliably tested.
Should we make a change at any point in the future, we can say with a high degree of certainty that the system is, or is no longer, behaving as we expect.
As I've said in previous video write ups, developing in this way takes longer. But the outcome is more robust, and if you have any desire to use this system in production, you will thank yourself repeatadly for devoting the time and effort to proving your system behaves properly, and in an automated fashion.