Registration - Part 2 - Happy Path
In the previous video we made a start in adding the "happy path" to our Registration / user sign up journey.
Towards the end of this video we hit upon a 401
error, meaning our security setup was preventing anonymous / not-logged-in users from accessing the /register
endpoint.
In this video we will:
- Fix the security problem with
/register
- Learn how to create a JWT for a given User object
- Amend a Behat step definition to make use of the returned JWT
To begin with, let's quickly recap the test we are aiming to pass:
# /src/AppBundle/Features/register.feature
Feature: Handle user registration via the RESTful API
In order to allow a user to sign up
As a client software developer
I need to be able to handle registration
Background:
Given there are Users with the following details:
| id | username | email | password |
| 1 | peter | peter@test.com | testpass |
And I set header "Content-Type" with value "application/json"
Scenario: Can register with valid data
When I send a "POST" request to "/register" with body:
"""
{
"email": "chris@codereviewvideos.com",
"username": "chris",
"plainPassword": {
"first": "abc123",
"second": "abc123"
}
}
"""
Then the response code should be 201
And the response should contain "The user has been created successfully"
When I am successfully logged in with username: "chris", and password: "abc123"
And I send a "GET" request to "/profile/2"
And the response should contain json:
"""
{
"id": "2",
"username": "chris",
"email": "chris@codereviewvideos.com"
}
"""
As mentioned, currently when hitting the /register
route we get a 401
instead of the expected 201
.
To fix this, we must update security.yml
to allow anonymous access to this particular route:
# /app/config/security.yml
security:
firewalls:
api_register:
pattern: ^/register
anonymous: true
access_control:
- { path: ^/register$, role: IS_AUTHENTICATED_ANONYMOUSLY }
I've only shown the parts relevant to this particular fix.
With these rules in place, re-running our test suite should make it a little further, but still not yet be a passing test.
If you've been following along with the code as is, we will now need to address a bug introduced by simply copy / pasting from FOSUserBundle's RegistrationController
:
// src/AppBundle/Controller/RestRegistrationController.php
// ~line 79
if (null === $response = $event->getResponse()) {
return new JsonResponse('Registration successful', Response::HTTP_CREATED);
}
The 'bug' will manifest as a 204
status code where we expect a 201
.
In the code above, the 204
status code is represented by the constant Response::HTTP_CREATED
.
The issue here is that shortly before this if
statement, an event is dispatched:
$dispatcher->dispatch(FOSUserEvents::REGISTRATION_SUCCESS, $event);
And this if
statement is checking that if no event listeners, or event subscribers create a different response, then return this new JsonResponse(...)
.
The problem here is that nothing will create a new response, as we have no registered listeners / subscribers. As such, the if
statement is always hit. Oops.
Changing this is simple enough, we only want to return the outcome of getResponse
if their is a response - the inverse of what we were just doing:
if ($event->getResponse()) {
return $event->getResponse();
}
This change should now result in a 201
status code, rather than the previous 204
. Progress.
At this stage our current Behat test should be almost there. However, it cheats somewhat.
We rely on logging back in to check the profile
contents. This isn't a total cheat. After all our credentials are being tested. But we have tested the login credentials many times already, and this time I want to test that the returned JWT token is usable immediately after registration.
Let's change up the test to validate this journey.
Following The Location Header
Firstly, we must update the Behat scenario to cover this altered journey:
# /src/AppBundle/Features/register.feature
Feature: Handle user registration via the RESTful API
In order to allow a user to sign up
As a client software developer
I need to be able to handle registration
Background:
Given there are Users with the following details:
| id | username | email | password |
| 1 | peter | peter@test.com | testpass |
And I set header "Content-Type" with value "application/json"
Scenario: Can register with valid data
When I send a "POST" request to "/register" with body:
"""
{
"email": "chris@codereviewvideos.com",
"username": "chris",
"plainPassword": {
"first": "abc123",
"second": "abc123"
}
}
"""
Then the response code should be 201
And the response should contain "The user has been created successfully"
And I follow the link in the Location response header
And the response should contain json:
"""
{
"id": "2",
"username": "chris",
"email": "chris@codereviewvideos.com"
}
"""
The change, if not immediately obvious is to switch from:
When I am successfully logged in with username: "chris", and password: "abc123"
And I send a "GET" request to "/profile/2"
to:
And I follow the link in the Location response header
This has the additional benefit of working properly if you use a UUID
for your entities, instead of the more common id
field, because you don't need to worry about what the value will be at the time of writing your test. Instead, your Behat test can handle this issue at runtime.
The harder part here is that to follow the link provided to us in the Location
Response header, we must also provide valid credentials - in the form of an Authorization
header on our next request. This is because the link provided in the Location
header will be to /profile/{something-here}
, and the /profile
endpoint requires a user be authenticated fully.
Ok, so let's cover - from a high level - what should happen here:
- User registers with good credentials
- Symfony API returns a Response containing a Location header > pointing to their profile URI
- The response also has a response body containing a JWT
- The JWT needs to be set as the
Authorization
header on their immediate next request - The request needs to go to the provided profile URI
Steps 2-5 all need to happen as part of:
And I follow the link in the Location response header
There is the other issue in that we are currently faking the creation of the JWT in our RegistrationController:registerAction
:
$response = new JsonResponse(
[
'msg' => $this->get('translator')->trans('registration.flash.user_created', [], 'FOSUserBundle'),
'token' => 'abc-123' // <<<---- this bit
],
JsonResponse::HTTP_CREATED,
[
'Location' => $this->generateUrl(
'get_profile',
[ 'user' => $user->getId() ],
UrlGeneratorInterface::ABSOLUTE_URL
)
]
);
But, one thing at a time. Let's teach our Behat test how to follow the Location
header.
Changing RestApiContext
To make this change we must update the RestApiContext
file, which so far throughout this course we have largely been able to ignore.
Currently the function looks as follows:
// /src/AppBundle/Features/Context/RestApiContext.php
/**
* @Then I follow the link in the Location response header
*/
public function iFollowTheLinkInTheLocationResponseHeader()
{
$location = $this->response->getHeader('Location')[0];
$this->iSendARequest(Request::METHOD_GET, $location);
}
This is partly working. It will grab the Location
header from the Response, and then try to GET
that given URL.
The problem we have is that the Request it makes will not contain the necessary Authorization
header.
We need to add this header in, ideally only if there is not an Authorization
header already set.
// /src/AppBundle/Features/Context/RestApiContext.php
/**
* @Then I follow the link in the Location response header
*/
public function iFollowTheLinkInTheLocationResponseHeader()
{
$location = $this->response->getHeader('Location')[0];
if ( ! $this->hasHeader('Authorization')) {
$responseBody = json_decode($this->response->getBody(), true);
$this->addHeader('Authorization', 'Bearer ' . $responseBody['token']);
}
$this->iSendARequest(Request::METHOD_GET, $location);
}
// a helper function to make the main function more readable
protected function hasHeader($name)
{
return isset($this->headers[$name]);
}
As it stands the code was already in place for pulling out the Location
header, and using it as the URI for the next request.
The part we needed to add was to extract the JWT / token
from the registration response body, and add this as a header.
This involved decoding the returned response from JSON into a PHP array, then adding the Authorization
header with the contents of Bearer {token}
, where token is taken from that PHP array.
At this stage, our Behat test should be behaving - but the token is still nonsense: abc-123
in our case.
Generating a Proper JWT
The last step is to generate a proper JWT for the given User object.
This is much easier than you may think. As we are using LexikJWTAuthenticationBundle, we can make use of the JWTManager
's create
method. All we need to do is pass in an instance of UserInterface
- which our own User
entity conforms too - and a JWT string will be returned for us to use.
Therefore, our controller code needs updating as follows:
$response = new JsonResponse(
[
'msg' => $this->get('translator')->trans('registration.flash.user_created', [], 'FOSUserBundle'),
'token' => $this->get('lexik_jwt_authentication.jwt_manager')->create($user), // creates JWT
],
JsonResponse::HTTP_CREATED,
[
'Location' => $this->generateUrl(
'get_profile',
[ 'user' => $user->getId() ],
UrlGeneratorInterface::ABSOLUTE_URL
)
]
);
And with that, our test should be passing :)