Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

The Entry Point: Inviting Users to Log In

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

Log back in using abraca_admin@example.com and password tada. When we go to /admin, like we saw earlier, we get "Access Denied". This is because of the access_control... and the fact that our user does not have ROLE_ADMIN.

But if we change this to ROLE_USER - a role that we do have - then access is granted:

security:
... lines 2 - 40
access_control:
- { path: ^/admin, roles: ROLE_USER }
... lines 43 - 44

And we get to see some pretty impressive graphs.

Let's try one more thing. Log out - so manually go to /logout. Now that we are not logged in, if I went directly to /admin: what should happen?

Well, right now, we get a big error page with a 401 status code. But... that's not what we want! If an anonymous user tries to access a protected page on our site, instead of an error, we want to be super friendly and invite them to log in. Because we have a login form, it means that we want to redirect the user to the login page.

Hello Entry Point!

To figure out what to do when an anonymous user accesses a protected page, each firewall defines something called an "entry point". The entry point of a firewall is literally a function that says:

Here's what we should do when an anonymous user tries to access a protected page!

Each authenticator under our firewall may or may not "provide" an entry point. Right now, we have two authenticators: our custom LoginFormAuthenticator and also the remember_me authenticator:

security:
... lines 2 - 16
firewalls:
... lines 18 - 20
main:
... lines 22 - 23
custom_authenticator: App\Security\LoginFormAuthenticator
... lines 25 - 27
remember_me:
secret: '%kernel.secret%'
signature_properties: [password]
#always_remember_me: true
... lines 32 - 44

But neither of these provides an entry point, which is why, instead of redirecting the user to a page... or something different, we get this generic 401 error. Some built-in authenticators - like form_login, which we'll talk about soon - do provide an entry point... and we'll see that.

Making our Authenticator an Entry Point

But anyways, none of our authenticators provide an entry point... so let's add one!

Open up our authenticator: src/Security/LoginFormAuthenticator.php. If you want your authenticator to provide an entry point, all you need to do is implement a new interface: AuthenticationEntryPointInterface:

... lines 1 - 22
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
class LoginFormAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface
{
... lines 27 - 89
}

This requires the class to have one new method... which we actually already have down here. It's called start(). Uncomment the method. Then, inside, very simply, we're going to redirect to the login page. I'll steal the code from above:

... lines 1 - 22
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
class LoginFormAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface
{
... lines 27 - 83
public function start(Request $request, AuthenticationException $authException = null): Response
{
return new RedirectResponse(
$this->router->generate('app_login')
);
}
}

And done!

As soon as an authenticator implements this interface, the security system will notice this and start using it. Literally, if an anonymous user tries to access a protected page, it will now call our start() method... and we're going to redirect them to the login page.

Watch: refresh! Boom! It knocks us over to the login page.

A Firewall has Exactly ONE Entry Point

But there's one important thing to understand about entry points. Each firewall can only have one of them. Think about: at the moment we go to /admin as an anonymous user.... we're not trying to log in via a login form... or via an API token. We're truly anonymous. And so, if we did have multiple authenticators that each provided an entry point, our firewall wouldn't know which to choose. It needs one entry point for all cases.

Right now, since only one of our two authenticators is providing an entry point, it knows to use this one. But what if that were not the case? Let's actually see what would happen. Find your terminal and generate a second custom authenticator:

symfony console make:auth

Make an empty authenticator... and call it DummyAuthenticator.

Beautiful! Like This created a new class called DummyAuthenticator:

... lines 1 - 2
namespace App\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
class DummyAuthenticator extends AbstractAuthenticator
{
public function supports(Request $request): ?bool
{
// TODO: Implement supports() method.
}
public function authenticate(Request $request): PassportInterface
{
// TODO: Implement authenticate() method.
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// TODO: Implement onAuthenticationSuccess() method.
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
// TODO: Implement onAuthenticationFailure() method.
}
// public function start(Request $request, AuthenticationException $authException = null): Response
// {
// /*
// * If you would like this class to control what happens when an anonymous user accesses a
// * protected page (e.g. redirect to /login), uncomment this method and make this class
// * implement Symfony\Component\Security\Http\EntryPoint\AuthenticationEntrypointInterface.
// *
// * For more details, see https://symfony.com/doc/current/security/experimental_authenticators.html#configuring-the-authentication-entry-point
// */
// }
}

And it also updated custom_authenticator in security.yaml to use both custom classes:

security:
... lines 2 - 16
firewalls:
... lines 18 - 20
main:
... lines 22 - 23
custom_authenticator:
- App\Security\LoginFormAuthenticator
- App\Security\DummyAuthenticator
... lines 27 - 46

In the new class, inside supports(), return false:

... lines 1 - 11
class DummyAuthenticator extends AbstractAuthenticator
{
public function supports(Request $request): ?bool
{
return false;
}
... lines 18 - 43
}

