Skip to content

Automatic logout after changing password on another session #19033

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
wants to merge 1 commit into from
Closed

Automatic logout after changing password on another session #19033

wants to merge 1 commit into from

Conversation

xaben
Copy link

@xaben xaben commented Jun 11, 2016

Q A
Branch? 3.1
Bug fix? yes
New feature? no
BC breaks? no
Deprecations? no
Tests pass? yes
Fixed tickets #17023
License MIT
Doc PR none

Although this PR does not break BC, the change in AbstractToken that relates to setUser functionality is highly volatile and should be thoughtfully inspected for possible regressions.

The flow that causes this bug:

  1. ContextListener L161 sets the refreshed User in the token (while checking and marking the token as not authenticated AbstractToken L112). This as a consequence leads to the fact that the user data from the unserialized session data is lost and cannot be used anymore later on.
  2. After Firewall listener trigger, DaoAuthenticationProvider is invoked. It fetches the user from the token (the already refreshed user) and runs the checks UserAuthenticationProvider 85-87.
  3. The checkPreAuth L85 and checkPostAuth L87 are ok as they use the most recent version of the user (refreshed user). checkAuthentication L86 on the other hand is intended for comparing the old user (from the token) and the new user (the refreshed one), but as the user was overwritten on step 1, this check always returns true (DaoAuthenticationProvider checkAuthentication method).

As a result the user remains logged in although the serialized data from the session he has, contains an old password. After first refresh of the page, his session data is overwritten with fresh data from the refreshed user.

@linaori
Copy link
Contributor

linaori commented Jun 12, 2016

You probably have to rebase, you have a bunch of commits in here that are not related to your PR

@xaben
Copy link
Author

xaben commented Jun 13, 2016

Yes sorry rebased it now onto fresh master, the patch was based on current 3.1 branch.

