UX Improvements - Part 2 - Redirect Using an Event Listener


In this video we are expanding on the approach we used in the previous video - where we stopped our Users from accessing the /login page once they had already logged in.

In the previous video we achieved our goal by wrapping our own logic around an existing FOSUserBundle controller action. Whilst this did achieve our goal, the end result was that we had tightly tied our code to the FOSUserBundle implementation.

There is a better way of achieving the same goal, and this way we can achieve looser coupling - but coupling will still exist. Whereas before we needed to wrap around a FOSUserBundle controller, by the end of this video, we will only need to know of the route name : fos_user_security_login.

Events

Symfony is really all about receiving a Request, and using the information in a given Request to generate and return a Response.

By the time that the Request gets to the code that we write as developers, it will have already passed through a large part of the core files that make up the Symfony framework. At the various stages along that journey, Symfony's code generates and dispatches Events.

Events are actions that other parts of your system may find interesting.

Interested parties - other parts of Symfony core, third party bundles, and / or your own code - can listen for, or subscribe to one or more of these events.

There is quite a bit more to it than this, but as a high level overview, this is enough for us to get started. If you want to read more, and you should, then be sure to check out the official documentation.

We are going to create our own Event Listener, and tell Symfony that our Event Listener should listen for all kernel.request events.

This is the first event that Symfony's HttpKernel will dispatch.

This Event will contain lots of information that's relevant to many different listeners. The only piece of information we really need to concern ourselves with is the name of the requested route.

One important thing to understand is that our new Event Listener will be called for every single Request that our system receives from now on. As such, we want to fail fast. We don't want to delay our application any longer than necessary.

From a very high level we want to simply ask: does this Request involve the fos_user_security_login route?

If not, then carry on as normal.

If it does involve this route then we still have a few more checks to do. The key point is, we want to do as little as possible to determine if we can avoid doing any further work.

Implementing An Event Listener

Our Event Listener is fairly simple. It consists of a single class which can be called anything we like, but for the purposes of demonstation I am going to call ours UserLoginRouteListener.php:

<?php

// src/AppBundle/Event/UserLoginRouteListener.php

namespace AppBundle\Event;

use Symfony\Component\HttpKernel\Event\GetResponseEvent;

class UserLoginRouteListener
{
    public function __construct()
    {
    }

    public function onKernelRequest(GetResponseEvent $event)
    {
    }
}

That is the basic shell of our Event Listener.

How did I know that onKernelRequest should receive an GetResponseEvent ? By consulting this table.

I've added __construct() method as we will use Symfony's dependency injection to pass in the two other 'things' we require.

Those two other things are:

  1. The Router, so we can generate a route to redirect to if we are already logged in.
  2. The User, or more specifically, the Token representing the logged in User

Knowing this, we can define our Event Listener as a service:

# app/config/services.yml

services:
    your.user_login_route_listener:
        class: AppBundle\Event\UserLoginRouteListener
        arguments:
            - @security.token_storage
            - @router
        tags:
            - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }

There are other Events that you can listen for, and / or subscribe to. This is the list of Symfony's kernel events, and you can also create and dispatch your own Events, or listen for third party bundle events also.

The tag is the key part here which differentiates this from a normal service definition. Tagging our service is how we ensure we are listening for the right Event. You can listen for multiple events.

With the service defined, we need to go ahead and update the constructor in UserLoginRouteListener to pass in our dependencies:


use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

class UserLoginRouteListener
{
    /**
     * @var TokenStorageInterface
     */
    private $tokenStorageInterface;

    /**
     * @var RouterInterface
     */
    private $routerInterface;

    public function __construct(TokenStorageInterface $tokenStorageInterface, RouterInterface $routerInterface)
    {
        $this->tokenStorageInterface = $tokenStorageInterface;
        $this->routerInterface = $routerInterface;
    }

    public function onKernelRequest(GetResponseEvent $event)
    {
    }

If we were to add in a simple exit(); statement into our onKernelRequest method now, no matter which route we hit, we would see our exit(); being triggered.

We need a way to exclude every other route except the one we are interested in.

Fortunately, prior to the GetResponseEvent being dispatched by Symfony, the Request object was added to it for us to use:

