Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

AbstractLoginFormAuthenticator & Redirecting to Previous URL

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

I have a confession to make: in our authenticator, we did too much work! Yep, when you build a custom authenticator for a "login form", Symfony provides a base class that can make life much easier. Instead of extending AbstractAuthenticator extend AbstractLoginFormAuthenticator:

... lines 1 - 15
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
... lines 17 - 25
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
... lines 28 - 95
}

Hold Command or Ctrl to open that class. Yup, it extends AbstractAuthenticator and also implements AuthenticationEntryPointInterface. Cool! That means that we can remove our redundant AuthenticationEntryPointInterface:

... lines 1 - 23
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
... lines 28 - 95
}

The abstract class requires us to add one new method called getLoginUrl(). Head to the bottom of this class and go to "Code"->"Generate" - or Command+N on a Mac - and then "Implement Methods" to generate getLoginUrl(). For the inside, steal the code from above... and return $this->router->generate('app_login'):

... lines 1 - 25
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
... lines 28 - 91
protected function getLoginUrl(Request $request): string
{
return $this->router->generate('app_login');
}
}

The usefulness of this base class is pretty easy to see: it implements three of the methods for us! For example, it implements supports() by checking to see if the method is POST and if the getLoginUrl() string matches the current URL. In other words, it does exactly what our supports() method does. It also handles onAuthenticationFailure() - storing the error in the session and redirecting back to the login page - and also the entry point - start() - by, yet again, redirecting to /login.

This means that... oh yea... we can remove code! Let's see: delete supports(), onAuthenticationFailure() and also start():

... lines 1 - 25
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
... lines 28 - 36
public function supports(Request $request): ?bool
{
return ($request->getPathInfo() === '/login' && $request->isMethod('POST'));
}
... lines 41 - 75
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$request->getSession()->set(Security::AUTHENTICATION_ERROR, $exception);
return new RedirectResponse(
$this->router->generate('app_login')
);
}
public function start(Request $request, AuthenticationException $authException = null): Response
{
return new RedirectResponse(
$this->router->generate('app_login')
);
}
... lines 91 - 95
}

Much nicer:

... lines 1 - 25
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
private UserRepository $userRepository;
private RouterInterface $router;
public function __construct(UserRepository $userRepository, RouterInterface $router)
{
... lines 33 - 34
}
public function authenticate(Request $request): PassportInterface
{
... lines 39 - 61
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
... lines 66 - 68
}
protected function getLoginUrl(Request $request): string
{
... line 73
}
}

Let's make sure we didn't break anything: go to /admin and... perfect! The start() method still redirects us to /login. Let's log in with abraca_admin@example.com, password tada and... yes! That still works too: it redirects us to the homepage... because that's what we're doing inside of onAuthenticationSuccess:

... lines 1 - 25
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
... lines 28 - 63
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return new RedirectResponse(
$this->router->generate('app_homepage')
);
}
... lines 70 - 74
}

TargetPathTrait: Smart Redirecting

But... if you think about it... that's not ideal. Since I was originally trying to go to /admin... shouldn't the system be smart enough to redirect us back there after we successfully log in? Yep! And that's totally possible.

Log back out. When an anonymous user tries to access a protected page like /admin, right before calling the entry point function, Symfony stores the current URL somewhere in the session. Thanks to this, in onAuthenticationSuccess(), we can read that URL - which is called the "target path" - and redirect there.

To help us do this, we can leverage a trait! At the top of the class use TargetPathTrait:

... lines 1 - 24
use Symfony\Component\Security\Http\Util\TargetPathTrait;
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
use TargetPathTrait;
... lines 30 - 81
}

Then, down in onAuthenticationSuccess(), we can check to see if a "target path" was stored in the session. We do that by saying if $target = $this->getTargetPath() - passing the session - $request->getSession() - and then the name of the firewall, which we actually have as an argument. That's that key main:

... lines 1 - 26
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
... lines 29 - 66
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
if ($target = $this->getTargetPath($request->getSession(), $firewallName)) {
... line 70
}
return new RedirectResponse(
$this->router->generate('app_homepage')
);
}
... lines 77 - 81
}

This line does two things at once: it sets a $target variable to the target path and, in the if statement, checks to see if this has something in it. Because, if the user goes directly to the login page, then they won't have a target path in the session.

So, if we have a target path, redirect to it: return new RedirectResponse($target):

... lines 1 - 26
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
... lines 29 - 66
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
if ($target = $this->getTargetPath($request->getSession(), $firewallName)) {
return new RedirectResponse($target);
}
return new RedirectResponse(
$this->router->generate('app_homepage')
);
}
... lines 77 - 81
}

Done and done! If you hold Command or Ctrl and click getTargetPath() to jump into that core method, you can see that it's super simple: it just reads a very specific key from the session. This is the key that the security system sets when an anonymous user tries to access a protected page.

Let's try this thing! We're already logged out. Head to /admin. Our entry point redirects us to /login. But also, behind the scenes, Symfony just set the URL /admin onto that key in the session. So when we log in now with our usual email and password... awesome! We get redirected back to /admin!

Next: um... we're still doing too much work in LoginFormAuthenticator. Dang! It turns out that, unless we need some especially custom stuff, if you're building a login form, you can skip the custom authenticator class entirely and rely on a core authenticator from Symfony.

Leave a comment!

8
Login or Register to join the conversation
MattWelander Avatar
MattWelander Avatar MattWelander | posted 7 months ago