We're... not going to turn this into a real authenticator.

If we stopped right now... and tried to go to /admin, it would still use the entry point from LoginFormAuthenticator. But now implement AuthenticationEntryPointInterface:

... lines 1 - 10
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
class DummyAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface
{
... lines 15 - 38
}

And then go down... and uncomment the start() method. For the body, just dd() a message... because this won't ever be executed:

... lines 1 - 12
class DummyAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface
{
... lines 15 - 34
public function start(Request $request, AuthenticationException $authException = null): Response
{
dd('DummyAuthenticator::start()!');
}
}

Now the firewall will notice that we have two authenticators that each provide an entry point. And so, when we refresh any page, it panics! The error says:

Run for you liiiiives!

Oh wait, it actually says:

Because you have multiple authenticators in firewall "main", you need to set the entry_point key to one of your authenticators.

And it helpfully tells us the two authenticators that we have. In other words: it's making us choose.

Copy the entry_point key... then, anywhere under the firewall, say entry_point: and then point to the LoginFormAuthenticator service:

security:
... lines 2 - 16
firewalls:
... lines 18 - 20
main:
... lines 22 - 23
entry_point: App\Security\LoginFormAuthenticator
... lines 25 - 47

Technically we can point to any service that implements AuthenticationEntryPointInterface... but usually I put that in my authenticator.

Now... if we go back to /admin.... it works fine! It knows to choose the entry point from LoginFormAuthenticator.

Speaking of LoginFormAuthenticator... um... we've been doing way too much work inside of it! That's my bad - we're doing things the hard way for... ya know... "learning"! But next, let's cut that out and leverage a base class from Symfony that will let us delete a bunch of code. We're also going to learn about something called TargetPathTrait: an intelligent way to redirect the user on success.

Leave a comment!

6
Login or Register to join the conversation
Default user avatar
Default user avatar Nicolas | posted 1 year ago

I have an issue with what I tried to do.
On my website I have an admin area and a public one. On the public side, by configuration, I can let user access all pages without authentication or require them an account if public side is private.
To manage that purpose I have a special account (guest in my database) with parameters (preferred language, theme, ...) like others users but theses parameters can only be changed by the webmaster.

To simplify my code (in controllers, services, templates,...) there's no difference between special account (guest) and other users. If guest access is allowed by webmaster, I want users to be automatically connected. So I create a new authenticator (GuestAuthenticator) with SelfValidatingPassport. It works.

It seems to works but... When I visit a page as guest and I try to access admin area I want to be redirect to login page. My first idea was that my authenticator do not implement AuthenticationEntryPointInterface. But the problem is that the returned response is 403 "Access Denied" because the user is already authenticated.

The main problem I try to resolve is that anonymous user are not real user in symfony but only a string. If you have another idea for me, it will be great.

If it's not clear, just tell me.

p.s: like always a really great tuorial

Reply

Hey Nicolas,

As far as I understand, you want to redirect "guest" users to the login page as soon as they try to access your admin area. Unfortunately, as soon as your guest user is authenticated in Symfony - you won't be redirect to the login page because Symfony already knows who you are and knows your roles. If you don't have a required role - the access will be denied with the error you mentioned instead of redirecting you to login page. So, either you should not authenticate anonymous users in your system and then the redirect will work, or accept that your users will see 403 access deny error accessing resources they don't have access to. Fairly speaking, I'm not sure how you can bypass this, that is pretty complex security system behind the scene. Maybe some even listeners with correct priority may help you, but I'm not sure, sorry.

Cheers!

Reply
Default user avatar

Thanks Victor for your qucik answer.

You perfectly understand.

Before symfony 5.3, I used an ugly hack, overriding getUser provided by AbstractControl. My getUser() method return a real User even for anonymous user. The token for anonymous user was AnonymousToken.

Until I find a better solution, guest users wil see a 403 deny access if they try to access admin area.

Reply

Hey Nicolas,

Yeah, an interesting hack actually, I suppose you can do something similar too. But instead of overriding getUser() in the abstract controller (because this way it will work only in controllers), I'd recommend you to create a separate service for this, where you will inject Security service. And if the Security service return null, i.e. if the user is anon - then return a Guest object in your custom service instead. Then, everywhere, instead of using Security service, you would need to use your new custom service that will return either Guest object or a User object, depends on whether the authenticated fully or anonymously. And this way I suppose you don't need a custom authenticator for Guest users at all, just main authenticated that authenticated users.

I hope this helps.

Cheers!

Reply
Default user avatar

It helps a lot. A custom service to retrieve the right user is a really good idea. Thanks

Reply

Hey Nicolas,

Great, I'm happy to hear it was useful for you :) And then you can override the getUser() in your base controller and leverage this custom service as well to avoid code duplication ;)

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": ">=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