Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Authentication Success & Refreshing the User

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

Let's do a quick review of how our authenticator works. After activating it in security.yaml:

security:
... lines 2 - 13
firewalls:
... lines 15 - 17
main:
... lines 19 - 20
custom_authenticator: App\Security\LoginFormAuthenticator
... lines 22 - 34

Symfony calls our supports() method on every request before the controller:

... lines 1 - 17
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 20 - 26
public function supports(Request $request): ?bool
{
return ($request->getPathInfo() === '/login' && $request->isMethod('POST'));
}
... lines 31 - 73
}

Since our authenticator knows how to handle the login form submit, we return true if the current request is a POST to /login. Once we return true, Symfony then calls authenticate() and basically asks:

Okay, tell me who is trying to log in and what proof they have.

We answer these questions by returning a Passport:

... lines 1 - 17
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 20 - 31
public function authenticate(Request $request): PassportInterface
{
... lines 34 - 36
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;
}),
new CustomCredentials(function($credentials, User $user) {
return $credentials === 'tada';
}, $password)
);
}
... lines 53 - 73
}

The first argument identifies the user and the second argument identifies some proof... in this case, just a callback that checks that the submitted password is tada. If we are able to find a user and the credentials are correct... then we are authenticated!

We saw this at the end of the last video! When we logged in using the email of a real user in our database and password tada... we hit this dd() statement:

... lines 1 - 17
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 20 - 53
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
dd('success');
}
... lines 58 - 73
}

onAuthenticationSuccess

Yep! If authentication is successful Symfony calls onAuthenticationSuccess() and asks:

Congrats on authenticating! We're super proud! But... what should we do now?

In our situation, after success, we probably want to redirect the user to some other page. But for other types of authentication you might do something different. For example, if you're authenticating via an API token, you would return null from this method to allow the request to continue to the normal controller.

Anyways, that's our job here: to decide what to do "next"... which will either be "do nothing" - null - or return some sort of Response object. We're going to redirect.

Head up to the top of this class. Add a second argument - RouterInterface $router - use the Alt+Enter trick and select "Initialize properties" to create that property and set it:

... lines 1 - 9
use Symfony\Component\Routing\RouterInterface;
... lines 11 - 19
class LoginFormAuthenticator extends AbstractAuthenticator
{
... line 22
private RouterInterface $router;
public function __construct(UserRepository $userRepository, RouterInterface $router)
{
... line 27
$this->router = $router;
}
... lines 30 - 79
}

Back down in onAuthenticationSuccess(), we need to return null or a Response. Return a new RedirectResponse() and, for the URL, say $this->router->generate() and pass app_homepage:

... lines 1 - 6
use Symfony\Component\HttpFoundation\RedirectResponse;
... lines 8 - 19
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 22 - 57
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return new RedirectResponse(
$this->router->generate('app_homepage')
);
}
... lines 64 - 79
}

Let me go... double-check that route name.... it should be inside of QuestionController. Yup! app_homepage is correct:

... lines 1 - 17
class QuestionController extends AbstractController
{
... lines 20 - 29
/**
* @Route("/{page<\d+>}", name="app_homepage")
*/
public function homepage(QuestionRepository $repository, int $page = 1)
{
... lines 35 - 43
}
... lines 45 - 86
}

I'm not sure why PhpStorm thinks this route is missing... it's definitely there.

Anyways, let's log in from scratch. Go directly to /login, enter abraca_admin@example.com - because that's a real email in our database - and password "tada". When we submit... it works! We're redirected! And we're logged in! I know because of the web debug toolbar: logged in as abraca_admin@example.com, authenticated: Yes.

If you click this icon to jump into the profiler, there is a ton of juicy info about security. We're going to talk about the most important parts of this as we go along.

Authentication Info & The Session

