Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Login Throttling & Events

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

Symfony's security system comes packed with a lot of cool stuff, like remember me, impersonation and voters. Heck, it even has built in support for a "login link" authenticator - also known as "magic login links". That's where you email a link to your user and they click that to log in.

One other really cool feature is login throttling: a way to prevent someone from a single IP address from testing passwords over and over again on your site... by trying to log in over and over and over again. And it's super easy to use.

Activating login_throttling

Under your firewall, enable it with login_throttling: true:

security:
... lines 2 - 20
firewalls:
... lines 22 - 24
main:
... lines 26 - 28
login_throttling: true
... lines 30 - 62

If you stopped right there... and refreshed any page, you're going to get an error:

Login throttling requires the Rate Limiter component.

And then a helpful command to install it! Nice! Copy that, spin over to your terminal and run:

composer require symfony/rate-limiter

This package also installs a package called symfony/lock, which has a recipe. Run:

git status

to see what it did. Interesting. It created a new config/packages/lock.yaml, and also modified our .env file.

To keep track of the login attempts, the throttling system needs to store that data somewhere. It uses the symfony/lock component to do that. Inside of our .env file, at the bottom, there's a new LOCK_DSN environment variable which is set to semaphore:

34 lines .env
... lines 1 - 28
###> symfony/lock ###
# Choose one of the stores below
# postgresql+advisory://db_user:db_password@localhost/db_name
LOCK_DSN=semaphore
###

A semaphore... is basically a super easy way to store this data if you only have a single server. If you need something more advanced, check out the symfony/lock documentation: it shows all the different storage options with their pros and cons. But this will work great for us.

So, step 1 was to add the login_throttling config. Step 2 was to install the Rate Limiter component. And step 3 is... to enjoy the feature! Yea, we're done!

Refresh. No more error. By default, this will only allow 5 consecutive log in attempts for the same email and IP address per minute. Let's try it. One, two, three, four, five and... the sixth one is rejected! It locks us out for 1 minute. Both the max attempts and interval can be configured. Actually, we can see that.

At your terminal, run:

symfony console debug:config security

And... look for login_throttling. There it is. Yup, this max_attempts defaults to 5 and interval to 1 minute. Oh, and by the way, this will also block the same IP address from making 5 times the max_attempts for any email. In other words, if the same IP address quickly tried 25 different emails, it would still block them. And if you want an awesome first line of defense, I would also highly recommend using something like Cloudflare, which can block bad users even before they hit your server... or enable defenses if your site is attacked from many IP addresses.

Digging into How Login Throttling Works

So... I think this feature is pretty cool. But the most interesting thing for us about it is how it works behind-the-scenes. It works via Symfony's listener system. After we log in, whether successfully or unsuccessfully, a number of events are dispatched throughout that process. We can hook into those events to do all sorts of cool things.

For example, the class that holds the login throttling logic is called LoginThrottlingListener. Let's... open it up! Hit Shift+Shift and open LoginThrottlingListener.php.

Awesome. The details inside of this aren't too important. You can see it's using something called a rate limiter... which does the checking of if the limit has been hit. Ultimately, if the limit has been hit, it throws this exception, which causes the message that we saw. For those of you watching closely, that exception extends AuthenticationException... and remember, you can throw an AuthenticationException at any point in the authentication process to make it fail.

Anyways, this method is listening to an event called CheckPassportEvent. This is dispatched after the authenticate() method is called from any authenticator. At this point, authentication isn't successful yet... and the job of most listeners to CheckPassportEvent is to do some extra checking and fail authentication if something went wrong.

This class also listens to another event called LoginSuccessEvent... which... well, it's kind of obvious: this is dispatched after any successful authentication. This resets the rate limiter on success.

So this is really cool, and it's our first vision into how the event system works. Next, let's go deeper by discovering that almost every part of authentication is done by a listener. Then, we'll create our own.

Leave a comment!

9
Login or Register to join the conversation
Oliver-W Avatar
Oliver-W Avatar Oliver-W | posted 1 year ago

Hi Ryan,
I am sorry, but I can NOT confirm this: "Oh, and by the way, this will also block the same IP address from making 5 times the max_attempts for any email."
My attempts with different email addresses and different passwords where not limited!? Different passwords for the same email, yes. But not if both are different.
I also tested five attempts for the same email with different passwords and then another email. And after six attempts and beeing blocked I can immediately restart with a new email.
Any idea why?
Thx for your good work - your means you AND the team ;-)
Oliver

1 Reply

Hey Oliver W.!

Hmm. So the way it should work is this (assuming max_attempts = 5, which is the default):

A) Only allow 5 attempts per email (and we saw this in the screencast).
B) Only allow max_attempts * 5 (so 5x5=25) attempts (regardless of email) from the same IP address.

I admit that I've never tried pat (B) - I know it from reading the code iirc. But what it means that after you attempt 25 login attempts, it should not allow your IP address to make more attempts (until you reach the cool-down period). Have you tried 26 straight logins quickly (obviously you'll need to change email addresses every 5 attempts to avoid hitting the smaller, email-specific limit).

Let me know if you try that. If I'm wrong, I would definitely like to know!

Cheers!

Reply
Oliver-W Avatar

Sorry Ryan, but I've made so many changes in the meantime that I don't think my tests would be of any value 8-(.
If I find any occasion to do it, I will.

Reply

So... if I get blocked for too many attempts, and then login successfully (say I created a fake user just for this), I'm not blocked anymore from attempting logins with a different e-mail than my fake user's? I gotta try this... (Rubs hands together) 😏

Reply

Hey @D-Marti

That's a great question, honestly, I don't know the inner details of how the login throttling system works, but I guess it blocks you by IP, not only by email. If you give this a try please let me know your findings :)

Cheers!

Reply

Indeed. Once you get blocked, you can only login if you provide valid credentials. Once you do that, and logout, even if you clear all your cookies, and try loging in with a different user and a bad password, your IP will still be blocked.

TL;DR The block timer does not get reset with a successful login. Blocking is IP based.

Which is great! 😲

Reply

Great research! Thank you for sharing it ✌

Reply
Braunstetter Avatar
Braunstetter Avatar Braunstetter | posted 1 year ago

Hey Ryan. It would be nice to see how you can use something like cloudfare or fastly to block bad users or use it as a reverse proxy, caching, esi and so on. Can you do such a tutorial some time pleeeeasse?

Reply

Hey Michael,

Thank you for your interest in SymfonyCasts tutorials and sharing this idea with us! Unfortunately, no certain plans to do this in the near future, but we will consider releasing a course about it later... or probably cover it at least partially in future courses maybe. We'll add this to our idea pool for now, but I can't say when it might be released yet.

About ESI, we kinda covered it before in previous tutorials, I'd recommend you to take a look at it: https://symfonycasts.com/se... - the code is based on an older version of Symfony, but the concepts it covers are still valid for today. And also, here we were talking about reverse proxy: https://symfonycasts.com/sc... and caching: https://symfonycasts.com/sc... - probably it will be useful for you too.

Feel free to play with our search to find more related topics, it's really powerful and searches in video, code blocks, comments, etc :)

Cheers!

1 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": ">=8.1",
        "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
    }
}
userVoice