Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Always Remember Me & "signature_properties"

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

Now that we've got the remember me system working, let's play with it! Instead of giving the user the option to enable "remember me", could we... just enable it always?

Sure! In this case, we no longer need a remember me checkbox... so we delete that entirely.

always_remember_me: true

There are two ways that you can "force" the remember me system to always set a cookie even though the checkbox isn't there. The first is in security.yaml: set always_remember_me: to true:

security:
... lines 2 - 16
firewalls:
... lines 18 - 20
main:
... lines 22 - 27
remember_me:
... line 29
always_remember_me: true
... lines 31 - 43

Yes, I totally just misspelled remember... so don't do that!

With this, our authenticator still needs to add a RememberMeBadge:

... lines 1 - 23
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 26 - 39
public function authenticate(Request $request): PassportInterface
{
... lines 42 - 44
return new Passport(
... lines 46 - 55
new PasswordCredentials($password),
[
... lines 58 - 61
new RememberMeBadge(),
]
);
}
... lines 66 - 92
}

But the system will no longer look for that checkbox. As long as it sees this badge, it will add the cookie.

Enabling on the RememberMeBadge

The other way that you can enable the remember me cookie in all situations is via the badge itself. Comment-out the new option. Well... let me fix my typo and then comment it out:

security:
... lines 2 - 16
firewalls:
... lines 18 - 20
main:
... lines 22 - 27
remember_me:
... line 29
#always_remember_me: true
... lines 31 - 43

Inside of LoginFormAuthenticator, on the badge itself, you can call ->enable()... which returns the badge instance:

... lines 1 - 23
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 26 - 39
public function authenticate(Request $request): PassportInterface
{
... lines 42 - 44
return new Passport(
... lines 46 - 55
new PasswordCredentials($password),
[
... lines 58 - 61
(new RememberMeBadge())->enable(),
]
);
}
... lines 66 - 92
}

This says:

I don't care about any other settings or the checkbox: I definitely want the remember me system to add a cookie.

Let's try it! Clear the session and REMEMBERME cookie. This time when we login... oh, invalid CSRF token! That's because I just killed my session without refreshing - silly Ryan! Refresh and try again.

Beautiful! We have the REMEMBERME cookie!

Securing Remember Me Cookies: Invalidate on User Data Change

There is one thing that you need to be careful with when it comes to remember me cookies. If a bad user somehow got access to my account - like they stole my password - then they could, of course, log in. Normally, that sucks... but as soon as I find out, I could change my password, which will log them out.

But... if that bad user has a REMEMEBERME cookie... then even if I change my password, they will stay logged in until that cookie expires... which could be a long time from now. These cookies are almost as good as the real thing: they act like "free authentication tickets". And they keep working - no matter what we do - until they expire.

Fortunately, in the new authenticator system, there's a really cool way to avoid this. In security.yaml, below remember_me, add a new option called signature_properties set to an array with password inside:

security:
... lines 2 - 16
firewalls:
... lines 18 - 20
main:
... lines 22 - 27
remember_me:
... line 29
signature_properties: [password]
... lines 31 - 44

Let me explain. When Symfony creates the remember me cookie, it creates a "signature" that proves that this cookie is valid. Thanks to this config, it will now fetch the password property off of our User and include that in the signature. Then, when that cookie is used to authenticate, Symfony will re-create the signature using the password of the User that's currently in the database and make sure the two signatures match. So if the password in the database is different than the password that was used to originally create the cookie... the signature match will fail!

In other words, for any properties in this list, if even one of these changes in the database on that User, all remember me cookies for that user will instantly be invalidated.

So if a bad user steals my account, all I need to do is change my password and that bad user will get kicked out.

This is super cool to see in action. Refresh the page. If you tweak the signature_properties config, that will invalidate all REMEMBERME cookies on your entire system: so make sure to get the config right when you first set things up. Watch: if I delete the session cookie and refresh... yup! I'm not authenticated: the REMEMBERME cookie didn't work. It's still there... but it's non-functional.

Let's log in - with our normal email address... and password... so that we get a new remember me cookie that's created with the hashed password.

Cool! And now, under normal conditions, things will work just like normal. I can delete the session cookie, refresh, and I'm still logged in.

But now, let's change the user's password in the database. We can cheat and do this on the command line:

symfony console doctrine:query:sql 'UPDATE user SET password="foo" WHERE email = "abraca_admin@example.com"'

