Login - Part 1 - Happy Path


In this video we are going to add in the foundations of Login functionality to our Symfony 3 RESTful API.

We are going to make use of LexikJWTAuthenticationBundle to handle authentication for our API, and as a result will be using JSON Web Tokens (JWT - pronounced Jot) for any endpoint that requires a user to be authenticated.

If you haven't ever seen JWTs in use before, the theory is that when we send in some good credentials to the /login endpoint, we should get back a string that represents a "set of claims", which when decoded becomes a standard JSON object.

Lexik JWT Authentication bundle uses NAMSHI JOSE under the hood, and this, for those that care about such things, means we are using JSON Web Signature (JWS).

For the more visually inclined, this means that when we send in a POST request to /login with a payload similar to:

{ "username": "peter@test.com", "password": "123" }

Then, assuming peter is a valid user, and the password is correct, we get back a response similar to:

{
  "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXUyJ9.eyJ1c2VySWQiOjEsInVzZXJuYW1lIjoicGV0ZXIiLCJpYXQiOiIxNDc1NjcyNTcxIn0.RFbZAi01iEkNMzlF9nT502x5vNgQDTKAdEe9hHDq4E0v6N0jP94ElunSVudRZ1-yNTYyeZ94wGCaxcmLKs7AeQRTPIwHucBEsWctGZJr2g9AHYsq0cP-nnFJeiGFU00fhWceLr7FGWh4CXyxR9H1im_rDcn-UlYqMSUX5NqpY8jmf2LMwHBKTVT-UEhVraXKmD8-YdvciSwZYM6niVOsI8tOEzcZ5zr8p2B9OrXzXoS7nyYVrmo2usDSWxuLIBWJlzk520vVBFzkSn0xsSNIOMF7HGpvkibdK-CYSuA3Ksihdb_QdBgo30a3ThCH1AZJYTnWqyZks3iA_uerEW0R3wLFCKXSViehr4d-EKKWn4B2BsP4tFgHb3oIjVhi-ffmtpRGKBRTywUihXI-3eaNFK8qqrDDx_9cXd55IoAwNXvr8XVybi7IozOo_-lOCn1c8IoeHxH0PpDdt22Nz7LRlbNCZANq7zHI5oGBweovrVI2nrb9BvB_FAF1m1kpzgV2moZLpCKI5DFULn8NItVgH7gUlqO3x1KmYXThxRypiNHU-FU3rYz6XPgZAS_gphE3Kiipe8blGTen7CGKRAFfSDEfJjzYw4a1abeY2ECbC7Ce6PtE9PCetmDxsVNevRx_9GKDUQnS-l-Xqko2Nhgj1k2zdp-QPJSrCX12YMAByDc"
}

Which when decoded should contain something similar to:

{
  "userId": 1,
  "username": "peter",
  "iat": "1475672571"
}

A fresh install will not contain the userId, that is something we will add in a little later in this course. I include the user ID because most API calls will require it, and otherwise, how would you know it?

Behat Feature

We're going to cover our system with tests using Behat. Of course you are free to use your preferred test suite. Codeception is also popular for testing APIs.

# /src/AppBundle/Features/login.feature

Feature: Handle user login via the RESTful API

  In order to allow secure access to the system
  As a client software developer
  I need to be able to let users log in and out

  Background:
    Given there are Users with the following details:
      | id | username | email          | password |
      | 1  | peter    | peter@test.com | testpass |
      | 2  | john     | john@test.org  | johnpass |
     And I set header "Content-Type" with value "application/json"

  Scenario: User can Login with good credentials
    When I send a "POST" request to "/login" with body:
      """
      {
        "username": "peter",
        "password": "testpass"
      }
      """
    Then the response code should be 200
     And the response should contain "token"

Again, we covered where these test steps come from in the previous video so I'm not going to go into how each step works. You are free to examine the context file itself to see the function implementation of any test step.

The really nice part of this setup is that by simply running the test suite:

php vendor/bin/behat