    public function onKernelRequest(GetResponseEvent $event)
    {
        $request = $event->getRequest();

        if ($request->get('_route') !== 'fos_user_security_login') {
            return false;
        }

Immediately now we can check the _route property to see if we match the only route this Listener cares about - fos_user_security_login - and if not, we can return false;, which simply ignores this listener any further and carries on as normal.

Perfect, we now should only trigger our Listener for the event we care about. But we aren't done just yet.

Are We Logged In?

The next step is a little trickier.

Whilst you may think that if you are not logged in then any references to a security token would result in a null response.

That isn't true with Symfony.

Users who have not yet authenticated will still have a security token set. This token will be of type AnonymousToken. We can use this as a check to determine if the current User is not yet logged in:

use FOS\UserBundle\Model\User;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\Security\Core\Authentication\Token\AnonymousToken;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

class UserLoginRouteListener
{
    // * snip *

    public function onKernelRequest(GetResponseEvent $event)
    {
        $request = $event->getRequest();

        if ($request->get('_route') !== 'fos_user_security_login') {
            return false;
        }

        if ($this->tokenStorageInterface->getToken() === null) {
            return false;
        }

        if ($this->tokenStorageInterface->getToken() instanceof AnonymousToken) {
            return false;
        }

        if ( ! $this->tokenStorageInterface->getToken()->getUser() instanceof User) {
            return false;
        }
    }

I would strongly advise you write a test for this method.

As a gotcha, you may also need to go back to any Twig template checks where you have done something like this:

    {% if is_granted("IS_AUTHENTICATED_REMEMBERED") %}
        <a href="{{ path('fos_user_security_logout') }}">Log Out</a>
    {% else %}

and update as follows:

    {% if app.user is not null and is_granted("IS_AUTHENTICATED_REMEMBERED") %}
        <a href="{{ path('fos_user_security_logout') }}">Log Out</a>
    {% else %}

Otherwise 500 errors ahoy!

One other point to mention here is that we have tied ourselves to FOSUserBundle by checking if the result of getting our User is an instance of FOS\UserBundle\Model\User:

use FOS\UserBundle\Model\User;

// * snip *

    public function onKernelRequest(GetResponseEvent $event)
    {
        // * snip *

        if ( ! $this->tokenStorageInterface->getToken()->getUser() instanceof User) {
            return false;
        }

You may wish to validate against your own custom User Interface. I am fairly happy with doing this check however, as I know the chances of me switching from FOSUserBundle are very slim.

Redirect on Success

Our Listener so far has been all about failing as quickly as possible.

As mentioned, we only want to do the bare minimum before we can determine if our Listener should take any action whatsoever.

If all the checks have been made and as yet we have not hit a return false; statement then we can say with some certainty that now a logged in User is trying to access the /login route. How naughty.

After all this, all we really want to do is redirect our User back to the homepage if they are trying to access this route whilst logged in.

This bit is simple, and involves using the Router to generate a route to redirect to:

use Symfony\Component\HttpFoundation\RedirectResponse;

// * snip *

    public function onKernelRequest(GetResponseEvent $event)
    {
        // * snip *

        return $event->setResponse(
            new RedirectResponse(
                $this->routerInterface->generate('your_home_page_or_something')
            )
        );
    }   

Whilst you may be thinking ... wow, that's a lot of extra code for something pretty basic, then I can agree with you in some regards.

This does seem like a pretty convoluted solution to a pretty simple problem.

In my opinion it makes a nicer solution as we are not having to create a Bundle, or make our existing Bundle a child of FOSUserBundle. We get to learn a little about Events, and we can very easily write a unit test to cover this eventuality.

Being Symfony there are likely other ways of achieving this same goal. Feel free to use whichever method works for you.

Code in full:

<?php

namespace AppBundle\Event;

use FOS\UserBundle\Model\User;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\Security\Core\Authentication\Token\AnonymousToken;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

class UserLoginRouteListener
{
    /**
     * @var TokenStorageInterface
     */
    private $tokenStorageInterface;
    /**
     * @var RouterInterface
     */
    private $routerInterface;

    public function __construct(TokenStorageInterface $tokenStorageInterface, RouterInterface $routerInterface)
    {
        $this->tokenStorageInterface = $tokenStorageInterface;
        $this->routerInterface = $routerInterface;
    }

    public function onKernelRequest(GetResponseEvent $event)
    {
        $request = $event->getRequest();

        if ($request->get('_route') !== 'fos_user_security_login') {
            return false;
        }

        if ($this->tokenStorageInterface->getToken() === null) {
            return false;
        }

        if ($this->tokenStorageInterface->getToken() instanceof AnonymousToken) {
            return false;
        }

        if ( ! $this->tokenStorageInterface->getToken()->getUser() instanceof User) {
            return false;
        }

        return $event->setResponse(
            new RedirectResponse(
                $this->routerInterface->generate('home')
            )
        );
    }
}

Code For This Course

Get the code for this course.

Episodes