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 :)

Code For This Course

Get the code for this course.

Episodes