Adding Logout


In this video we are continuing on with our Login / Logout / Registration workflow, this time implementing the Logout path.

Now, somewhat strangely, we must define a logout route / logoutAction even though we will never directly call this route. Way back at the start of this tutorial series I mentioned that we wouldn't be using FOSUserBundle in this course. However, if you ever have used FOSUserBundle, you may have come across their logoutAction and been somewhat confused. I know I was:

    public function logoutAction()
    {
        throw new \RuntimeException('You must activate the logout in your security firewall configuration.');
    }

This is code taken directly from FOSUserBundle's SecurityController.

One thing to note here is that FOSUserBundle does not use annotations for routing, preferring XML instead. This is common inside third party bundles. I have no definitive source on this, but typically third party bundles prefer XML - I think it goes back to the days of Symfony 1.

We're going to implement something very similar:

<?php

// /src/AppBundle/Controller/SecurityController.php

namespace AppBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;

class SecurityController extends Controller
{
    // * snip

    /**
     * @Route("/logout")
     * @throws \RuntimeException
     */
    public function logoutAction()
    {
        throw new \RuntimeException('This should never be called directly.');
    }
}

In reality, no one - except you as a developer - should ever see this RuntimeException message. The only time you should see it is if you have incorrectly configured your system.

And this should now work, because we have already configured logout in security.yml:

# /app/config/security.yml

security:

    encoders:
        Symfony\Component\Security\Core\User\User:
            algorithm: bcrypt

    providers:
        in_memory:
            memory:
                users:
                    admin:
                        password: $2y$13$C3D/lnwWeh73axMnldcB.euo.Gkv4IThttEFp2.yaEWiIt585zbOa #here
                        roles: 'ROLE_ADMIN'

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            pattern: ^/
            provider: in_memory
            form_login:
                login_path: login
                check_path: login
            logout: true # <------ this line here
            anonymous: ~

Ok, on the surface of this, this was all very easy. But Symfony's Security Component is widely regarded as being complex and complicated, so surely there's more going on here than meets the eye?

Let's started with logout: true, because that seems to be the most obvious place, and also an initial potential point of confusion.

What does true mean in this instance?

Well, it means we want to use the default configuration for logout.

And so, what is the default configuration for logout?

Good question. I could just tell you, but instead, I will show you how to find out for yourself:

php bin/console config:dump-reference SecurityBundle

This is going to dump out a massive amount of stuff.

All of this stuff is interesting in its own right, but covered as one chunk it would be brutal. Instead, we will focus only on the part we are currently interested in:

php bin/console config:dump-reference SecurityBundle

# Default configuration for "SecurityBundle"

security:

    # snip

    firewalls:            # Required

        # Prototype
        name:

            # snip

            logout:
                csrf_parameter:       _csrf_token
                csrf_token_generator:  ~
                csrf_token_id:        logout
                path:                 /logout
                target:               /
                success_handler:      ~
                invalidate_session:   true
                delete_cookies:

                    # Prototype
                    name:
                        path:                 null
                        domain:               null
                handlers:             []

There's still a lot of things to cover here.

Notice how this corresponds to the setup we use in security.yml?

We start with a top level key of security, then inside this key we have firewalls (along with encoders and providers in our real security.yml file), and then name maps to main in our example.

You can use this command to determine all the available options when configuring your security setup. Or change the command slightly and get configuration for twig, or doctrine, or swiftmailer, or any other bundle you have available. To find out more simply use the command without an argument:

php bin/console config:dump-reference

 // Provide the name of a bundle as the first argument of this command to dump its default configuration.

Available registered bundles with their extension alias if available:
+------------------------------+--------------------------+
| Bundle name                  | Extension alias          |
+------------------------------+--------------------------+
| AppBundle                    |                          |
| DebugBundle                  | debug                    |
| DoctrineBundle               | doctrine                 |
| FrameworkBundle              | framework                |
| MonologBundle                | monolog                  |
| SecurityBundle               | security                 |
| SensioDistributionBundle     | sensio_distribution      |
| SensioFrameworkExtraBundle   | sensio_framework_extra   |
| SensioGeneratorBundle        |                          |
| SwiftmailerBundle            | swiftmailer              |
| TwigBundle                   | twig                     |
| WebProfilerBundle            | web_profiler             |
+------------------------------+--------------------------+

For a little more on this, check out this blog post.

Back to the logout config, the CSRF protection parameters are nice - and something we will come back to in a moment.

The default path of /logout is sensible enough, and would is the URI we need to hit in order to logout. You can change this of course, just like you can change any of these settings.

When logging out, we are redirected to a target of /. In other words, when we log out, by default we get sent back to the site root. Again, a sensible default.

