8000 [Security] ContextListener misbehavior with multiple UserProviders · Issue #4498 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[Security] ContextListener misbehavior with multiple UserProviders #4498

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
zeqfreed opened this issue Jun 5, 2012 · 28 comments
Closed

[Security] ContextListener misbehavior with multiple UserProviders #4498

zeqfreed opened this issue Jun 5, 2012 · 28 comments

Comments

@zeqfreed
Copy link
zeqfreed commented Jun 5, 2012

I'm implementing an application that needs to have separate sets of users for its different sections. For instance, we create Admin and Front sections. For each of the sections I define a firewall rule in security config and a different user provider:

providers:
    provider_admin:
        id: acme.shmundle.my_user_provider.admin

    provider_front:
        id: acme.shmundle.my_user_provider.front

firewalls:
    admin:
         provider: provider_admin
         ....

    front:
         provider: provider_front
         ....

Now, the provider ids reference two different services of the same class that implements UserProviderInterface configured to use different user storages, but same user class.

This setup leads to a problem in ContextListener. After user logs in, the ContextListener attempts to validate user token and refresh user by iterating over every user provider and calling its refreshUser method. If current user provider does not support the user class it throws UnsupportedUserException and ContextListener continues to the next user provider, but if it does and can't actually find the user it throws UsernameNotFoundException which interrupts the iteration and returns null essentially forcing the user to log out.

One solution that would work here is to make sure a different user class used for every user provider, so the iteration always reaches the provider that can refresh user token. But this solution seems quirky, non-obvious and moreover is a kind of side effect depending on the internal implementation of ContextListener.

Another solution is obviously to implement my own ContextListener that will iterate through every user provider regardless of the exceptions thrown. This is the approach I think I will have to take if you confirm that this behavior is actually intended and my use case doesn't lie within symfony's design philosophy.

Please refer to the ContextListener source for the problematic code.

@kix
Copy link
Contributor
kix commented Jun 25, 2012

Perhaps someone should really look into this.

@michelsalib
Copy link

I ran into this issue today too. This might be very problematic.
@schmittjoh is this design intentional ?

@shiroyuki
Copy link
Contributor

FYI to the sport fans, I got this problem even if I use different classes for each provider.

@ghost
Copy link
ghost commented Jul 6, 2012

I have come across this behaviour when using two firewalls (eg. members and admin) with separate in memory user providers for each. When ContextListener refreshes a user after login it checks ALL user providers, not just the ones specific to the firewall, so it tries to load an admin user from the members user provider, fails, and logs out the admin.

Not sure if this design is intentional, but it isn't documented that you can't use separate instances of the same class of provider.

I have tested a quick bit of code which alters the problematic part of the ContextListener but not sure what effect it would have in the grand scheme of things. My new refreshUser method is:

    private function refreshUser(TokenInterface $token)
    {
        $user = $token->getUser();

        if (!$user instanceof UserInterface) {
            return $token;
        }

        if (null !== $this->logger) {
            $this->logger->debug(sprintf('Reloading user from user provider.'));
        }

        foreach ($this->userProviders as $provider) {
            try {
                $token->setUser($provider->refreshUser($user));

                if (null !== $this->logger) {
                    $this->logger->debug(sprintf('Username "%s" was reloaded from user provider.', $user->getUsername()));
                }

                return $token;
            } catch (UnsupportedUserException $unsupported) {
                // let's try the next user provider
            } catch (UsernameNotFoundException $notFound) {
                // user not found, try next provider
            }
        }

        // If we had a user not found exception then we had a valid provider but could not find a user
        if (isset($notFound)) {
            if (null !== $this->logger) {
                $this->logger->warn(sprintf('Username "%s" could not be found.', $user->getUsername()));
            }

            return null;
        }

        throw new \RuntimeException(sprintf('There is no user provider for user "%s".', get_class($user)));
    }

@shiroyuki
Copy link
Contributor

After running some tests, here is my findings.

First of all, the ContextListener is currently designed in the way that assumes the default behaviour like chain providers. This may be right if the chain provider (from security.yml) is to be dropped in SF 2.1.

The implementation of many authentication providers is not well aware of this change and eventually leads this situation unless each provider check if TokenInterface::getProviderKey() is equal to the provider key of AuthenticationProviderInterface.

This makes me think that it might be better to add AuthenticationProviderInterface::compatible which do the check or even better to have an abstract class implementing AuthenticationProviderInterface which at least have the check implementation.

