Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Login Form Authenticator

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

Now that we've added our authenticator under the authenticators key:

security:
... lines 2 - 8
firewalls:
... lines 10 - 12
main:
... lines 14 - 15
guard:
authenticators:
- App\Security\LoginFormAuthenticator
... lines 19 - 33

Symfony calls its supports() method at the beginning of every request, which is why we see this little die statement:

... lines 1 - 10
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
public function supports(Request $request)
{
die('Our authenticator is alive!');
}
... lines 17 - 41
}

These authenticator classes are really cool because each method controls just one small part of the authentication process.

The supports() Method

The first method - supports() - is called on every request. Our job is simple: to return true if this request contains authentication info that this authenticator knows how to process. And if not, to return false.

In this case, when we submit the login form, it POSTs to /login. So, our authenticator should only try to authenticate the user in that exact situation. Return $request->attributes->get('_route') === 'app_login':

... lines 1 - 10
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
public function supports(Request $request)
{
// do your work when we're POSTing to the login page
return $request->attributes->get('_route') === 'app_login'
... line 17
}
... lines 19 - 43
}

Let me... explain this. If you look in SecurityController, the name of our login route is app_login:

... lines 1 - 8
class SecurityController extends AbstractController
{
/**
* @Route("/login", name="app_login")
*/
public function login(AuthenticationUtils $authenticationUtils)
{
... lines 16 - 25
}
}

And, though you don't need to do it very often, if you want to find out the name of the currently-matched route, you can do that by reading this special _route key from the request attributes. In other words, this is checking to see if the URL is /login. We also only want our authenticator to try to login the user if this is a POST request. So, add && $request->isMethod('POST'):

... lines 1 - 10
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
public function supports(Request $request)
{
// do your work when we're POSTing to the login page
return $request->attributes->get('_route') === 'app_login'
&& $request->isMethod('POST');
}
... lines 19 - 43
}

Here's how this works: if we return false from supports(), nothing else happens. Symfony doesn't call any other methods on our authenticator, and the request continues on like normal to our controller, like nothing happened. It's not an authentication failure - it's just that nothing happens at all.

If we return true from supports(), well, that's when the fun starts. If we return true, Symfony will immediately call getCredentials():

... lines 1 - 10
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
... lines 13 - 19
public function getCredentials(Request $request)
{
... line 22
}
... lines 24 - 43
}

To see if things are working, let's just dump($request->request->all()), then die():

... lines 1 - 10
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
... lines 13 - 19
public function getCredentials(Request $request)
{
dump($request->request->all());die;
}
... lines 24 - 43
}

I know, that looks funny. Unrelated to security, if you want to read POST data off of the request, you use the $request->request property.

Anyways, let's try it! Go back to your browser and hit enter on the URL so that it makes a GET request to /login. Hello login page! Our supports() method just returned false. And so, the request continued anonymously, like normal.

Log in with one of our dummy users: spacebar1@example.com. The password doesn't matter. And... enter! Yes! This time, because this is a POST request to /login, supports() returns true! So, Symfony calls getCredentials() and our dump fires! As expected, we can see the email and password POST parameters, because the login form uses these names:

... lines 1 - 10
{% block body %}
<form class="form-signin" method="post">
... lines 13 - 18
<input type="email" name="email" id="inputEmail" class="form-control" placeholder="Email address" required autofocus>
... line 20
<input type="password" name="password" id="inputPassword" class="form-control" placeholder="Password" required>
... lines 22 - 29
</form>
{% endblock %}

The Brand-New dd() Function

Oh, and I want to show you a quick new Easter egg in Symfony 4.1, unrelated to security. Instead of dump() and die, use dd() and then remove the die:

... lines 1 - 10
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
... lines 13 - 19
public function getCredentials(Request $request)
{
dd($request->request->all());
}
... lines 24 - 43
}

Refresh! Same result. This is just a nice, silly shortcut: dd() is dump() and die. We'll use it... because... why not?

The getCredentials() Method

Back to work! Our job in getCredentials() is simple: to read our authentication credentials off of the request and return them. In this case, we'll return the email and password. But, if this were an API token authenticator, we would return that token. We'll see that later.

Return an array with an email key set to $request->request->get('email') and password set to $request->request->get('password'):

... lines 1 - 10
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
... lines 13 - 19
public function getCredentials(Request $request)
{
return [
'email' => $request->request->get('email'),
'password' => $request->request->get('password'),
];
}
... lines 27 - 46
}

I'm just inventing these email and password keys for the new array: we can really return whatever we want from this method. Because, after we return from getCredentials(), Symfony will immediately call getUser() and pass this array back to us as the first $credentials argument:

... lines 1 - 10
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
... lines 13 - 27
public function getUser($credentials, UserProviderInterface $userProvider)
{
... line 30
}
... lines 32 - 46
}

Let's see that in action: dd($credentials):

... lines 1 - 10
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
... lines 13 - 27
public function getUser($credentials, UserProviderInterface $userProvider)
{
dd($credentials);
}
... lines 32 - 46
}

Move back to your browser and, refresh! Coincidentally, it dumps the exact same thing as before. But, this time, it's coming from line 30 - our line in getUser().

The getUser() Method

