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!

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