We get a re-initialised database with our two users: peter, and john, we can see their passwords and email addresses, and we can also copy / paste the JSON string from the test step into a client like Postman to manually play with the test setup.

All in all, this makes working with the system much more understandable, as the tests act as easily readable documentation on how the system is expected to work.

Of course, running this test is not going to pass as we haven't actually done the implementation.

Implementing the Login Route

An initial point of confusion may be why there are none of the standard routes available that you might expect in a typical FOSUserBundle project.

Whilst we have added FOSUserBundle to this project as a dependency, and we added the initial configuration to make it work, we crucially didn't import the FOSUserBundle routing files.

The reasoning for this is that we don't want their routes. We need their logic, but we want to work with a JSON API, whereas theirs is HTML.

This will have an impact a little later on when we come to sending out password reset emails. This is because the default FOS User Bundle mailer has an expectation that certain routes will be available, so we will have to work around this problem also.

We're going to need to add in a /login route, but it will never be directly used.

If we don't define the route then any incoming requests to /login will respond with a 404.

Let's define our controller:

<?php

// /src/AppBundle/Controller/RestLoginController.php

namespace AppBundle\Controller;

use FOS\RestBundle\Controller\Annotations;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Routing\ClassResourceInterface;
use FOS\RestBundle\Controller\Annotations\RouteResource;

/**
 * @RouteResource("login", pluralize=false)
 */
class RestLoginController extends FOSRestController implements ClassResourceInterface
{
    public function postAction()
    {
        // route handled by Lexik JWT Authentication Bundle
        throw new \DomainException('You should never see this');
    }
}

To hook this up, we need to add in the resource (think: controller) to our routing.yml file.

I do this a little differently. In my routing.yml file I reference a routing_api.yml file. This keeps a separation should there be an API and HTML implementation co-existing. Even when there isn't, I maintain this convention - force of habit.

## /app/config/routing.yml

routing_rest:
    resource: routing_rest.yml

and:

## /app/config/routing_rest.yml

login:
    type: rest
    resource: AppBundle\Controller\RestLoginController

Next we need a separate Symfony firewall to handle our login route.

If we tried to login without this firewall in place then our current configuration would default us to the api firewall, which expects us to send in a valid Authorization header. We can't get that header, because... we can't log in. Catch 22.

Ok, so we define a new firewall:

# /app/config/security.yml

security:
    encoders:
        FOS\UserBundle\Model\UserInterface: bcrypt

    role_hierarchy:
        ROLE_ADMIN:       ROLE_USER
        ROLE_SUPER_ADMIN: ROLE_ADMIN

    providers:
        fos_userbundle:
            id: fos_user.user_provider.username_email

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false

        api_login:
            pattern:  ^/login
            stateless: true
            anonymous: true
            form_login:
                check_path:               /login
                require_previous_session: false
                username_parameter:       username
                password_parameter:       password
                success_handler:          lexik_jwt_authentication.handler.authentication_success
                failure_handler:          lexik_jwt_authentication.handler.authentication_failure

        api:
            pattern:   ^/
            stateless: true
            lexik_jwt: ~

    access_control:
        - { path: ^/login$,           role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/,                 role: IS_AUTHENTICATED_FULLY }

By adding the entry for ^/login$ for anonymous users (in other words, any site visitor) to our access_control section, we effectively white list the URL and allow all traffic through.

A sidenote here - how do we logout?

My approach here is to simply forget the token. That is, once you have logged in you get a token. On the client side, when you log out, you remove the token from localStorage, the cookie you were using, or whatever. Tokens will expire after 86400 seconds (24 hours to you and me), and you can be cleverer about this if you want / need to be, but honestly, just delete them on the client side unless you have a specific security policy to enforce.

With all this in place we should now be getting a passing Behat test.

We haven't yet covered anything other than the "happy path", so in the next video we will continue working on our Login flow, this time aiming to catch a bunch of bad outcomes with the aim to handle them in a consistent manner.

Code For This Course

Get the code for this course.

Episodes