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.