If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeAnother nice feature of a login form is a "remember me" checkbox. This is where we store a long-lived "remember me" cookie in the user's browser so that when they close their browser - and so, lose their session - that cookie will keep them logged in... for a week... or a year... or whatever we configure. Let's add this.
The first step is to go to config/packages/security.yaml
and activate the system. We do this by saying remember_me:
and then, below, setting one required piece of config: secret
: set to %kernel.secret%
:
security: | |
... lines 2 - 16 | |
firewalls: | |
... lines 18 - 20 | |
main: | |
... lines 22 - 27 | |
remember_me: | |
secret: '%kernel.secret%' | |
... lines 30 - 42 |
This is used to "sign" the remember me cookie value... and the kernel.secret
parameter actually comes from our .env
file:
... lines 1 - 15 | |
###> symfony/framework-bundle ### | |
... line 17 | |
APP_SECRET=c28f3d37eba278748f3c0427b313e86a | |
### | |
... lines 20 - 28 |
Yup, this APP_SECRET
ends up becoming the kernel.secret
parameter... which we can reference here.
Like normal, there are a bunch of other options that you can put under remember_me
... and you can see many of them by running:
symfony console debug:config security
Look for the remember_me:
section. One important one is lifetime:
, which is how long the remember me cookie will be valid for.
Earlier, I said that most of the configuration that we put under our firewall serves to activate different authenticators. For example, custom_authenticator:
activates our LoginFormAuthenticator
:
security: | |
... lines 2 - 16 | |
firewalls: | |
... lines 18 - 20 | |
main: | |
... lines 22 - 23 | |
custom_authenticator: App\Security\LoginFormAuthenticator | |
... lines 25 - 42 |
Which means that our class is now called at the start of every request and looks for a login form submit. The remember_me
config also activates an authenticator: a core authenticator called RememberMeAuthenticator
. On every request, this looks for a "remember me" cookie - that we'll create in a second - and, if it's there, uses it to authenticate the user.
Now that this is in place, our next job is to set that cookie on the user's browser after they log in. Open up login.html.twig
. Instead of always adding the cookie, let's let the user choose. Right after the password, add a div with some classes, a label and an input type="checkbox"
, name="_remember_me"
:
... 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 | |
<div class="form-check mb-3"> | |
<label> | |
<input type="checkbox" name="_remember_me" class="form-check-input"> Remember me | |
</label> | |
</div> | |
... lines 30 - 39 | |
</form> | |
</div> | |
</div> | |
</div> | |
{% endblock %} |
The name - _remember_me
- is important and needs to be that value. As we'll see in a minute, the system looks for a checkbox with this exact name.
Ok, refresh the form. Cool, we have a checkbox! Though... it's a little ugly... I think messed something up. Use form-check
and let's give our checkbox form-check-input
:
... 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 | |
<div class="form-check mb-3"> | |
<label> | |
<input type="checkbox" name="_remember_me" class="form-check-input"> Remember me | |
</label> | |
</div> | |
... lines 30 - 39 | |
</form> | |
</div> | |
</div> | |
</div> | |
{% endblock %} |
Now... better!
If we checked the box and submitted... absolutely nothing different would happen: Symfony would not set a remember me cookie.
That's because our authenticator needs to advertise that it supports remember me cookies being set. This is a little weird, but think about it: just because we activated the remember_me
system in security.yaml
doesn't mean that we ALWAYS want remember me cookies to be set. In a login form, definitely. But if we had some sort of API token authentication... then we wouldn't want Symfony to try to set a remember me cookie on that API request.
Anyways, all we need to add is a little flag that says that this authentication mechanism does support adding remember me cookies. Do this with a badge: new RememberMeBadge()
:
... lines 1 - 16 | |
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge; | |
... lines 18 - 23 | |
class LoginFormAuthenticator extends AbstractAuthenticator | |
{ | |
... lines 26 - 39 | |
public function authenticate(Request $request): PassportInterface | |
{ | |
... lines 42 - 44 | |
return new Passport( | |
new UserBadge($email, function($userIdentifier) { | |
... lines 47 - 55 | |
new PasswordCredentials($password), | |
[ | |
... lines 58 - 61 | |
new RememberMeBadge(), | |
] | |
); | |
} | |
... lines 66 - 92 | |
} |
That's it! But there's one kind of odd thing. With the CsrfTokenBadge
, we read the POSTed token and passed it to the badge. But with RememberMeBadge
... we don't do that. Instead, internally, the remember me system knows to look for a check box called, exactly, _remember_me
.
The entire process works like this. After we successfully authenticate, the remember me system will look for this badge and look to see if this checkbox is checked. If both are true, it will add the remember me cookie.
Let's see this in action. Refresh the page... and enter our normal email, password "tada", click the remember me checkbox... and hit "Sign in". Authentication successful! No surprise. But now open your browser tools, go to "Application", find "Cookies" and... yes! We have a new REMEMBERME
cookie... which expires a long time from now: that's in 1 year!
To prove the system works, delete the session cookie that normally keeps us logged in. Watch what happens when we refresh. We're still logged in! That is thanks to the remember_me
authenticator.
In the web debug toolbar, you can see a slight difference: it's this token class. When you authenticate, internally, your User
object is wrapped in a "token" object... which usually isn't too important. But that token shows how you were authenticated. Now it says RememberMeToken
... which proves that the remember me cookie was what authenticated us.
Oh, and if you're wondering why Symfony didn't add a new session cookie... that's only because Symfony's session is lazy. You won't see it until you go to a page that uses the session - like the login page. Now it's back.
And... that's really it! In addition to our LoginFormAuthenticator
, there is now a second authenticator that looks for authentication information on a REMEMBERME
cookie.
Though, we can make all of this a bit fancier. Next, let's see how we could add a remember me cookie for all users when they log in, without needing a checkbox. We're also going to explore a brand-new option on the remember me system that allows you to invalidate all existing remember me cookies if the user changes their password.
Hmm... I found the setting, so I set my cookie REMEMBERME to httponly: false
(under remember_me in the security.yaml
), but the behavior was still present, so apparently it wasn't caused by that setting. I kept digging and realized that I was testing the authenticated status with if ($this->isGranted('IS_AUTHENTICATED_FULLY')
which returns false if you are logged in through a cookie. I changed it to if ($this->isGranted('IS_AUTHENTICATED_FULLY') || $this->isGranted('IS_AUTHENTICATED_REMEMBERED'))
and it works like a charm, without lowering the bar by setting httponly: false
, I was out in left field there =)
Hey Matt,
Good catch! I'm glad you were able to figure it out yourself. Yes, that IS_AUTHENTICATED_FULLY
ignores the REMEMBER_ME cookies and looks only at the session - it's useful for some parts of your app where you want have extra security, e.g. the change password page. In this cases, even if the user is still authenticated via REMEMBER_ME - we still want user to log in fully first to be able to change their password.
About that if ($this->isGranted('IS_AUTHENTICATED_FULLY') || $this->isGranted('IS_AUTHENTICATED_REMEMBERED'))
- it's redundant here to check for IS_AUTHENTICATED_FULLY, it's just enough to check for IS_AUTHENTICATED_REMEMBERED and that's it. I.e. you either check for IS_AUTHENTICATED_FULLY or IS_AUTHENTICATED_REMEMBERED, but checking both in the same if clause has no sense :)
Cheers!
// 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
}
}
Hi! Seems like it's been even more simplified with sym 5.4, using the main: form_login authenticator all I needed to do was add the checkbox to the view, name it _remember_me like you explained, and symfony picks it right up.
A question though - how can I set the cookie to httpOnly:false? I make som asynchronous requests (jQuery ajax) for which this cookie is not valid, so parts of my site still works, while other parts (built on ajax requests) tell the user "you are not logged in".