Setting the password to foo is utter nonsense... since this column needs to hold a hashed password... but it'll be ok for our purposes. Hit it and... awesome! This imitated what would happen if I changed the password on my account.

Now, if we are the bad user, the next time we come back to the site... suddenly we're logged out! Blast! And I would've gotten away with it, too, if it weren't for you meddling kids! The remember me cookie is there... but it's not working. I love this feature.

Let's go back... and reload our fixtures to fix my password:

symfony console doctrine:fixtures:load

And... once that's done, go log in again as abraca_admin@example.com, password tada.

Next: it's time to have a power trip and start denying access! Let's look at access_control: the simplest way to block access to entire sections of your site.

Leave a comment!

12
Login or Register to join the conversation
Aigars Z. Avatar
Aigars Z. Avatar Aigars Z. | posted 4 months ago

Hi!
Is it possible to also invalidate all other (without Remember Me) active user sessions when password is changed?

Reply

Hey Aigars,

Actually, this is exactly how things should work in Security component. As soon as user's password is changed - it would invalidate and automatically log out the user everywhere. IIRC that's should be possible thanks to serialize/unserialize User method where you include the password, email, etc. fields that are sensitive for the security.

Cheers!

2 Reply
Aigars Z. Avatar

Thank you for very fast response!
It seems, that I mixed up users when performing tests.

1 Reply

Hey Aigars,

Yeah, most probably so :) Anyway, I'm glad my tips helped.

Cheers!

1 Reply
Trafficmanagertech Avatar
Trafficmanagertech Avatar Trafficmanagertech | posted 8 months ago

I see
`signature_properties:
- password`
is the default now, it works even if not set in the yaml. But this is useful for other properties such as the status, you don't want them to keep logging in after they are banned.

But is this the same as doing it in the User::isEqualTo() method?

if($this->status !== $user->getStatus()) {
return false;
}

Reply

Hey The_nuts,

Nice, good to have it by default :)

Yeah, good question! Well, the purpose of isEqualTo() and signature_properties are a bit different, but they have kinda similar behaviour. Personally, I think I'd probably keep status in isEqualTo(), and if user object "changed" - it would be logged out anyway.

Cheers!

Reply
akincer Avatar

Reminder for Windows users -- if you get the following error you have to switch the single and double quotes around:

"Too many arguments to "doctrine:query:sql" command, expected arguments "sql".

Use this as the command:

symfony console doctrine:query:sql "UPDATE user SET password='foo' WHERE email = 'abraca_admin@example.com'"

Reply

I have to start remembering to do this by default to help our Windows users :).

Thanks for the note!

Reply
Kevin B. Avatar

I was curious about remember me's being invalidated on password change with the old auth system. Appears they are - I believe this is because of the refresh user process and the AbstractToken::hasUserChanged() method. I assume this would be the same with the new authenticator but using signature_properties, it would "fail earlier" (before authentication). Am I off-base here?

Reply

Yo Kevin Bond!

> Appears they are - I believe this is because of the refresh user process and the AbstractToken::hasUserChanged() method

The old system invalidates the remember me cookies when the password changed? Are you positive about that? It's possible... but it's not what I would have expected. Yes, you're totally correct that the hasUserChanged() would "fail" when the user's password changes. That should invalidate the User object that's stored in the session... so it should have basically the same effect as deleting the "session" cookie. But then, the "remember me" system would take over: it would read the cookie, grab the "user identifier" from that, query for a refresh user, authenticate them and then store that new user in the session.

So let me know if you're seeing something different - that would be very interesting. I did test the new system for this behavior while I recorded (I changed the user's password BEFORE adding signature_properties and the result was that the user was still logged in, but suddenly via the remember me cookie instead of the original "token"). But, if you get a different result, we should look deeper :).

Cheers!

Reply
Kevin B. Avatar

Figured it out. The old auth system uses TokenBasedRememberMeServices and this hashes the cookie with the user's password (because UserInterface has the getPassword() method still). The new auth system uses SignatureRememberMeHandler which, because UserInterface does not have a getPassword() method anymore, can't* know to create the hash with the password.

*It could if it checked if the user implemented `PasswordAuthenticatedUserInterface` though...

Glad I have a test covering this for when I upgrade to the new auth system!

Reply

Ah ha! Good digging! So in the old system, there was almost a "hardcoded" signature_properties, which included the password. With the new system, you need can control the signature.

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
    }
}