Let's keep going! Our job in getUser() is to use these $credentials to return a User object, or null if the user isn't found. Because we're storing our users in the database, we need to query for the user via their email. And to do that, we need the UserRepository that was generated with our entity.

At the top of the class, add public function __construct() with a UserRepository $userRepository argument:

... lines 1 - 4
use App\Repository\UserRepository;
... lines 6 - 11
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
... lines 14 - 15
public function __construct(UserRepository $userRepository)
{
... line 18
}
... lines 20 - 54
}

I'll hit Alt+Enter and select "Initialize Fields" to add that property and set it:

... lines 1 - 4
use App\Repository\UserRepository;
... lines 6 - 11
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
private $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
... lines 20 - 54
}

Back down in getUser(), just return $this->userRepository->findOneBy() to query by email, set to $credentials['email']:

... lines 1 - 11
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
... lines 14 - 35
public function getUser($credentials, UserProviderInterface $userProvider)
{
return $this->userRepository->findOneBy(['email' => $credentials['email']]);
}
... lines 40 - 54
}

This will return our User object, or null. The cool thing is that if this returns null, the whole authentication process will stop, and the user will see an error. But if we return a User object, then Symfony immediately calls checkCredentials(), and passes it the same $credentials and the User object we just returned:

... lines 1 - 11
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
... lines 14 - 40
public function checkCredentials($credentials, UserInterface $user)
{
... line 43
}
... lines 45 - 54
}

Inside, dd($user) so we can see if things are working:

... lines 1 - 11
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
... lines 14 - 40
public function checkCredentials($credentials, UserInterface $user)
{
dd($user);
}
... lines 45 - 54
}

Refresh and... got it! That's our User object!

The checkCredentials() Method

Ok, final step: checkCredentials(). This is your opportunity to check to see if the user's password is correct, or any other last, security checks. Right now... well... we don't have a password, so, let's return true:

... lines 1 - 11
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
... lines 14 - 40
public function checkCredentials($credentials, UserInterface $user)
{
// only needed if we need to check a password - we'll do that later!
return true;
}
... lines 46 - 55
}

And actually, in many systems, simply returning true is perfect! For example, if you have an API token system, there's no password.

If you did return false, authentication would fail and the user would see an "Invalid Credentials" message. We'll see that soon.

But, when you return true... authentication is successful! Woo! To figure out what to do, now that the user is authenticated, Symfony calls onAuthenticationSuccess():

... lines 1 - 11
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
... lines 14 - 46
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
... line 49
}
... lines 51 - 55
}

Put a dd() here that says "Success":

... lines 1 - 11
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
... lines 14 - 46
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
dd('success!');
}
... lines 51 - 55
}

Move over and... refresh the POST! Yes! We hit it! At this point, we have fully filled in all the authentication logic. We used supports() to tell Symfony whether or not our authenticator should be used in this request, fetched credentials off of the request, used those to find the user, and returned true in checkCredentials() because we don't have a password.

Next, let's fill in these last two methods and finally see - for real - that our user is logged in. We'll also learn a bit more about what happens when authentication fails and how the error message is rendered.

Leave a comment!

87
Login or Register to join the conversation
Yahya E. Avatar
Yahya E. Avatar Yahya E. | posted 4 years ago | edited

Heyi I am trying to add some validation to login form.

I have followings:


# LoginFormAuthenticatior.php
public function getCredentials(Request $request)
{
    $form = $this->formFactory->create(LoginType::class);

    if($form->isValid())
    {
        $form->handleRequest($request);
        $data = $form->getData();
        $post = $request->request->all();

        $credentials = [
            'email' => $data['email'],
            'password' => $data['password'],
            'csrf_token' => $post['login']['_csrf_token'],
        ];

        $request->getSession()->set(
            Security::LAST_USERNAME,
            $credentials['email']
        );

        return $credentials;
    }
    else
    {
        return $form->getErrors();
    }
}

class LoginType extends AbstractType
{

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder
        ->add('email', TextType::class, [
            'required' => true,
            'constraints' => array(new Email())
        ])
        ->add('password', PasswordType::class)
    ;
}

public function configureOptions(OptionsResolver $resolver)
{
    $resolver->setDefaults(array(
        // enable/disable CSRF protection for this form
        'csrf_protection' => true,
        // the name of the hidden HTML field that stores the token
        'csrf_field_name' => '_csrf_token',
        // an arbitrary string used to generate the value of the token
        // using a different string for each form improves its security
        'csrf_token_id' => 'authenticate',
    ));
}

}



But when I click submit in login form;

This throws the following error:

Cannot check if an unsubmitted form is valid. Call Form::isSubmitted() before Form::isValid().`

How can we achieve the validation and the validation errors in a form authenticator?

1 Reply
Yahya E. Avatar
Yahya E. Avatar Yahya E. | Yahya E. | posted 4 years ago | edited

I have ended up with the following: FYI:


    public function getCredentials(Request $request)
    {
        $form = $this->formFactory->create(LoginType::class);

        $form->handleRequest($request);

        $validation_errors = $this->validator->validate($form);

        foreach ($validation_errors as $error)
        {
            if(self::str_contains($error->getPropertyPath(), 'email'))
            {
                throw new CustomUserMessageAuthenticationException(
                    $this->translator->trans('You entered an invalid email address.')
                );
            }

            if(self::str_contains($error->getPropertyPath(), 'password'))
            {
                throw new CustomUserMessageAuthenticationException(
                    $this->translator->trans('Your password must be at least 8 characters.')
                );
            }
        }

        $data = $form->getData();

        $post = $request->request->all();

        $credentials = [
            'email' => $data['email'],
            'password' => $data['password'],
            'csrf_token' => $post['login']['_csrf_token'],
        ];

        $request->getSession()->set(
            Security::LAST_USERNAME,
            $credentials['email']
        );

        return $credentials;
    }
2 Reply

Hey Yahya E.!

Ah! Your second solution works. But you first solution was SO close. The change that got you was something that changed from Symfony 3 to Symfony 4. Here is what you needed in the first code:


if($form->isSubmitted() &&  $form->isValid())

That's it! The key is the $form->isSubmitted(). That line doesn't make as much sense in this context, but basically, Symfony wants you to also make sure the form was submitted (i.e. that this wasn't a GET request) before actually asking if it's valid.

I hope it will help simplify your code a bit!

Cheers!

Reply
Mike P. Avatar

Does it has any security benefits to use SF form over our standard html form, which would make the more work worth? (e.g. better protection against xss or mysql injection?)

Reply

Hey Mike,

Unfortunately, any forms do not protect you from MySQL injection, your MySQL client should worry about it. And Doctrine do so if you use it the correct way, i.e. use parameters instead of concatenating dynamic values to query strings. You can check our Doctrine tutorial about how to write good queries: https://symfonycasts.com/sc... .

What about XSS - yes! Symfony Forms has CSRF protection enabled by default. You can turn it of in global settings or turn off in specific forms. To ensure you have CSRF protection in Symfony Forms - you can render the form and look for hidden field with token.

And fairly speaking, even if we're talking about simple forms, it's more convenient to work with Symfony Forms instead of handle them manually. Well, you can do an experiment, implement a form with Symfony Form and then do the same with custom form and compare results.

Cheers!

Reply

Any explanation why start() functioon is fired even that support return false, normally the authenticator should be skipped ?

Reply

Hello ahmedbhs

start() method is a part of AuthenticationEntryPointInterface it helps to define which way user should be authenticated if you have multiple authenticators. You can read more about it here https://symfony.com/doc/current/security/entry_point.html

Cheers!

Reply
Chamal P. Avatar
Chamal P. Avatar Chamal P. | posted 2 years ago

I am new to Symfony web application development. And at the moment Iam building a web application to get my head around Symfony web development. I have stored some sensitive keys in the Symfony's secrets management system (vault). And I want to access a key in the vault to do some operation. I need to access the vault items in the LoginFormAuthenticator class which extends AbstractFormLoginAuthenticator, and $this->getParameter('key_name') is not working in the LoginFormAuthenticator class.

Is there a work around where I should be able to access the vault items in that class ?

Reply

Hey Chamal,

To access an environment variable in PHP user $_ENV or $_SERVER vars, i.e. you can get that "KEY_NAME" env var via $_ENV['KEY_NAME']
or $_SERVER['KEY_NAME']. Usually, env vars are written in uppercase.

Or, if you want to get it via getParameter() instead - you need to create the "parameter" first. For example, in config/services.yaml add:


parameters:
    key_name: '%env(KEY_NAME)%'

And then in your PHP code call "->getParameter('key_name')" that you just created.

I hope this helps!

Cheers!

Reply
Chamal P. Avatar
Chamal P. Avatar Chamal P. | Victor | posted 2 years ago | edited

Hi Victor,

Thanks for your reply.
That is what I have exactly done it, in my code. Pasted the code below for more clarity, along with the error message I received.

in config/services.yaml


parameters:
    db_access_key: '%env(DB_ACCESS_KEY)%'
    db_access_secret: '%env(DB_ACCESS_SECRET)%'

In LoginFormAuthenticator


public function getUser($credentials, UserProviderInterface $userProvider) {
        $email      = $credentials['email'];

        dump($this->getParameter('db_access_secret'));  //error on this line
    }

And I get the following exception:

<blockquote>Attempted to call an undefined method named "getParameter" of class "App\Security\LoginFormAuthenticator".
</blockquote>

Reply

Hey Chamal,

Ah, I see your problem now :) Sure, the "getParameter()" method is available in your controllers only, and only in case you extends Symfony base controller. If you want to get a parameter in your service - you have to inject it there, and that's a good practice btw!

So, the easiest way will be next: in your config/services.yaml, create a bind for a variable name and associated parameter:


services:
    _defaults:
        bind:
            $dbAccessKey: '%db_access_key%'

And then in your "LoginFormAuthenticator" add the constructor if you don't have it, that will look like this:


class LoginFormAuthenticator
{
    private $dbAccessKey;

    public function __construct($dbAccessKey)
    {
         $this->dbAccessKey = $dbAccessKey
    }
}

I.e., now you can ask for $dbAccessKey parameter injection in any service this way. The same way do for other parameters you need to inject.

Another way would be to inject the Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface service into the LoginFormAuthenticator that will give you access to all the parameters via ->get('db_access_key'). It might be useful when you need to get access to many parameters in your service. But unless you need just one or a few - the 1st way is recommended.

I hope this helps!

Cheers!

Reply
Chamal P. Avatar

Hi Victor,

Thanks for your replying.

I have already solved my issue, and it was the solution that you have suggested second. I have injected the ContainerBagInterface to the LoginFormAuthenticator class, and it worked. I have tried your first solution as well, but it didnt work for me. Maybe because I have configured it incorrectly. I will try out again.

Thank you very much again for helping to resolve my issue.

Cheers !

Reply

Hey Chamal,

Thanks for letting me know the 2nd solution works for you. Yeah, it may also depends on the version of Symfony you're using in your project. Anyway, if the 2nd works for you well - great!

Cheers!

Reply
Nicolas-S Avatar
Nicolas-S Avatar Nicolas-S | posted 2 years ago | edited

Hello there !

UserProvider is passed to getUser() but not used in your example.
Just wondering in what cases UserProvider would be useful in getUser().

Thanks !

Reply

Hey Nicolas-S!

Wonderful question! I have an answer for it back here - https://symfonycasts.com/sc... - let me know if it helps :).

Cheers!

Reply
Nicolas-S Avatar

Perfect, thanks !

Reply
Kiuega Avatar

Hey! In recent months, there has been a new way to manage authentication with Symfony: https://symfony.com/doc/cur...

It uses the new generation of GUARD if I understood correctly. But that's not all, by setting up this new system, we could use the "new" component of Symfony: The login throttling ( https://symfony.com/blog/ne... ) based on the RateLimiter.

I went through the doc 'to try to set up the new authentication system, based on our LoginFormAuthenticator, but it is much more complicated than expected, the doc is not very clear and goes in all meaning. In the end, we don't really know what we should use, which class to extend or implement, which function to perform and how. This is a drawback ...

Have you planned to make an Update video about this new component?

(And why not create a new command to set up this new authentication mode, like the php bin/console make:auth command)

Reply

Hey @kieuga!

Yes! I know and love this system - I’ve been working with Wouter... who has done ALL the real work on it - to perfect it.

A few things:

1) we will do a tutorial, but I’m waiting for symfony 5.3, as a few more (nice) changes are coming that I want to show (the splitting up of UserInterface and renaming of getUsername).

2) version 1.26.0 of maker bundle - https://github.com/symfony/... - was updated to support the new system :). It detects if you have it enabled and generates different code. We would like to add more options to that command, but it supports the current empty and form login authenticators.

If you have any questions about the new system before 5.3, I’d be happy to answer them :).

Cheers!

Reply
Kiuega Avatar
Kiuega Avatar Kiuega | weaverryan | posted 2 years ago | edited

It's just awesome weaverryan Good job! For my part, I continued to reform this new authentication system (but very good news that the maker will be able to manage this soon!). I finally succeeded.

On the other hand, I don't know if you will talk about it in your tutorial or even if the maker will handle it, but regarding the Login Throttling, it does not work at all for me. Finally, I think this is mostly because I'm on Windows, and Semaphore is not supported for the locker. So I opted for "Flock" for the locker by simply replacing LOCK_DSN=semaphore by LOCK_DSN=flock in my <b>.env</b>, but it's like it's being ignored, like if I had not implemented this new feature. Weird. Hope your tutorial will talk about its use for us recalcitrant Windows lovers! :D

Reply

Hey Kiuega!

Woohoo - nice work!

but regarding the Login Throttling, it does not work at all for me

Hmm. Indeed, I will have to keep this in mind. When you use the login_throttling under your firewall, it crates a basic rate limiting service to use. But there is also an option to use a custom, rate limiter service id.

So here is what I bet is happening and what you should do:

1) Configure a custom rate limiter - via the framework.rate_limiter config to use Flock. It sounds like you've already done this. If you named it "login_throttler" in the config file, then behind the scenes, this is creating a service with the id "limiter. login_throttler".

2) Specify this in your security config:


security:
    firewalls:
        default:
            login_throttling:
                limiter: limiter. login_throttler

I'm doing some guess work here. There are some holes in the docs (lock is documented, rate limiter is documented and login throttling is documented, but this crosses all 3).

Btw, if you want to do any debugging, here is the class that's called on login and does the rate limiting logic: https://github.com/symfony/symfony/blob/5.x/src/Symfony/Component/Security/Http/EventListener/LoginThrottlingListener.php

Let me know what you find out :).

Cheers!

Reply
Kiuega Avatar
Kiuega Avatar Kiuega | weaverryan | posted 2 years ago | edited

Grrr my previous answer was marked as spam. I am enraged at having to start my answer all over again. :x

Hey weaverryan and thanks for your answer !

So I followed your directions, and using the doc ', I configured everything as needed.

  1. I kept in the <b>.env</b> my LOCK_DSN=flock to be able to use flock all the time without reconfiguring it each time.

  2. I created my custom <b>rate_limiter</b>:


framework:
    rate_limiter:
        login_throttler:
            policy: 'fixed_window'
            limit: 2
            interval: '2 minutes'
  1. I checked that it was active with <b>php bin/console debug:autowiring limiter</b>, which returned this to me:

<blockquote>
Autowirable Types
=================

The following classes & interfaces can be used as type-hints when autowiring:
(only showing classes/interfaces matching limiter)

Symfony\Component\RateLimiter\RateLimiterFactory $loginThrottlerLimiter (limiter.login_throttler)
</blockquote>

  1. I reconfigured my <b>security.yaml</b> to add the custom <b>rate_limiter</b>:
<
login_throttling:
        limiter: limiter.login_throttler
  1. I have not yet set up the listener because there is already a problem before I can be interested in it ... precisely with the basic listener.

In fact, I can still try as many times as I want with the wrong password. However, if I try with a good password this time around I get this:

<blockquote>
Argument 2 passed to Symfony\Component\Security\Http\EventListener\LoginThrottlingListener::__construct() must be an instance of Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface, instance of Symfony\Component\RateLimiter\RateLimiterFactory given, called in C:\Users\user\Desktop\Sylvia\var\cache\dev\ContainerWv8DSJy\getSecurity_Listener_LoginThrottling_MainService.php on line 38
</blockquote>

I don't understand, the documentation tells us that we can use a custom rate_limiter for this feature, but doesn't seem to support it. Or am I doing something wrong?

Reply

Hey Kiuega!

First, apologies for my slow reply - it was crazy week :p.

Grrr my previous answer was marked as spam. I am enraged at having to start my answer all over again. :x

Really sorry about that :/. We DO find and rescue these... not that this helps you for this time :/

Ok, let's get to your question! There are 2 interesting pieces. And let me first say that you followed my instructions beautifully. But... I apparently did not lead you down the path all the way correctly ;).

A) Argument 2 passed to LoginThrottlingListener::__construct() must be an instance of RequestRateLimiterInterface, instance of RateLimiterFactory given

I've just asked the author of this feature about this. There is a little disconnect that I didn't realize. Basically, the framework.rate_limiter config allows you to create RateLimiterFactory... but what the login throttling system needs is a RequestRateLimiterInterface... which is kind of a class that wraps the actual "rate limiters". Right now, I don't see a simple way of actually creating this. I mean, it's possible, but much harder than it should be. I'm either missing a detail (very possible), or this was an oversight. That's why I've asked the author. I will let you know :).

If you want to create the service yourself, create a new service in services.yaml that looks like this:


services:
    # ... 

    app.default_login_rate_limiter:
        class: Symfony\Component\Security\Http\RateLimiter \DefaultLoginRateLimiter
        arguments: ['@limiter.login_throttler', '@limiter.login_throttler']

Then pass this app.default_login_rate_limiter as your limiter in security.yaml.

B) Why did you not see the "LoginThrottlingListener::__construct() must be an instance" when you typed an invalid password?

This part... I can't explain. Basically, after you followed my directions, your rate limiter was totally misconfigured. This means that you should have gotten an error regardless of whether your typed in your password correctly or incorrectly: the rate limiter should have been used in both cases.

When I tried this locally (just now), I DID get the error regardless of whether I typed in the right password or wrong password. So something in your setup is different. I'm using the generic login form that you get with make:auth from MakerBundle. What does your authenticator look like?

Cheers!

Reply
Kiuega Avatar
Kiuega Avatar Kiuega | weaverryan | posted 2 years ago | edited

Hi Ryan! No worries I understand that you are very busy, thank you for taking time for me it's nice!

Okay so small change compared to the last time at my place. I installed the dual-boot to be able to develop on Ubuntu (yeeees awesome) and logically be able to use semaphore naturally.

<blockquote>When I tried this locally (just now), I DID get the error regardless of whether I typed in the right password or wrong password. So something in your setup is different. I'm using the generic login form that you get with make:auth from MakerBundle. </blockquote>

The answer below (the one crossed out) is totally wrong, ignore it. The real answer is just below what is crossed out.

<s>What you said there disturbed me. If in doubt, I also recreated a new project (EDIT: no I took an old project which was still virgin, and the rest of this text is therefore incorrect. I'm editing, I edit this post as soon as I'm done), and I set up the authentication with the make: auth command (so by generating a guard authentication, that is to say, the future old version), and I configured a login_throttler in the security.yaml, in the most basic way.

Well even logging in more than 2 times (the maximum number I allowed) with bad credentials, I got no other error than a simple "invalid credentials". And I was able to log in quite normally afterwards.

I took the trouble to post the project on github: https://github.com/bastien70/symfony-login-throttler-test

On my main project, this is the new authenticator that I put in place and which brings me exactly the same result.</s>

I will provide more details to my answer by editing it with new elements later, the time to test everything you have offered me!

EDIT : OKAY ! I came to understand some things.

  1. If we create a new project, and generate the authenticator directly with`
    make:auth`
    , it is based on the old version of the authenticator.

  2. If we create a project, and first modify the security.yaml to tell it to use the new way to authenticate (with enable_authenticator_manager: true), then generate an authenticator with the`
    make:auth`
    command, so this time it generates the new version of the authenticator (and indeed there are differences with the file that I had tried to create on my own project, in particular at the level of the Passport object in the authenticate ()).
    And the <b>login_throttler</b> seems to work on it!

Here is <u>a working github repository:</u> https://github.com/bastien70/symfony-working-login-throttler

EDIT2 : I just added an issue about this new authenticator system about @Security() and @IsGranted() annotations : https://github.com/symfony/symfony/issues/40571

Reply

Hey Kiuega!

Wow! Awesome job there! Yes, you're 1000% right about make:auth... and I'm so happy that you got the login throttler working! And what a *beautiful* issue you made on Symfony - it was able to quickly look into your reproducer and understand what was going on! Though... I still can't quite spot what's wrong with @Security from looking at its code in the bundle. Anyways, use @IsGranted()... it's better anyways (but we should fix this).

Cheers!

Reply
Kiuega Avatar
Kiuega Avatar Kiuega | weaverryan | posted 2 years ago | edited

Hello here !

Thank you for your participation in the Github issue!
You said : <blockquote>In SensioFrameworkExtraBundle, I think we should deprecate @Security. I created @IsGranted to be easier to use... but also because I thought the expression is not a good place to have that logic.</blockquote>

Indeed it would be better to try to solve the problem instead. According to the documentation ( https://symfony.com/doc/current/bundles/SensioFrameworkExtraBundle/annotations/security.html ), the @Security is still very useful for expressions (but if I understand your answer correctly, you would prefer that the expressions be finally moved to custom voters?), or for accessing the request and checking something (in my case I created a voter which verifies that the request was sent with Ajax, and using the @Security annotation allows me to directly access the request without bothering me, as explained in the doc https://symfony.com/doc/current/bundles/SensioFrameworkExtraBundle/annotations/security.html#security ).

In the meantime, if we want to verify that a user does <b>NOT</b> have a certain role, how could we do this since we won't be able (until it's fixed I imagine), use :

@Security("not is_granted('MY_SUPER_ROLE')")

And the @IsGranted() annotation does not allow to check a negation (unless it does, but in any case I do not see it in the documentation)

Reply

Hey Kiuega!

> but if I understand your answer correctly, you would prefer that the expressions be finally moved to custom voters

Yep! That's my general opinion... but I admit that using some basic expressions is probably ok :).

> In the meantime, if we want to verify that a user does NOT have a certain role, how could we do this since we won't be able (until it's fixed I imagine), use :
> @Security("not is_granted('MY_SUPER_ROLE')")

Hmm, I have never done this before! You want to allow access only if they do NOT have a role? Can you explain this a bit further? This sounds a bit odd... bit you might have a legitimate use-case. Anyways, for now, I would just do that logic in my controller in PHP. Or, I might create a voter - especially if it helps my code to read my clearly e..g. @IsGranted('NOT_AN_ADMIN')

Cheers!

Reply
Kiuega Avatar
Kiuega Avatar Kiuega | weaverryan | posted 2 years ago | edited

Hello !

<blockquote>Can you explain this a bit further?</blockquote>

Yes, quite simply, I have a user who has the <b>ROLE_SUPER_ADMIN</b> role (he's the supreme boss actually), and I only want him to be able to access his sublime admin dashboard, and if he tries to surrender on a page reserved for simple users (those who have the <b>ROLE_USER</b>), then I berate him. This is why I was using the not is_granted ('ROLE_SUPER_ADMIN') on some routes

And last question in passing (I hope you don't get bored). Since this new authentication system, the way to treat anonymous users has therefore changed. I saw that there was thus the '<b>PUBLIC_ACCESS</b>', but that seems a little vague to me.

Does making $this->isGranted ("PUBLIC_ACCESS") make sense?

What about the "role" '<b>IS_ANONYMOUS</b>'? Could we still use it?

I find it difficult to know what we can do now to limit access to certain categories of people. For example if I have a custom voter in which I have code that allows me to know if I allow access to someone, according to several conditions:

  1. Either he meets certain conditions as a registered user
  2. Or it is simply a guest (a user who does not have an account).

In this case, how to manage this second part? '<b>PUBLIC_ACCESS</b>', '<b>IS_ANONYMOUS</b>', <u>or pass the token as a parameter and check that it is null</u>, which would mean that it is a guest?

All this gives me lots of doubts about everything I have to change in my code

Reply

Hey Kiuega!

> if he tries to surrender on a page reserved for simple users (those who have the ROLE_USER), then I berate him

LOL! Ok, I've never had this use-case, but it makes sense ;)

> Does making $this->isGranted ("PUBLIC_ACCESS") make sense?
> What about the "role" 'IS_ANONYMOUS'? Could we still use it?

Using PUBLIC_ACCESS in that way does not make sense, in the same way that $this->isGranted ("IS_ANONYMOUS") didn't make sense either. Both are only useful (iirc) in access_control, where you add an access_control with PUBLIC_ACCESS to *allow* access to certain URLs (and prevent the rest of the access_control from being parsed, as only the first matched access control wins).

> or pass the token as a parameter and check that it is null, which would mean that it is a guest?

this one :). As I mentioned, those other 2 attributes were only ever useful on access_control. Everywhere else in the system, just check to see if the is a user or not. Oh, and a small detail, iirc, you will be passed a NullToken to your voter (not actually null) if the user is anonymous.

Cheers!

Reply
Kiuega Avatar
Kiuega Avatar Kiuega | weaverryan | posted 2 years ago | edited

Hello !

Okay great! Thank you very much for your time weaverryan , you have allowed me to learn a lot about this new system! I also hope that this discussion can be of use to others who will drop by in the comments section.

In the end, I created an ExtraVoter in which I will insert reusable general voters. I put the 'is_not_authenticated' there which checks that the token is a NullToken and returns true

Thanks for everything and see you next time! :)

Reply

Hey Kiuega!

> I also hope that this discussion can be of use to others who will drop by in the comments section.

Me too! It's one of the reasons I love keeping these conversations in the comments. We index them for the our search engine too... I search for old comments all the time ;).

> Thanks for everything and see you next time! :)

Cheers!

Reply

Hey again weaverryan!

After talking with the author, he's aware that configuring a custom rate limiter for login throttling is a bit tricky. He's aware of the issue. For now, he's documenting it - his documentation is similar to what I was telling you, but a bit more complete. Here's the rough draft of it - https://github.com/symfony/...

Let me know if that helps :).

Cheers!

Reply
triemli Avatar
triemli Avatar triemli | posted 2 years ago | edited

But how Symfony knows which is Authenticator need to send credentials? We can several Authenticators specify in security.yaml
Becuase on login methods i see only
` $error = $authenticationUtils->getLastAuthenticationError();

    $lastUsername = $authenticationUtils->getLastUsername();`

How it call the Authenticator service? Thanks.

Reply

Hey triemli!

Excellent question! On each request, Symfony loops over all of the authenticators that you have defined and calls the supports() method on ALL of them. Usually, either zero or one authenticator will return true. For example, if the user is sending a POST request to /login, then our LoginFormAuthenticator will return "true" from supports.

Once Symfony finds an authenticator whose supports method returns true, it then calls getCredentials(), getUser() and checkCredentials on that authenticator. If any of those fail, then it calls onAuthenticationFailure on that one authenticator.

On this situation, our LoginFormAuthenticator does TWO important things during that process:

1) In getCredentials() we call $request->getSession()->set(Security::LAST_USERNAME, $credentials['email']);. In your login method, the $authenticationUtils->getLastUsername(); is actually a shortcut to READ this key from the session. That is why that logic works inside your login controller method. There's no magic: it's simply that our login authenticator stores the username that was last used in the session, then we read it in the controller.

2) In the parent class' onAuthenticationFailure(), the error is also stored in the session - https://github.com/symfony/symfony/blob/6d521d40721104ac684565fe6d1ca2bb0372127e/src/Symfony/Component/Security/Guard/Authenticator/AbstractFormLoginAuthenticator.php#L42. When you call $authenticationUtils->getLastAuthenticationError();, that is just a shortcut to read that from the session.

I hope that clears things up - but please let me know if some parts are still confusing :).

Cheers!

1 Reply
triemli Avatar

Thanks for the answer! I didn't know that it every authenticator call support() method.

Reply
triemli Avatar
triemli Avatar triemli | posted 2 years ago | edited

Hi, I have some issue here.
I made /api-login annotation in the SecurityController but I see "too many redirects" an error.
I fixed with


    firewalls:
        login_firewall:
            pattern: ^/api-login
            anonymous: ~

in security yaml, but actually doesn't have an idea what'w wrong was here ;[

Reply

Hi triemli!

I think I can explain that :). See the text/video right around this code block: https://symfonycasts.com/screencast/symfony-security/firewalls-authenticator#codeblock-e09d08a770

If you don't have anonymous: true, then it means "don't allow anyone - even anonymous users to access this firewall". I normally recommend that you always have this setting (and then you can restrict access in other ways, like access_control.

But... this may only be part of the answer. I noticed that you created an entirely new firewall for this. Typically (but not always) an application only needs one firewall (if you don't count the sort of, fake, firewall that just prevents you from making the web debug toolbar and profiler not accessible - https://github.com/symfony/recipes/blob/62271a461b640d2d91fd8aaa779f6d65d7a66ec0/symfony/security-bundle/5.1/config/packages/security.yaml#L6-L8 ). On each request, only a single firewall matches - and it checks from top to bottom. So, I DO think that your problem is that you were missing the anonymous: true on your firewall. However, I would normally expect you to have only one real firewall and for that one real firewall to have this key - similar to what the default firewall configuration looks like - https://github.com/symfony/recipes/blob/62271a461b640d2d91fd8aaa779f6d65d7a66ec0/symfony/security-bundle/5.1/config/packages/security.yaml#L9-L12

Let me know if that helps... or makes any sense. If you have more questions, let me know ;).

Cheers!

1 Reply
triemli Avatar
triemli Avatar triemli | weaverryan | posted 2 years ago | edited

Thanks a lot!
That was a problem. I didn't notice that another firewall was created. Before I couldn't open the login page without those lines.
I also tried to say in access_control { path: ^/api-login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
On example anonymous is <b>true</b> but it works even when i set anonymous: false (idk why xD):

https://pastebin.com/7krLmZ4Z

Now I just added
`access_control:

  • { path: ^/, roles: ROLE_ADMIN }`
    But I still can open it and method support is works.

Basically, I just wanted to change /login on /api-login. Because it will 2 different auth services. So I couldn't open /api-login
I even didn't reach the <b>support</b> method

Reply

Hey triemli

So, is it working now with the code you linked? It looks OK to me, it's basically the default config, you just need to start restricting some paths in the access_control section

Reply
triemli Avatar
triemli Avatar triemli | MolloKhan | posted 2 years ago | edited

Thanks! Yes, everything is work good.
But 1 thing: - { path: ^/api-login, roles: ROLE_ADMIN } access_control rules works for everything except of /api-login page. Even I specify anonymous: false it still works and open but only /api-login page.

Reply

You may have another rule that's overruling it. The access control list reads from top to bottom and the first one always wins.
{ path: ^/api-login, roles: ROLE_ADMIN } means that any URL that starts with "/api-login" it will enforce the user to have the ROLE_ADMIN but it may be happening what I just said. Double-check your other rules

Reply
triemli Avatar

Yes, but I have only 1 rule. Also access_control can't be rewritten. Actually I was expected to see access denied but I perfectly open /api-login with no problems as anonym.

Reply

Hmm, this is strange! So, if I understand things correctly, you have just ONE access_control, which is:


- { path: ^/api-login, roles: ROLE_ADMIN }

And you ARE able to access /api-login but you are NOT able to access /api-login/anything-else (this requires ROLE_ADMIN). Is that correct?

If so, that's very odd behavior indeed! That access control should match EVERY url starting with /api-loginincluding the exact URL /api-login. Could you post your full security.yaml by chance?

Cheers!

Reply
triemli Avatar

Before probably was aggressive cache. I manually removed var folder
This is full security.yaml
https://pastebin.com/CRQK7u6e
closed: /api-login
closed: /api-login?username=value@val.com
open: /api-login?username=value@val.com&password=pa$$w0rd //I see JSON answer "true"

Reply

Hey triemli!

Ok, I think I know what's going on - it's a "flow" that I had not thought about before :). Here is what (I think) happens:

1) When you access /api-login (via any of the examples you gave above), then the one access_control IS activated. At that moment, you are not authenticated yet (even if you send valid authentication, Symfony hasn't checked it yet - it's "lazy" about doing that). But, before denying you access, it tries to see if you *should* be authenticated by calling your authenticator.

2) In the 3rd case above, your authentication IS successful and you are now authenticated.

3) Finally, the access_control (now that it has at least attempted the authentication system) enforces the ROLE_ADMIN. In the first 2 cases above, you are still anonymous, so you are kicked out. In the 3rd case (I'm assuming the value@val.com user has ROLE_ADMIN) you are allowed access.

So, I think it makes sense :). The "normal" rule is that your login page MUST be accessible anonymously (and this is what I was thinking too), but that is NOT the case in an API situation where you can directly attach your credentials to that very same request. Your login page only needs to be anonymously accessible in a traditional form login where an anonymous user literally needs to be able to "load" /login anonymously before submitting the form.

tl;dr this makes sense - but I was thinking that it didn't. My mistake! Keep up the good work :).

Cheers!

1 Reply
triemli Avatar

Thanks for the answer! Yeah actually make sense then, but a small thing here: this is user *value@val.com* has a ROLE_USER role, he's not an admin ;] In this case then I would expect auth process and deny.

Reply

Hey triemli!

Hmm. Ok, yea that (I think) still makes sense. The full process looks like this:

A) The access_control matches. In order to see if the user has access, the security system is started
B) The security system successfully authenticates the user
C) The security system determines the "authentication success JSON" to send back
D) That is returned... and we're done! The original access_control stuff never has a chance to be enforced

I'm only 98% sure this is what's happening... but it makes sense. The /api-login page is never *really* accessed - it starts to be accessed, but is intercepted by the security system and never continues. But, I admit, it's not a flow I had thought of before - I would normally not cover the "login" URL with an access_control (but it is interesting!).

Cheers!

1 Reply

Hi,

When I create my CustomAuthenticator following your example, I get this error-message:

<blockquote>The "App\Security\CustomAuthenticator::getUser()" method must return a UserInterface. You returned "App\Entity\IvyUser".</blockquote>

My getUser method looks like this:

<br />public function getUser($credentials, UserProviderInterface $userProvider)<br />{<br />return $this->ivyUserRepository->findOneBy(['id' => $credentials['id']]);<br />}<br />

Any idea what I'm doing wrong?

Reply

Sorry for asking, found the solution myself.

Reply

Hey andremoens

That's great! Would you like to share with us your solution?

Cheers!

Reply
Default user avatar

And how I can impliment Google Authenticator?

Reply
Cat in space

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.8.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.0.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.0
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.1.4
        "symfony/console": "^4.0", // v4.1.4
        "symfony/flex": "^1.0", // v1.17.6
        "symfony/framework-bundle": "^4.0", // v4.1.4
        "symfony/lts": "^4@dev", // dev-master
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.1.4
        "symfony/serializer-pack": "^1.0", // v1.0.1
        "symfony/twig-bundle": "^4.0", // v4.1.4
        "symfony/web-server-bundle": "^4.0", // v4.1.4
        "symfony/yaml": "^4.0", // v4.1.4
        "twig/extensions": "^1.5" // v1.5.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.0.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.1.4
        "symfony/dotenv": "^4.0", // v4.1.4
        "symfony/maker-bundle": "^1.0", // v1.7.0
        "symfony/monolog-bundle": "^3.0", // v3.3.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.1.4
        "symfony/profiler-pack": "^1.0", // v1.0.3
        "symfony/var-dumper": "^3.3|^4.0" // v4.1.4
    }
}
userVoice