Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Security Upgrades

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 $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

It's time to fix these deprecations so that we can finally upgrade to Symfony 6. Go to any page on the site and click the deprecations down on the web debug toolbar to see the list. This is a big list... but a lot of these relate to the same thing: security.

The biggest - and perhaps most wonderful - change in Symfony 5.4 and Symfony 6, is the new security system. But don't worry. It's not that much different from the old one... and the upgrade path is surprisingly easy.

UserInterface, getPassword & PasswordAuthenticatedUserInterface

For the first change, open up the User entity. In addition to UserInterface, add a second PasswordAuthenticatedUserInterface. Until recently, UserInterface had a lot of methods on it, including getPassword().

... lines 1 - 8
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
... lines 10 - 14
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 17 - 211
}

But... this didn't always make sense. For example, some security systems have users that don't have passwords. For example, if your users log in via a single sign-on system, then there are no passwords to handle. Well, the user might enter their password into that system... but as far as our app is concerned, there are no passwords.

To make this cleaner, in Symfony 6, getPassword() was removed from UserInterface. So you still always need to implement UserInterface... but then the getPassword() method and its PasswordAuthenticatedUserInterface are optional.

UserInterface: getUsername() -> getUserIdentifier()

Another change relates to getUsername(). This method lives on UserInterface... but its name was always confusing. It made it seem like you needed to have a username... when really, this method is supposed to return any unique user identifier - not necessarily a username. Because of that, in Symfony 6, this has been renamed from getUsername() to getUserIdentifier(). Copy this, paste, change getUsername to getUserIdentifier()... and that's it.