Very happy with all these walkthroughs as always. One question - I have the following authenticate() method with a flag on the user to block login via the form for certain users.
As it currently behaves, it will check whether this block flag is on or not BEFORE the password is checked. This has the side effect that you can find out whether a user exists or not even if you don't pass the correct password.

Where would I put this deny-flag-check in order to do it only once the password is successfully checked (but before the user is authenticated... I understand that I could do it in the onAuthenticationSuccess method, but I want it before the session is approved)

public function authenticate(Request $request): PassportInterface
{

    $email = $request->request->get('email');
    $password = $request->request->get('password');

    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();
            } elseif ($user->isDenyFormLogin() == true) { //checking for denyFlag on user
                throw new CustomUserMessageAuthenticationException('Form login denied for this user.');
            }

            return $user;
        }),
        new PasswordCredentials($password),
        [
            new CsrfTokenBadge(
                'authenticate',
                $request->request->get('_csrf_token')
            ),
            (new RememberMeBadge())->enable(),
        ]
    );
}
Reply

Hey MattWelander!

Happy new year! This is an excellent question. Take this logic out of your authenticator and instead, add it to an event subscriber on the CheckPassportEvent::class event. The PasswordCredentials themselves are actually checked via a listener on this event: https://github.com/symfony/symfony/blob/6.3/src/Symfony/Component/Security/Http/EventListener/CheckCredentialsListener.php - you can use it as a guide. To be sure that you're running AFTER the password check, you could set your priority to -1 for this subscriber - so like this:

public static function getSubscribedEvents(): array
{
    return [CheckPassportEvent::class => ['checkPassport', -1]];
}

Let me know if that works out! Btw, a side effect of this (which is probably good) is that if you have any other ways for a user to authenticate (including, iirc, via a "remember me cookie"), those will also be "subject" to this check. In other words, no matter how your user tries to log in, if they fail this check, they will fail authentication. If this is NOT what you want (and you only want this check to happen for this ONE authenticator), that's no problem. Do this:

A) Create a new badge class - e.g. CheckDenyFormLoginBadge. It can basically be empty
B) Add this to your Passport
C) In your subscriber, only run the check if this badge is present.

Cheers!

Reply
Default user avatar
Default user avatar Andrea Gelmini | posted 1 year ago | edited

Hello.

Great guide. it's really helping me a lot.
I am writing this comment because while I was trying this step "AbstractLoginFormAuthenticator & Redirecting to Previous URL", after all the changes the authentication process did not seem to take place.

the solution I found was to change the LoginFormAuthenticator class
adding the motodo

    public function supports(Request $request): bool
    {
        return $request->isMethod('POST') && $this->getLoginUrl($request) === $request->getRequestUri();
    }

this

$this->getLoginUrl($request) === $request->getPathInfo()

it returned false and did not make the magic work.

Reply

Hey Andrea Gelmini !

It look like you have a tiny error in your first code example. This is how we do the supports() check


    public function supports(Request $request): ?bool
    {
        return ($request->getPathInfo() === '/login' && $request->isMethod('POST'));
    }

You can notice we hardcode the "/login" string

Cheers!

Reply
MattWelander Avatar

I'm confused =)

At one stage in this script you say:
"This means that... oh yea... we can remove code! Let's see: delete supports(), onAuthenticationFailure() and also start():"

In the resulting example, the method supports() is completely erased. Like Andrea Gelmini says above, this causes the authenticator to go completely disconnect. the login form is no longer attached to an authenticator, submitting the form will only render the login form anew.

By your answer to Andrea above, it seems that you confirm that the supports() method should still be in the LoginFormAuthenticator? Was it a mistake to tell us to remove it alltogether and the script needs amending? Or is there something in mine and Andreas environment that is configured differently than in your environment, causing the supports() method from the AbstractLoginFormAuthenticator to go disconnect?

Reply

Hmm, I see what you mean. Let me clarify what's going on. I think you likely already know some of this, but just to clear everything up:

A) Every authenticator DOES need a supports() method.
B) But, when we extend AbstractLoginFormAuthenticator, you can remove the supports() method in your class, simply because it already exists in the parent class.

So yes, I think Diego's comment was not quite right.

Now, to the real question:

Or is there something in mine and Andreas environment that is configured differently than in your environment, causing the supports() method from the AbstractLoginFormAuthenticator to go disconnect?

Yes, possibly :). For some reason, the supports() method that you're inheriting is returning false when it should return true. You could add some debugging code to that method to find out why, though I might have an idea: are you running your site at the root of a domain - e.g. http://127.0.0.1:8000 is your homepage? Or is it under a subdirectory - e.g. http://127.0.0.1:8000/site is the homepage? If it is the latter, there is a known bug in that inherited method that makes it not match correctly. In that case, you should keep your supports() method so it works. Wow, and apparently I opened that issue - lol - https://github.com/symfony/symfony/issues/44893 - and it's fixed in Symfony 5.4.13, 6.0.14, 6.1.6 and 6.2.0 and higher.

Anyways, that was just a guess at the problem. If I'm wrong, I'd love to know what you find going wrong in that parent supports() method!

Cheers!

Reply
MattWelander Avatar

That was in fact a pretty good guess - I'm testing it off of http://localhost:8888/mysite/ 😂 thanks

Reply

Yay! Well then I'm double glad that this will, at least, be fixed in later versions!

Have fun!

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