What do you guys think?

@ghost
Copy link
ghost commented Jul 6, 2012

I agree. In fact, is there any reason that all tokens should not have a getProviderKey() method? This way you would know which provider it came from rather than testing them all in sequence and only perform a single call to refreshUser, which would be of benefit if you are using a database or webservice to provide your users.

@ghost
Copy link
ghost commented Jul 7, 2012

Looking at this again it seems I'm getting confused between authentication provider and user provider. The user provider has no knowledge of which authentication provider it came from, particularly as it could potentially be used in multiple firewalls. The context listener has no knowledge of the authentication providers, only the user providers, user providers only accept/reject a user based on its class.

I can see a few solutions:

  1. Introduce a getUserProviderKey into UserInterface so each user instance knows exactly where it came from.
  2. Introduce a user_class option into the user provider definition and document that each instance of a user provider must return a unique class.
  3. Change the refreshUser code in the context listener to check all providers, not just stop at the first user not found exception.

@shiroyuki
Copy link
Contributor

I think @schmittjoh might have the better idea of the behavior.

@asm89
Copy link
Contributor
asm89 commented Jul 7, 2012

Why do you have different user providers for the same user objects? What is the difference between the two?

@zeqfreed
Copy link
Author
zeqfreed commented Jul 8, 2012

The two different user providers access different user storages to have
distinct set of users. But the user class is the same all over the app.

@fabpot
Copy link
Member
fabpot commented Jul 9, 2012

FYI, we used to have a getUserProviderName() method on TokenInterfaceand it was removed here: df6ffbb

@zeqfreed
Copy link
Author

@fabpot, could you confirm if the behavior described in the issue is intentional or not? If it is, could you provide some rationale behind this?

@fabpot
Copy link
Member
fabpot commented Jul 10, 2012

@zeqfreed I need to investigate further the consequences of the current way it works vs the "better" way.

@tcowin
Copy link
tcowin commented Sep 14, 2012

Ran into this myself. This seems like less than ideal behavior. Is there an accepted stop gap solution? What are you guys doing to work around this issue?

@lyrixx
Copy link
Member
lyrixx commented Sep 21, 2012

Hello.

I agree with first @nyorai's solution : "Introduce a getUserProviderKey into UserInterface so each user instance knows exactly where it came from."

Of course, Add this behavior will lead to break the BC, But it will fix this issue.

@tcowin
Copy link
tcowin commented Sep 21, 2012

Thanks @lyrixx. I found that since I was actually using different User classes that corresponded to my different UserProviders, I was able to modify the method "supportsClass" and be precise about which Users that provider is able to support. The checks in FOS UserBundle and the FR3D LDAPBundle are a little broad for my application, but by customizing those and narrowing down to the exact class I wanted each to support, I was able to get the existing setup to work...

@BboyKeen
Copy link

Any news on this one ? I just ran into the same issue today

@MlleDelphine
Copy link
MlleDelphine commented Aug 31, 2016

I am "stuck" with the same behavior.
I have two memory providers.
In my concerned firewall settings, in security.yml, I have precised the provider.
security.yml:

    providers:
        memory_users:
            memory:
                users: "%app.users%"
        backend_users:
            memory:
                users: "%app_backend_users%"
[...]

 backend:
            pattern:    ^/
            host:       "%app_backend_host%"
            provider: backend_users
            anonymous:  ~
            form_login:
            logout:
                path:   /logout
                target: /

Nevertheless, I am unable to login. Stacktrace during ONE try :

[2016-08-31 15:25:46] security.INFO: User has been authenticated successfully. {"username":"admin"} []
[2016-08-31 15:25:46] security.WARNING: Username could not be found in the selected user provider. 

My questions are:

  • Why "provider" in my backend firewall is not taken into account in ContextListener?
  • Why ContextListener does check only the first provider ? Don't understand why a "UsernameNotFoundException" breaks the code while the final response of "refreshUser" method is "There is no user provider for user "%s".', get_class($user)" as if this method has checked ALL providers defined in security.yml while actually, it did not..

I bypass this behavior with "chain_provider" but it does not make sense for me..

@hamanuha
Copy link
hamanuha commented Sep 17, 2016

I have the same problem with the following configuration:

    providers:
        admin_user_provider:
            id: app.security.admin_user_provider
        passwordless_user_provider:
            id: app.security.passwordless_user_provide