The success_handler allows you to register your own service or array of services to handle a successful logout. This is not something I have ever needed to use myself, but as an example, you might wish to create a service that redirects some users (identified by them having a specific security role) to a specific page on logout, whereas 'generic' users might get sent to the /. From my experience, adding a custom success handler is more useful on login, but it's nice to have the option.

invalidate_session allows you to customise how a user is logged out from your application. By default, this value is set to true. This means that when logging out of a firewall, they are simultaneously logged out from every firewall in your application. This may or may not be what you want - maybe when logged in via the admin panel you don't want to also log out of the JSON API... Again, it's nice to have the option.

delete_cookies allows you to explicitly remove certain cookies by name. I've never needed this myself, but from the docs:

logout:
    path:   /logout
    target: /
    invalidate_session: false
    delete_cookies:
        a: { path: null, domain: null }
        b: { path: null, domain: null }
    handlers: [some.service.id, another.service.id]
    success_handler: some.service.id

Finally, handlers allows us to define a service, or an array of services that listen for logout requests and then do something that we as developers define.

By default, the Symfony Security Component is configured to listen for requests to the defined path - which is /logout by default - and intercept them for us. This will perform the logout process for us, and ensure we are redirected to the expected target path. In other words, this logout listener uses this config to figure out what we want to happen on logout.

To actually trigger all of this, all we need to do is visit the /logout route.

Now, as mentioned at the very start of this write up, this is a little strange as we've just seen that we will never actually hit the defined logoutAction. Symfony will intercept this request and handle it for us. But without a defined route, we cannot actually visit /logout, which is why we must manually define it.

We likely don't want to have to manually type in /logout in our browser, so let's add in a link to allow logged in users to log out:

<!-- /app/Resources/views/base.html.twig -->

<!DOCTYPE html>
<html>
    <head>
        // <!-- snip -->
    </head>
    <body>
        <div class="container">

            {% include('flash-messages.html.twig') %}

            {% if app.user %}
            <a href="{{ logout_path('main') }}">Logout</a>
            {% endif %}

There's two interesting things going on here.

Firstly, we are wrapping the logout link in a conditional - the if statement. Here we say if the app.user is defined, then show the logout link. There's no point showing the logout link if we aren't logged in :)

app.user is a Global Variable defined for us by Symfony so we don't need to do anything to start using it. There are a few more - so check out the documentation.

If we are logged in then we want to show the logout link.

However, you may be wondering why we use the Twig function logout_path, and not simply just path? Seems like they both do the same thing, right?

Well, they do, until you want to use a CSRF token to protect your logout link.

Remember back in our config:

php bin/console config:dump-reference SecurityBundle

# Default configuration for "SecurityBundle"

security:

    # snip

    firewalls:            # Required

        # Prototype
        name:

            # snip

            logout:
                csrf_parameter:       _csrf_token
                csrf_token_generator:  ~
                csrf_token_id:        logout
                # snip

Notice how csrf_token_generator is nulled (~) out?

If we don't set a csrf_token_generator then sure enough, the outcome of logout_path (or logout_url) are the same as if we use path (or url). For reference all of these Twig functions are explained in the documentation. The gist is, path will create a relative path, whereas url will create an absolute URL.

So we use path:

<!-- /app/Resources/views/base.html.twig -->

<!DOCTYPE html>
<html>
    <head>
        // <!-- snip -->
    </head>
    <body>
        <div class="container">

            {% include('flash-messages.html.twig') %}

            {% if app.user %}
            <a href="{{ path('logout') }}">Logout</a>
            {% endif %}

And we mouse over the rendered logout link and we see:

http://127.0.0.1:8000/logout

And then we go back in, and we swap this out for <a href="{{ logout_path('main') }}">Logout</a>, and we refresh and we see...

http://127.0.0.1:8000/logout

Huh.

Ok, but if we set up the csrf_token_generator then this changes the output:

# /app/config/security.yml

security:

    # snip

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            pattern: ^/
            provider: in_memory
            form_login:
                login_path: login
                check_path: login
            logout:
                csrf_token_generator: security.csrf.token_manager
            anonymous: ~

And now if we repeated the original process, first using path:

http://127.0.0.1:8000/logout

And then we go back in, and we swap this out for <a href="{{ logout_path('main') }}">Logout</a>, and we refresh and we see something similar to:

http://127.0.0.1:8000/logout?_csrf_token=jQcnlvVybyMHFvFRygEzUIzzwxa-JEmxCfzZrye0y4I

In reality you would likely want to configure CSRF protection on your login form also.

Lastly, just to clarify that by being explicit about the logout.csrf_token_generator doesn't mean we lost all the configuration. All the defaults still apply, we just changed the csrf_token_generator to be more specific in this instance.

Code For This Course

Get the code for this course.

Episodes