Click back to the homepage. Notice that, if we surf around the site, we stay logged in... which is what we want. This works because Symfony firewalls are, by default, "stateful". That's a fancy way of saying that, at the end of each request, the User object is saved to the session. Then at the start of the next request, that User object is loaded from the session... and we stay logged in.

Refreshing the User

This works great! But... there is one potential problem. Imagine we log in at our work computer. Then, we go home, log in on a totally different computer, and change some of our user data - like maybe we change our firstName in the database via an "edit profile" section. When we come back to work the next day and refresh the site, Symfony will, of course, load the User object from the session. But... that User object will now have the wrong firstName! Its data will no longer match what's in the database... because we're reloading a "stale" object from the session.

Fortunately... this is not a real problem. Why? Because at the beginning of every request, Symfony also refreshes the user. Well, actually our "user provider" does this. Back in security.yaml, remember that user provider thingy?

security:
... lines 2 - 7
providers:
# used to reload user from session & other features (e.g. switch_user)
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
... lines 15 - 17
main:
... line 19
provider: app_user_provider
... lines 21 - 34

Yep it has two jobs. First, if we give it an email, it knows how to find that user. If we only pass a single argument to UserBadge then the user provider does the hard work of loading the User from the database:

... lines 1 - 19
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 22 - 35
public function authenticate(Request $request): PassportInterface
{
... lines 38 - 40
return new Passport(
new UserBadge($email, function($userIdentifier) {
... lines 43 - 50
}),
... lines 52 - 54
);
}
... lines 57 - 79
}

But the user provider also has a second job. At the start of every request, it refreshes the User by querying the database for fresh data. This all happens automatically in the background.... which is great! It's a boring, but critical process that you, at least, should be aware of.

User Changed === Logged Out

Oh, and by the way: after querying for the fresh User data, if some important data on the user changed - like the email, password or roles - you'll actually get logged out. This is a security feature: it allows a user to, for example, change their password and cause any "bad" users who may have gotten access to their account to get logged out. If you want to learn more about this, search for EquatableInterface: that's an interface that allows you to control this process.

Let's find out what happens when we fail authentication. Where does the user go? How are errors displayed? How will we deal with the emotional burden of failure? Most of that is next.

Leave a comment!

17
Login or Register to join the conversation
Braunstetter Avatar
Braunstetter Avatar Braunstetter | posted 1 year ago

I love your intonations. You might as well have become an audio book speaker.

1 Reply

Ah, thanks for the kind words! And I hope you still think so... if you end up watching a LOT of the videos 😀.

Cheers! ❤️❤️❤️

Reply
Braunstetter Avatar
Braunstetter Avatar Braunstetter | weaverryan | posted 1 year ago

Hey Ryan. I actually watched a lot :D. I am a symfonycasts member since I guess 7 years now? Since I startet learning symfony. Most of the things I know already but I like to watch you anyways. When I want to be in a good mood - I watch symfonycasts :D. 3-4 Years ago I had some episodes in my mp3 player for jogging :D.

Reply

Ha! MP3 player, I love that! I remember being REAL excited (a LONG time ago) about getting the first MP3 player that could hold (wait for it) 8 SONGS :p.

7 years - that's incredible. THANK YOU for the support - we love our jobs - people like you make it possible.

Cheers!

1 Reply
Hanane K. Avatar

I love Micheal's idea (audio book speaker) <3 , I'm also one of your fans Ryan, I discovered you 3 years ago (via soundofsymfony Podcast), and the way you explain things in your videos make learning joyful :D, even if I am not using symfony in my daily work (yet), I watch symfonyCast and your youtube conferences because I like how you explain things. big thanks to u and ur team

Reply

That is SO nice - thank you for posting this - you made my week (and this makes my job so awesome) ❤️❤️❤️

1 Reply
Default user avatar
Default user avatar GianlucaF | posted 5 months ago

Hi
Is there a way to skip session refresh at every call?
Thanks!

Reply

Hey GianlucaF!

