Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Security Listener System & Csrf Protection

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

After we return the Passport object, we know that two things happen. First, the UserBadge is used to get the User object:

... lines 1 - 21
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 24 - 37
public function authenticate(Request $request): PassportInterface
{
... lines 40 - 42
return new Passport(
new UserBadge($email, function($userIdentifier) {
// optionally pass a callback to load the User manually
$user = $this->userRepository->findOneBy(['email' => $userIdentifier]);
if (!$user) {
throw new UserNotFoundException();
}
return $user;
}),
... line 54
);
}
... lines 57 - 83
}

In our case, because we passed this a second argument, it just calls our function, and we do the work. But if you only pass one argument, then the user provider does the work.

The second thing that happens is that the "credentials badge" is "resolved":

... lines 1 - 21
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 24 - 37
public function authenticate(Request $request): PassportInterface
{
... lines 40 - 42
return new Passport(
... lines 44 - 53
new PasswordCredentials($password)
);
}
... lines 57 - 83
}

Originally it did this by executing our callback. Now it checks the user's password in the database.

The Event System in Action

All of this is powered by a really cool event system. After our authenticate() method, the security system dispatches several events... and there are a set of listeners to these events that do different work. We're going to see a full list of these listeners later... and even add our own listeners to the system.

UserProviderListener

But let's look at a few of them. Hit Shift+Shift so we can load some core files from Symfony. The first is called UserProviderListener. Make sure to "Include non-project items"... and open it up.

This is called after we return our Passport. It first checks to make sure the Passport has a UserBadge - it always will in any normal situation - and then grabs that object. It then checks to see if the badge has a "user loader": that's the function that we're passing to the second argument of our UserBadge. If the badge already has a user loader, like in our case, it does nothing. But if it does not, it sets the user loader to the loadUserByIdentifier() method on our user provider.

It's... a little technical... but this is what causes our user provider in security.yaml to be responsible for loading the user if we only pass one argument to UserBadge.

CheckCredentialsListener

Let's check one other class. Close this one and hit Shift+Shift to open CheckCredentialsListener. As the name suggests, this is responsible for checking the user's "credentials". It first checks to see if the Passport has a PasswordCredentials badge. Even though its name doesn't sound like it, the "credentials" objects are just badges... like any other badge. So this checks to see if the Passport has that badge and if it does, it grabs the badge, reads the plain-text password off of it, and, eventually way down here, uses the password hasher to verify that the password is correct. So this contains all of that password hashing logic. Below, this listener also handles the CustomCredentials badge.

Badges Must be Resolved

So your Passport always has at least these two badges: the UserBadge and also some sort of "credentials badge". One important property of badges is that each one must be "resolved". You can see this in CheckCredentialsListener. After it finishes checking the password, it calls $badge->markResolved(). If, for some reason, this CheckCredentialsListener was never called due to some misconfiguration... the badge would remain "unresolved" and that would actually cause authentication to fail. Yup, after calling the listeners, Symfony checks to make sure that all badges have been resolved. This means that you can confidently return PasswordCredentials and not have to wonder if something did actually verify that password.

Adding CSRF Protection

And here's where things start to get more interesting. In addition to these two badges, we can also add more badges to our Passport to activate more super powers. For example, one good thing to have on a login form is CSRF protection. Basically you add a hidden field to your form that contains a CSRF token... then, on submit, you validate that token.

Let's do this. Anywhere inside your form, add an input type="hidden", name="_csrf_token" - this name could be anything, but this is a standard name - then value="{{ csrf_token() }}". Pass this the string authenticate:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="login-form bg-light mt-4 p-4">
<form method="post" class="row g-3">
... lines 10 - 24
<input type="hidden" name="_csrf_token"
value="{{ csrf_token('authenticate') }}"
>
... lines 28 - 33
</form>
</div>
</div>
</div>
{% endblock %}

That authenticate could also be anything... it's like a unique name for this form.

Now that we have the field, copy its name and head over to LoginFormAuthenticator. Here, we need to read that field from the POST data and then ask Symfony:

Is this CSRF token valid?

Well, in reality, that second part will happen automatically.

How? The Passport object has a third argument: an array of any other badges that we want to add. Add one: a new CsrfTokenBadge():

... lines 1 - 15
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
... lines 17 - 22
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 25 - 38
public function authenticate(Request $request): PassportInterface
{
... lines 41 - 43
return new Passport(
... lines 45 - 55
[
new CsrfTokenBadge(
... lines 58 - 59
)
]
);
}
... lines 64 - 90
}

This needs two things. The first is the CSRF token ID. Say authenticate:

... lines 1 - 22
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 25 - 38
public function authenticate(Request $request): PassportInterface
{
... lines 41 - 43
return new Passport(
... lines 45 - 55
[
new CsrfTokenBadge(
'authenticate',
... line 59
)
]
);
}
... lines 64 - 90
}

this just needs to match whatever we used in the form. The second argument is the submitted value, which is $request->request->get() and the name of our field: _csrf_token:

... lines 1 - 22
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 25 - 38
public function authenticate(Request $request): PassportInterface
{
... lines 41 - 43
return new Passport(
... lines 45 - 55
[
new CsrfTokenBadge(
'authenticate',
$request->request->get('_csrf_token')
)
]
);
}
... lines 64 - 90
}

And... we're done! Internally, a listener will notice this badge, validate the CSRF token and resolve the badge.

Let's try it! Go to /login, inspect the form... and find the hidden field. There it is. Enter any email, any password... but mess with the CSRF token value. Hit "Sign in" and... yes! Invalid CSRF token! Now if we don't mess with the token... and use any email and password... beautiful! The CSRF token was valid... so it continued to the email error.

Next: let's leverage Symfony's "remember me" system to allow users to stay logged in for a long time. This feature also leverages the listener system and a badge.

Leave a comment!

1
Login or Register to join the conversation

How CSRF protection work in Symfony? How app know what CSRF token was send to form and after that validate it ?

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial also works great for Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.1 || ^8.0.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^2.1", // 2.6.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.10.1
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "scheb/2fa-bundle": "^5.12", // v5.12.1
        "scheb/2fa-qr-code": "^5.12", // v5.12.1
        "scheb/2fa-totp": "^5.12", // v5.12.1
        "sensio/framework-extra-bundle": "^6.0", // v6.2.0
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.8
        "symfony/flex": "^1.3.1", // v1.17.5
        "symfony/form": "5.3.*", // v5.3.8
        "symfony/framework-bundle": "5.3.*", // v5.3.8
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/property-access": "5.3.*", // v5.3.8
        "symfony/property-info": "5.3.*", // v5.3.8
        "symfony/rate-limiter": "5.3.*", // v5.3.4
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/security-bundle": "5.3.*", // v5.3.8
        "symfony/serializer": "5.3.*", // v5.3.8
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/ux-chartjs": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.8
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "symfonycasts/verify-email-bundle": "^1.5", // v1.5.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.3
        "twig/string-extra": "^3.3", // v3.3.3
        "twig/twig": "^2.12|^3.0" // v3.3.3
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.34.0
        "symfony/var-dumper": "5.3.*", // v5.3.8
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.8
        "zenstruck/foundry": "^1.1" // v1.13.3
    }
}