@@ -56,7 +57,7 @@ protected function checkAuthentication(UserInterface $user, UsernamePasswordToke
$currentUser = $token->getUser();
if ($currentUser instanceof UserInterface) {
if ($currentUser->getPassword() !== $user->getPassword()) {
throw new BadCredentialsException('The credentials were changed from another session.');
throw new AccountPasswordChangedException('The credentials were changed from another session.');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the new Exception extends a different base Exception (account status instead of bad credentials), this is a pretty big BC break

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exception should extend this other type in order to avoid a redirect loop and unset the wrong(old) token from the session.

Both exceptions are correctly handled, checkPreAuth and checkPostAuth in DaoAuthenticationProvider both throw exceptions that extend this base exception class.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DaoAuthenticationProvider can be extended or used elsewhere, thus can break user-land implementations 😢

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we remove the token on the BadCredentialsException as well? This change will be done on ExceptionListener.php L197 ??

@xaben
Copy link
Author

xaben commented Jun 16, 2016

@iltar I've changed the PR and removed the custom exception. Instead of it, I added code that removes the token for the case when there is a BadCredentialsException in order to prevent a infinite loop.

$this->tokenStorage->setToken(null);

if (null !== $this->logger) {
$this->logger->info('The security token was removed due to an BadCredentialsException.', array('exception' => $authException));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a BadCrendentialsException.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fabpot fixed

}

$this->user = $user;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this break applications that rely on the current behaviour?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well as I stated at the top of my initial comment, this is the point that is actually the most problematic. And I need input frim somebody that knows really well the security component and it's usage.

This setter as it stands does too much on it's own, and should be refactored. Most likely separating the logic for marking a token as not authenticated and doing a check elsewhere before setting the token.

The logout fix will not work if we overwrite the user in the token with the refreshed user.

@nicolas-grekas nicolas-grekas modified the milestones: 3.1, 3.x Dec 6, 2016
@ThePeterMick
Copy link

Should / could this be optional and driven by a security.yml config that defaults to false?

@vlechemin
Copy link

Any update on this? It's an important issue as changing a password to log out any other session is a very common use case and should be the default behaviour on any website.
I think this PR got the two key points right. The only small issue is that the user will be loaded from DB twice but I guess it's acceptable given it's not the general use case.
The other solution would be not to trust an unauthenticated token from the AuthenticationProvider? What would be the purpose of this flag?
Thank you

@eXtreme
Copy link
Contributor

eXtreme commented Jun 13, 2017

So after some debugging of mentioned "incorrect" behavior (not being logged out on other sessions after changing the password) I came to the same conclusions: isAuthenticated is being correctly set to false after hasUserChanged notices changed password (it starts from ContextListener). But then AccessListener comes into play and reauthenticates the token.
This is clearly not the way it should work and not the way it is mentioned in the docs.

However, I have not yet tested the patch here.

@gharlan
Copy link
Contributor

gharlan commented Jul 18, 2017

Any update here?

@linaori
Copy link
Contributor

linaori commented Jul 18, 2017

I don't think this PR should be accepted, imo the flow is working as intended. The current behavior is to log the user out whenever anything changes. This is correct because the user is not the same anymore. If you want to divert from this behavior, implement the EquatableInterface and customize behavior.

@eXtreme
Copy link
Contributor

eXtreme commented Jul 18, 2017

@iltar have you seen my comment above? It should log out whenever anything (or, like, a password) changes but it does not.

@gharlan
Copy link
Contributor

gharlan commented Jul 18, 2017

imo the flow is working as intended. The current behavior is to log the user out whenever anything changes.

No, that is not the current behavior. But I would expect this, too.

See the comment of @eXtreme.

@linaori
Copy link
Contributor

linaori commented Jul 18, 2017

@eXtreme I've been digging in the security component quite often last weeks and it works exactly as I just described, with the difference that I have the EquatableInterface which returns false only when the ID changes. This behavior works for me. Perhaps you can reproduce it in a fork?

@eXtreme
Copy link
Contributor

eXtreme commented Jul 18, 2017

@iltar ok I will try later today or tomorrow. The app runs on 2.8 so I will use the same version.

@ThePeterMick
Copy link

@iltar have you followed the entire trail of this back to the original issue report? As others mentioned, this is not the current behaviour. #17023 - might be it also works for ID (can you share your fork), same as for enabled field, but doesn't for other properties.

Security is such an important aspect and shouldn't be ignored - imo this should be picked up by the core team as a high priority (it has been almost 2 years already)

@gharlan
Copy link
Contributor

gharlan commented Jul 18, 2017

@iltar you can use this branch: https://github.com/gharlan/symfony-demo/tree/issue-19033

Go to backend, login, click on the "CHANGE PASSWORD" link.

@linaori
Copy link
Contributor

linaori commented Jul 18, 2017

I don't have a proper environment to run or test stuff at the moment. Digging into the code, this part looks like the problem to me: https://github.com/symfony/symfony/blob/2.8/src/Symfony/Component/Security/Core/Authentication/Provider/UserAuthenticationProvider.php#L70-L86
I also found that the refreshUser is never called upon, which I think is normal to use in the other authenticators.

This part in the authenticator, is what causes the problem imo. There's no distinction between the current and actual user, because it always fetches the current user from the current token:
https://github.com/symfony/symfony/blob/2.8/src/Symfony/Component/Security/Core/Authentication/Provider/DaoAuthenticationProvider.php#L77-L80

What should happen imo, is a refreshUser() call at that position and return that one. As this behavior is working fine for the Guard implementations for example, I recommend the AbstractToken stays the way it is. If anything needs fixing, it's the Dao/User authenticators.

@eXtreme
Copy link
Contributor

eXtreme commented Jul 18, 2017

@iltar
my attempt is here:
https://github.com/eXtreme/symfony-demo/tree/issue-19033
eXtreme/symfony-demo@9f22a02
using a form and a controller etc :P but on 2.8 as I mentioned above, but I guess it's the same on latest branches

simple reproduce:

  1. log in as the same user on different browsers
  2. in one browser, go to http://localhost:8000/en/profile/change-password
  3. change a password to a different one
  4. refresh the page on the second browser

I have not managed to reproduce the same behavior I debugged in my real app. BUT. Changing the passwords sets authenticated to false on the second browser. Which... logically makes sense, well, you are not authenticated but still logged in (?). So checking IS_AUTHENTICATED_FULLY should fail right? You can guard your critical app parts by this check and prevent abuse, right? Nope. After adding

if (!$this->get('security.authorization_checker')->isGranted('IS_AUTHENTICATED_FULLY')) {
    throw $this->createAccessDeniedException();
}

to Admin\BlogController::indexAction and refreshing the page on the second browser.. you are being reauthenticated. The same thing happens using, for example @Security("has_role('ROLE_ADMIN') and is_granted('IS_AUTHENTICATED_FULLY')"). So with these checks for "IS_AUTHENTICATED_FULLY" and changing the password on the first browser and then refreshing the page on the second browser you are reauthenticated. I've put a breakpoint on AbstractToken::setAuthenticated and on the second browser there is a call to set it to false and then a call to set it true. The first one is called by ContextListener::refreshUser, the second one by UserAuthenticationProvider.

In my real app I mentioned in the comment above it was AccessListener which reset the token to authenticated. But maybe it is because I use JMS SecurityExtraBundle? Not sure.

@linaori
Copy link
Contributor

linaori commented Jul 18, 2017

There's a flag that might alter behavior of the the access decision manager, could that change this particular behavior?

@eXtreme
Copy link
Contributor

eXtreme commented Jul 18, 2017

@iltar I'm out of office so I will test my fork tomorrow.

@eXtreme
Copy link
Contributor

eXtreme commented Jul 19, 2017

@iltar consensus or unanimous, it does not change anything in this case.

@linaori
Copy link
Contributor

linaori commented Jul 19, 2017

@eXtreme I mean this line of code, by default alwaysAuthenticate is false, but I have a feeling this is set to true for you: https://github.com/symfony/symfony/blob/master/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php#L60

@eXtreme
Copy link
Contributor

eXtreme commented Jul 19, 2017

@iltar it's symfony-demo, so it should be default false

@gharlan
Copy link
Contributor

gharlan commented Jul 19, 2017

@iltar I checked this. It is false, at least in my demo branch.

@linaori
Copy link
Contributor

linaori commented Jul 19, 2017

So the issue is really just within the DaoAuthenticator then. What if you would remove just those lines, would that fix the issue as well?

https://github.com/symfony/symfony/blob/2.8/src/Symfony/Component/Security/Core/Authentication/Provider/DaoAuthenticationProvider.php#L77-L80

I see no reason to not always fetch the new data tbh.

@eXtreme
Copy link
Contributor

eXtreme commented Jul 19, 2017

@iltar still no luck.

btw. we can move to Slack, not to spam here

@linaori
Copy link
Contributor

linaori commented Jul 19, 2017

So after some fiddling around with @eXtreme on slack, we have found out the following:

  • AuthenticationManagerInterface have to manually implement logic to verify if the token can be authenticated. Only Guard has done this right, because it splits the token in pre and post.

  • When a token is deauthenticated because the user has changed, it will trigger a call in the AuthorizationChecker::isGranted() method. This will happily reauthenticate the user in the same request as it was just determined that the user had been changed.

        if ($this->alwaysAuthenticate || !$token->isAuthenticated()) {
            $this->tokenStorage->setToken($token = $this->authenticationManager->authenticate($token));
        }
  • The UserAuthenticationProvider uses a UsernamePasswordToken, which is unable to detect whether or not the user has been deauthenticated when going outside of the scope of the setUser method.

Possible solution

In theory a check could be added to the ContextListener which triggers an authentication exception. There's a new exception for this in 2.8 but not in 2.7 (where this problem also exists). There's 1 flaw in the following patch, it doesn't show the users why they have been logged out.

diff --git a/src/Symfony/Component/Security/Http/Firewall/ContextListener.php b/src/Symfony/Component/Security/Http/Firewall/ContextListener.php
index b443fa1..42b950d 100644
--- a/src/Symfony/Component/Security/Http/Firewall/ContextListener.php
+++ b/src/Symfony/Component/Security/Http/Firewall/ContextListener.php
@@ -157,6 +157,12 @@ class ContextListener implements ListenerInterface
                 $refreshedUser = $provider->refreshUser($user);
                 $token->setUser($refreshedUser);

+                // tokens can be deauthenticated if the user has been changed.
+                if (!$token->isAuthenticated()) {
+                    $this->logger->debug('Token was deauthenticated after trying to refresh it.', array('username' => $refreshedUser->getUsername(), 'provider' => get_class($provider)));
+                    return null;
+                }
+
                 if (null !== $this->logger) {
                     $this->logger->debug('User was reloaded from a user provider.', array('username' => $refreshedUser->getUsername(), 'provider' => get_class($provider)));
                 }

This is how Guard solved this. The return null should probably become an exception or something in this patch. @weaverryan perhaps you can confirm this?

@weaverryan
Copy link
Member

@iltar haha, yes, someone finally noticed this in Guard :). I noticed this problem when making Guard, and so I made sure to make Guard not suffer from this. But, it's a workaround in Guard for sure.

About your patch, initially, I like it, but I'd need to review this again in more detail - I can't remember all the moving pieces. In the Guard provider, I throw an instance of AccountStatusException because this triggers a "logout" in the ExceptionListener:

if ($authException instanceof AccountStatusException) {
. I had to do this, because in that method, I don't have access to the token storage... so I couldn't nullify the token.

But in ContextListener, we should just be able to nullify the token in handle(). Perhaps:

        if ($token instanceof TokenInterface) {
            $token = $this->refreshUser($token);
        } elseif (null !== $token) {
            if (null !== $this->logger) {
                $this->logger->warning('Expected a security token from the session, got something else.', array('key' => $this->sessionKey, 'received' => $token));
            }
            $token = null;
        }

       // -> right HERE, check if the token is authenticated, and if not, $token = null.
       
        $this->tokenStorage->setToken($token);

Hopefully that helps... a bit :)

@timwsuqld
Copy link

Hopefully someone on this PR can answer this clearly. From my investigations, refreshUser isn't being called, and so ANY field changing in a user entity, causes the session to be forced to log back in. But everywhere I'm reading, I'm seeing conflicting information about it should refresh the user, vs logout the user.
Is this PR fixing it so it correctly reloads the user when a change occurs, or is it also logging it out? I'm trying to use Guards, @weaverryan do I understand correctly that Guards force a logout?

@eXtreme
Copy link
Contributor

eXtreme commented Aug 13, 2017

So? Is anyone interested in picking this one up?

@weaverryan
Copy link
Member

I would love if someone could open a PR for this. I don't think this PR is the correct way - it would only fix it for some parts of the system. I think the solution proposed by @iltar and I is a good approach to try: (#19033 (comment) and comment above).

Anyone want to open a PR?

@linaori
Copy link
Contributor

linaori commented Aug 14, 2017

@weaverryan I can try, but I'm a bit concerned about the change as it does 2 things:

  • It changes behavior that might break the majority of apps, by logging out users (I can't predict this)
  • It fixes a long standing bug

First point indicates 3.4, second point indicates 2.7, which one should I open it against?

@fabpot
Copy link
Member

fabpot commented Aug 14, 2017

I would definitely do this change in 3.4.

@chalasr
Copy link
Member

chalasr commented Sep 20, 2017

Closing in favor of #23882.

@chalasr chalasr closed this Sep 20, 2017
chalasr pushed a commit that referenced this pull request Sep 26, 2017
…change (iltar)

This PR was squashed before being merged into the 3.4 branch (closes #23882).

Discussion
----------

[Security] Deprecated not being logged out after user change

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

This PR is an alternative approach to #19033. Due to a behavioral change that could break a lot of applications and websites, I've decided to trigger a deprecation instead of actually changing the behavior as that can be done for 4.0.

Whenever a user object is considered changed (`AbstractToken::hasUserChanged`) when setting a new user object after refreshing, it will now throw a deprecation, paving the way for a behavioral change in 4.0. The idea is that in 4.0 Symfony will simply trigger a logout when this case is encountered.

Commits
-------

22f525b [Security] Deprecated not being logged out after user change
@xaben xaben deleted the fixLogoutAfterPasswordChange branch June 19, 2018 07:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.