[...]

        backend:
            pattern: ^/backend
            provider: admin_user_provider
            anonymous: true
            form_login:
                login_path: _backend_security_login
                check_path: _backend_security_login_check
            logout:
                path: _backend_security_logout
                target: _backend_security_login
                invalidate_session: false

        frontend:
            pattern: ^/(?!backend)
            provider: passwordless_user_provider
            anonymous: true
            simple_preauth:
                authenticator: app.security.passwordless_authenticator
            logout:
                path: _frontend_user_logout
                target: _frontend_project_list
                invalidate_session: false

Both providers use the same class buth only return users with specific roles.
As I understood from the docs, every firewall uses one provider. But my frontend firewall uses the first specified provider and not the one I defined in the firewall.
I solved this temporarily also with a chain_provider. Thanks to @MlleDelphine.

It worked on my local development setup somehow, but on the production site it does not...

@Einenlum
Copy link
Contributor
Einenlum commented Nov 3, 2016

👍

I think the implementation is really strange. Here is the difference between the ContextListener and the ChainUserProvider.

The ContextListener will only loop if the provider throws an UnsupportedUserException:

foreach ($this->userProviders as $provider) {
    try {
        // …
        return $token;
    } catch (UnsupportedUserException $e) {
        // let's try the next user provider
    } catch (UsernameNotFoundException $e) {
        if (null !== $this->logger) {
            $this->logger->warning('Username could not be found in the selected user provider.', array('username' => $e->getUsername(), 'provider' => get_class($provider)));
        }

        return;
    }
}

The ChainUserProvider on the other hand, will try to refresh the user with every provider even if the user was not found :

foreach ($this->providers as $provider) {
    try {
        return $provider->refreshUser($user);
    } catch (UnsupportedUserException $e) {
        // try next one
    } catch (UsernameNotFoundException $e) {
        $supportedUserFound = true;
        // try next one
    }
}

This is not only undocumented, it is also very disturbing. I just discovered this after 2 days trying to understand why my user had the correct token after login but then was again anonymous right after the redirection. We really need to fix this incoherent behaviour.

Seems an old PR from 2014 proposed to solve this bug : #12465

@xabbuh
Copy link
Member
xabbuh commented Feb 27, 2017

This should be fixed by the patch proposed in #21791.

fabpot added a commit that referenced this issue Feb 28, 2017
This PR was merged into the 2.7 branch.

Discussion
----------

[SecurityBundle] only pass relevant user provider

| Q             | A
| ------------- | ---
| Branch?       | 2.7
| Bug fix?      | yes
| New feature?  | no
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | #4498, #12465, #20401, #21737
| License       | MIT
| Doc PR        |

There is no need for the context listener to be aware of all the configured user providers. It must only use the provider for the current firewall (the one identified by the context key passed to the constructor) to refresh the user from the session.

Commits
-------

d97e07f [SecurityBundle] only pass relevant user provider
@fabpot fabpot closed this as completed Feb 28, 2017
@xabbuh
Copy link
Member
xabbuh commented Feb 28, 2017

The fix in #21791 was not right.

@xabbuh
Copy link
Member
xabbuh commented Mar 4, 2017

#21865 is my next attempt to solve this issue. Could you please try it with your projects?

fabpot added a commit that referenced this issue Mar 6, 2017
…ing (xabbuh)

This PR was merged into the 2.7 branch.

Discussion
----------

[Security] context listener: hardening user provider handling

| Q             | A
| ------------- | ---
| Branch?       | 2.7
| Bug fix?      | yes
| New feature?  | no
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | #4498
| License       | MIT
| Doc PR        |

After the wrong fix in #21791 this is the second attempt to solve #4498. If more than one user provider support the user for the current context, all of them will be applied instead of returning prematurely when the first user provider does not find the logged in user.

Commits
-------

0fb0929 context listener: hardening user provider handling
@fabpot fabpot closed this as completed Mar 6, 2017
@omnilight
Copy link

@xabbuh
Copy link
Member
xabbuh commented May 31, 2017

@omnilight Which patch version of Symfony 2.8 do you use?

@omnilight
Copy link

@xabbuh 2.8.16

@xabbuh
Copy link
Member
xabbuh commented May 31, 2017

2.8.19 was the first 2.8 release containing the fix.

@omnilight
Copy link

@xabbuh Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

0