... lines 1 - 14
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 17 - 69
/**
* A visual identifier that represents this user.
*
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}
... lines 79 - 221
}

We do need to keep getUsername() for now because we're still on Symfony 5.4... but once we upgrade to Symfony 6, we can safely remove it.

New Security System: enable_authenticator_manager

But the biggest change in Symfony's security system can be found in config/packages/security.yaml. It's this enable_authenticator_manager. When we upgraded the recipe, it gave us this config... but it was set to true.

security:
... lines 2 - 9
enable_authenticator_manager: false
... lines 11 - 64

This teenie, tiny, innocent-looking line allows us to switch from the old security system to the new one. And what that means, in practice, is that all of the ways you authenticate - like a custom authenticator or form_login or http_basic - will suddenly start using an entirely new system under the hood.

For the most part, if you're using one of the built-in authentication systems, like form_login or http_basic... you probably won't notice any changes. You can activate the new system by setting this to true... and everything will work exactly like before.... even though the code behind form_login will suddenly be very different. In a lot of ways, the new security system is an internal refactoring to make the core code more readable and to give us more flexibility, when we need it.

Guard -> Custom Authenticator Conversion

However, if you have any custom guard authenticators... like we do, you'll need to convert these to the new authenticator system... which is super fun anyways... so let's do it!

Open up our custom authenticator: src/Security/LoginFormAuthenticator.php. We can already see that AbstractFormLoginAuthenticator from the old system is deprecated. Change this to AbstractLoginFormAuthenticator.

... lines 1 - 20
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
... lines 22 - 23
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
... lines 26 - 107
}

I know, it's almost the exact same name: we just swapped "Form" and "Login" around. If your custom authenticator is not for a login form, then change your class to AbstractAuthenticator.

Oh, and we don't need to implement PasswordAuthenticatedInterface anymore: that was something for the old system.

Adding the New Authenticator Methods

The old Guard system and new authenticator system do the same thing: they figure out who's trying to log in, check the password, and decide what to do on success and failure. But the new authenticator style will feel quite a bit different. For example, you can immediately see that PhpStorm is furious because we now need to implement a new method called authenticate().

Ok! I'll go down below supports(), go to "Code Generate" - or "cmd" + "N" on a Mac - and implement that new authenticate() method. This is the core of the new authenticator system... and we're going to talk about it in a few minutes.

... lines 1 - 24
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
... lines 27 - 40
public function authenticate(Request $request)
{
// TODO: Implement authenticate() method.
}
... lines 45 - 113
}

Oh, but the old and new systems do share several methods. Like, they both have a method called supports()... but the new system has a bool return type. As soon as we add that, PhpStorm is happy.

... lines 1 - 25
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
... lines 28 - 35
public function supports(Request $request): bool
{
... lines 38 - 39
}
... lines 41 - 114
}

Below, on onAuthenticationSuccess(), it looks like we need to add a return type here as well. At the end, add the Response type from HttpFoundation. Nice! And while we're working on this method, rename the $providerKey argument to $firewallName.

... lines 1 - 25
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
... lines 28 - 90
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): Response
{
... lines 93 - 97
}
... lines 99 - 114
}

You don't have to do this, that's just the new name of the argument... and it's more clear.

Next, down on onAuthenticationFailure(), add the Response return type there as well. Oh, and for onAuthenticationSuccess(), I just remembered that this can return a nullable Response. In some systems - like API token authentication - you will not return a response.

... lines 1 - 25
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
... lines 28 - 99
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
... lines 102 - 108
}
... lines 110 - 114
}

Finally, we still need a getLoginUrl() method, but in the new system, this accepts a Request $request argument and returns a string.

... lines 1 - 25
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
... lines 28 - 110
protected function getLoginUrl(Request $request): string
{
return $this->urlGenerator->generate(self::LOGIN_ROUTE);
}
}

Alright! we still need to fill in the "guts", but we at least have all the methods we need.

Removing supports() for "form login" authenticators

And actually, we can remove one of these! Delete the supports() method.

... lines 1 - 25
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
... lines 28 - 35
public function supports(Request $request): bool
{
return self::LOGIN_ROUTE === $request->attributes->get('_route')
&& $request->isMethod('POST');
}
... lines 41 - 114
}

Ok, this method is still needed by custom authenticators and its job is the same as before. But, if you jump into the base class, in the new system, the supports() method is implemented for you. It checks to make sure that the current request is a POST and that the current URL is the same as the login URL. Basically, it says

I support authenticating this request if this is a POST request to the login form.

We wrote our logic a bit differently before, but that's exactly what we were checking.

Ok, it's time to get to the meat of our custom authenticator: the authenticate() method. Let's do that next.

Leave a comment!

19
Login or Register to join the conversation
MDelaCruzPeru Avatar
MDelaCruzPeru Avatar MDelaCruzPeru | posted 2 months ago

Hello everybody, I'm trying to implement security with no user table and authentication authorization all through an API, but I'm a little bit confused about what is the best way to implement it. Firstly I created the user with the make user method and Symfony creates the User class and a UserProvider class, then I check the course and learned about LoginFormAutheticator. I am already getting the response from the API with this piece of code in the class UserProvider implements UserProviderInterface, PasswordUpgraderInterface. But this method should return UserInterface and not a Passport. Do I have to implement the LoginFormAuthenticator instead of this method? What I'm missing is creating the user, the roles and processing the login. The User class created by Symfony implements UserInterface. Maybe is only an issue of filling the methods setUsername and setRoles and I can add there anything I need to store user information. Thanks for your help.

public function loadUserByIdentifier($identifier): UserInterface
{
    $user = $this->api->validateCredentials();

    dd($user->getContent());

    // Load a User object from your data source or throw UserNotFoundException.
    // The $identifier argument may not actually be a username:
    // it is whatever value is being returned by the getUserIdentifier()
    // method in your User class.
    throw new \Exception('TODO: fill in loadUserByIdentifier() inside ' . __FILE__);
}
Reply

Hey @MDelaCruzPeru

You can use the LoginFormAuthenticator if you have a login form or create a custom authenticator to fill in the logic for fetching users through the API. If I recall correctly, on a successful login, you'll need to return an instance of UserInterface, you can just add a User class to your project, it does not need to be an entity, just a holder for your users' info
You'll also need that UserProvider because Symfony will use it to reload your user object. You can learn more about Symfony's security here https://symfonycasts.com/screencast/symfony-security - The tutorial it's built on Symfony 5 but all concepts are up to date

Cheers!

1 Reply
Dirk Avatar
Dirk Avatar Dirk | posted 3 months ago | edited

Hi,

I already had the authenticate method in Symfony 5, however after upgrading to 6 I get this error when a user is not found or types in an incorrect password:

Symfony\Component\HttpFoundation\Exception\ BadRequestException
Input value "login_form" contains a non-scalar value.

<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
    return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

This is the code I use:

public function authenticate(Request $request): Passport
    {
        $email = $request->get('login_form')['_username'];
        $password = $request->get('login_form')['_password'];
        return new Passport(
            new UserBadge($email, function($userIdentifier) {
                // optionally pass a callback to load the User manually
                $user = $this->em->getRepository(User::class)->findOneBy(['email' => $userIdentifier]);
                if (!$user) throw new UserNotFoundException();
                if (!$user->isEnabled()) throw new CustomUserMessageAuthenticationException('The account is deactivated.');
                if ($user->isLocked()) throw new CustomUserMessageAuthenticationException('The account is blocked.');
                return $user;
            }),
            new PasswordCredentials($password),
            [
                new CsrfTokenBadge(
                    'authenticate',
                    $request->request->get('_csrf_token')
                ),
                (new RememberMeBadge())->enable(),
            ]
        );
    }

Any idea what might cause this? It seems to not like the Exceptions I defined in the UserBadge.

Reply

Hey Dirk,

Yeah, the get() request method does not work with arrays in Symfony 6.x anymore, only with scalar values. You need to use all() instead, e.g. in your case it should be something like this:

$parameters = $request->request->all();
$email = $parameters['login_form']['_username'];
// and so on...

See a related PR https://github.com/php-translation/symfony-bundle/pull/488 for more context.

Cheers!

Reply
Dirk Avatar

Spot on! Works flawlessly now. Many thanks for your quick reply, Victor!

Reply

Hey Dirk,

Awesome, thanks for confirming it helped :)

Cheers!

Reply

Hello MolloKhan,

Thank you for the quick response.

We only use http_basic_ldap.
It goes wrong when i want to start a session, when we have no PHP_AUTH_USER.
If i turn of enable_authenticator_manager everything works fine.

When I start a session and should get a popup for username, password, i get this error:

An exception has been thrown during the rendering of a template 
("Cannot autowire argument $user of "App\Controller\<controller>": it references 
interface "Symfony\Component\Security\Core\User\UserInterface" but no such service exists. Did you 
create a class that implements this interface?").

This is our services.yaml

 Symfony\Component\Ldap\Ldap:
    arguments: [ '@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter' ]
    tags:
      - ldap

  Symfony\Component\Ldap\Adapter\ExtLdap\Adapter:
    arguments:
      - connection_string: 'ldap://%env(LDAP_URL)%:389'
        options:
          protocol_version: 3
          referrals: false

And this is our security.yaml

security:
  enable_authenticator_manager: true
  providers:
    my_ldap:
      ldap:
        service: Symfony\Component\Ldap\Ldap
        base_dn: ...
        search_dn: ...
        search_password: 
        default_roles: ROLE_USER

  firewalls:
    dev:
      pattern: ^/(_(profiler|wdt)|css|images|js)/
      security: false
    main:
      stateless: true
      provider: my_ldap
      http_basic_ldap:
        service: Symfony\Component\Ldap\Ldap
        dn_string:...

Thanks for your help

Reply

Hey Annemieke, sorry for my slow response, but I was on holiday :)

You seem to be trying to inject a User object into a controller's action. Double-check the arguments of your controller

Reply

Hi MolloKhan,

Hope you had a nice vacation.

I get errors with every url (in the controller, voter etc.).
Because the username, password popup does not show up while it should when it is a new session.
When i turn of enable_authenticator_manager the popup does show, you fill in your username and password and everything works fine.
Can i send you some code or anything for you to try and replicate this situation?
Thanks in advance.

Reply

That's interesting... yea, I think I'd need to see your code. Perhaps you could give me access to your repository?

Reply

Hi Diego,

Do do you have some base example of a working ldap authentication program with symfony 6?
I have it working with symfony 5 and with symfony 3 for the last 4 years.
And what code do you need exactly?
Thanks in advance.

With kind regards,

Annemieke

Reply

Hey Annemieke-B,

Symfony Docs have some examples about authenticating against an LDAP server, you can check it here: https://symfony.com/doc/current/security/ldap.html - unfortunately, I have never used this before, so can't help with examples, but I hope the official docs help!

Cheers!

Reply

I'm afraid I do not have a working example. I'd like to play around with your application so I can debug what's going on, but I'd understand if you cannot share your app's code. I'll ask the team if someone can jump in to help

Reply

Hi all,

It goes wrong in HttpBasicAuthenticator class.

public function supports(Request $request): ?bool
{
    return $request->headers->has('PHP_AUTH_USER');
}

Since with a new session i do not have a PHP_AUTH_USER i will never get a response true.

For testing purposes I've added a few lines of code and everything works like a charm:

public function supports(Request $request): ?bool
{
    if (!$request->headers->has('PHP_AUTH_USER')) {
        $request->headers->set('PHP_AUTH_USER', '')
    }

    return $request->headers->has('PHP_AUTH_USER');
}

But now of course i have to find out how to set PHP_AUTH_USER outside vendor code.

Reply

Hey Annemieke, an option would be to create a custom authenticator that extends from HttpBasicAuthenticator so you can override the supports() method and add those lines

Cheers!

Reply

Hi Diego, thanks for the help.
HttpBasicAuthenticator is a final class, so I cannot extend it.
Or did you mean something else?

Greetz!

ps. I think i have to do something in apache virtual hosts.

Reply

Ohh, I was not aware of that (personally, I don't like final classes too much :p). What you can do instead is to decorate the HttpBasicAuthenticator class - https://symfony.com/doc/current/service_container/service_decoration.html

Cheers!

Reply

Hi Symfony Casts,

Again, great work people, thank you very much for making these videos.
Finally got my employers to invest in its developers and arrange a team account for symfony casts !!!

My question is:
We use ldap for authentication and authorization in symfony 5.4.
This works fine as long as we do not set 'enable_authenticator_manager' to true.
We want to use the LdapAuthenticator class of course, but the code first goes to HttpBasicAuthenticator.
There the support function gives us a false because of course we do not use PHP_AUTH_USER.
If i hack this function and let it always return true, it will get to LdapAuthenticator.
When i do a dump in the AuthenticatorManager class in the constructor of $this->authenticators i get:

0 => Symfony ...\LdapAuthenticator {
    authenticator: Symfony ..\HttpBasicAuthenticator { // is this HttpBasicAuth... correct??
        userProvider: Symfo..LdapUserProvider
        ......
  }
}

Thank you very much for your help. Hope to hear from you soon, we are upgrading to sf 6!

Kind regards,

Annemieke Buijs

Reply

Hey Annemieke,

Congratulations on convincing your employers! They're not going to regret this ;)

About your problem. When you activate the enable_authenticator_manager, it enables the new Symfony security system, so you need to adapt your authenticators. Perhaps you already did it, so the next step is to set up the execution order of your custom authenticators by tweaking the security.firewalls.main.custom_authenticators config option in your security.yaml file. Symfony will call the authenticators from top to bottom and will stop calling them as soon as one of them returns true from its supports method

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^8.0.2",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.6", // v3.6.1
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.5
        "doctrine/annotations": "^1.13", // 1.13.2
        "doctrine/dbal": "^3.3", // 3.3.5
        "doctrine/doctrine-bundle": "^2.0", // 2.6.2
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.0", // 2.11.2
        "knplabs/knp-markdown-bundle": "^1.8", // 1.10.0
        "knplabs/knp-time-bundle": "^1.18", // v1.18.0
        "pagerfanta/doctrine-orm-adapter": "^3.6", // v3.6.1
        "pagerfanta/twig": "^3.6", // v3.6.1
        "sensio/framework-extra-bundle": "^6.0", // v6.2.6
        "sentry/sentry-symfony": "^4.0", // 4.2.8
        "stof/doctrine-extensions-bundle": "^1.5", // v1.7.0
        "symfony/asset": "6.0.*", // v6.0.7
        "symfony/console": "6.0.*", // v6.0.7
        "symfony/dotenv": "6.0.*", // v6.0.5
        "symfony/flex": "^2.1", // v2.1.7
        "symfony/form": "6.0.*", // v6.0.7
        "symfony/framework-bundle": "6.0.*", // v6.0.7
        "symfony/mailer": "6.0.*", // v6.0.5
        "symfony/monolog-bundle": "^3.0", // v3.7.1
        "symfony/property-access": "6.0.*", // v6.0.7
        "symfony/property-info": "6.0.*", // v6.0.7
        "symfony/proxy-manager-bridge": "6.0.*", // v6.0.6
        "symfony/routing": "6.0.*", // v6.0.5
        "symfony/runtime": "6.0.*", // v6.0.7
        "symfony/security-bundle": "6.0.*", // v6.0.5
        "symfony/serializer": "6.0.*", // v6.0.7
        "symfony/stopwatch": "6.0.*", // v6.0.5
        "symfony/twig-bundle": "6.0.*", // v6.0.3
        "symfony/ux-chartjs": "^2.0", // v2.1.0
        "symfony/validator": "6.0.*", // v6.0.7
        "symfony/webpack-encore-bundle": "^1.7", // v1.14.0
        "symfony/yaml": "6.0.*", // v6.0.3
        "symfonycasts/verify-email-bundle": "^1.7", // v1.10.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.8
        "twig/string-extra": "^3.3", // v3.3.5
        "twig/twig": "^2.12|^3.0" // v3.3.10
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.1
        "phpunit/phpunit": "^9.5", // 9.5.20
        "rector/rector": "^0.12.17", // 0.12.20
        "symfony/debug-bundle": "6.0.*", // v6.0.3
        "symfony/maker-bundle": "^1.15", // v1.38.0
        "symfony/var-dumper": "6.0.*", // v6.0.6
        "symfony/web-profiler-bundle": "6.0.*", // v6.0.6
        "zenstruck/foundry": "^1.16" // v1.18.0
    }
}
userVoice