Apologies for my slow reply! The answer is... yes! But based on your needs, it could mean 2 different things:

A) Firewalls are already "lazy" (that's the lazy: true under the firewall). This means that if, during a request, nothing in your code "asks for the user" or tries to perform any security checks, then the entire security system won't be activated. And so, the user will never be loaded from the session.

B) But, what I think you are really asking is: could we load the user from the session on every request... but only SOMETIMES "refresh" it from its data source (e.g. database or something else). The answer is also yes. You would do this by creating a custom user provider (instead of using the built-in "entity" provider like we are). On each request, Symfony will all the refreshUser(UserInterface $user) method on your user provider and pass you the User object that it just loaded from the session. *Normally*, you would use this (e.g. read its id) and fetch a fresh User object and return it. But, nothing is stopping you from just returning the User object that was just passed to you (the one from the session). Or, to give a more realistic example, you could store (somewhere) the last time that you refreshed the user. Then, in your method, if you refreshed it recently, you return the User that was passed to you. If it's been too long, then you do whatever your logic is to refresh it.

Let me know if that helps :). I would be careful avoiding refreshing (if you are talking about situation B)... just because your data could get out-of-date.

Cheers!

Reply
Juan-Etxenike Avatar
Juan-Etxenike Avatar Juan-Etxenike | posted 10 months ago

Seems there is a bug on this page I have tried to deploy the hidden lines of code and the full page gets reloaded inside the code snippets.

Reply

FYI bug is fixed, thanks for your report again!

Reply

Hello Juan E.

Nice catch! Thank you for reporting we are working to fix it as soon as possible

Cheers!

Reply
discipolat Avatar
discipolat Avatar discipolat | posted 1 year ago | edited

there Thank's for theses very cool videos.
How can i limit the number of connection of a user. for instance, deny another connection maybe from another device...when the user is already connected? Thank's.

Reply
Oliver W. Avatar
Oliver W. Avatar Oliver W. | discipolat | posted 10 months ago | edited

@Frdiscipolat ,
in one of my projects I just added a boolean field to the db holding the user data. Set this to true when the user is logged in and set it to 0 if he or she looges out. When logging in check if the field is set and if so deny a new login.
But be aware of this field if the user is logged out because the remember me runs out of time!
HTH

Reply
discipolat Avatar

Ok ok. Thank's i understand the idea. This was what is was planning to do. I thought symfony the new auth system came with such a feature.

Reply

Hey discipolat!

Apologies for my slow reply! Hmm. I've never implemented this before. I would imagine that it would look something like this:

A) You would need a way to calculate some sort of "device id". I'm not sure the best way to do this - there are some suggestions here - https://stackoverflow.com/q...

B) To track which devices a user is currently logged in, I would create a new UserDevice entity. It would have a ManyToOne to the User entity, a deviceId property and probably a lastActiveAt DateTime property.

C) I would create an event listen on the RequestEvent (previously called kernel.request). This event happens very early in Symfony. In that listener, I would find/calculate the device id. I would then find or create the UserDevice for this device id.

At this point in the listener, you could also query to find any other UserDevice that have been active, for example, in the last 5 minutes. If you find one, and so want to deny access, you could do something like $event->setResponse(new RedirectResponse(..)) and redirect the user to some page with an error message.

I hope this helps! Cheers!

Reply
Apr Avatar

I'm having troubles here. I'm using my custom authenticator and when i pass through LoginFormAuthenticator::onAuthenticationSuccess i do a redirect to the homepage as shown in the video. Debugging i could see the user info inside on $token and the session info seem alright too, but when i get to he homepage the debug toolbar says Authenticated:No. What am i doing wrong here?

Love the videos. Thanks

Reply

Hey saul

It's likely the security system is removing your session just after you authenticate because it reloads your user object on every request. I believe one of your "getters" in your User class is not correct. You can inspect the logs on each requests through the web profiler to gather more information. I hope it helps!

Cheers!

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