dreamfactorysoftware / df-core

The DreamFactory Core services and resources.
Apache License 2.0
14 stars 32 forks source link

JWT Token Invalidated Without Updating Map #31

Open zerox1212 opened 6 years ago

zerox1212 commented 6 years ago

I'm trying to solve this problem detailed in the below forum posts: http://community.dreamfactory.com/t/forgot-password-api/1236/6 http://community.dreamfactory.com/t/password-reset-not-working-after-user-logins/3180 http://community.dreamfactory.com/t/unable-to-change-or-reset-password/4100/

After looking through much of the code, I have found what I believe to be the issue.

Sometimes JWTUtilities::invalidate($token); is called to change a token to blacklisted. This function seems to also remove the token from the map.

Othertimes JWTAuth::invalidate(); is called directly:

https://github.com/dreamfactorysoftware/df-core/blob/db355e08a6eb976d6a93caa76f689af04b8ae392/src/Utility/JWTUtilities.php#L177

    public static function invalidateTokenByUserId($userId)
    {
        DB::table('token_map')->where('user_id', $userId)->get()->each(function ($map){
            try {
                JWTAuth::setToken($map->token);
                JWTAuth::invalidate();
            } catch (TokenExpiredException $e) {
                //If the token is expired already then do nothing here.
            }
        });
        return DB::table('token_map')->where('user_id', $userId)->delete();
    }

I think part of the password reset + blacklisted token issue stems from this. Especially when the error stems from here: https://github.com/dreamfactorysoftware/df-core/blob/db355e08a6eb976d6a93caa76f689af04b8ae392/src/Resources/UserPasswordResource.php#L406

Because invalidateTokenByUserId will get called via the user model at User.setPasswordAttribute($password).

public function setPasswordAttribute($password)
    {
        if (!empty($password)) {
            $password = bcrypt($password);
            JWTUtilities::invalidateTokenByUserId($this->id);
            // When password is set user account must be confirmed. Confirming user
            // account here with confirm_code = null.
            // confirm_code = 'y' indicates cases where account is confirmed by user
            // using confirmation email.
            if (isset($this->attributes['confirm_code']) && $this->attributes['confirm_code'] !== 'y') {
                $this->attributes['confirm_code'] = null;
            }
        }
        $this->attributes['password'] = $password;
    }
df-arif commented 6 years ago

@zerox1212 , I tried to reproduce this issue but failed. Can you show me steps needed to reproduce this? Also where did you see that JWTAuth::invalidate() is called directly?

zerox1212 commented 6 years ago

The below function can be called by setPasswordAttribute($password) and it won't update the map as far as I can tell. This could be why some users report Password Reset only works the first time.

https://github.com/dreamfactorysoftware/df-core/blob/db355e08a6eb976d6a93caa76f689af04b8ae392/src/Utility/JWTUtilities.php#L177

df-arif commented 6 years ago

It removes the token mapping for the user at the last line of the method - invalidateTokenByUserId return DB::table('token_map')->where('user_id', $userId)->delete();

Also I can reset a user password multiple times without running into any issue. Which version of DreamFactory you are running?

zerox1212 commented 6 years ago

For your test you should do this with forever sessions enabled.

  1. Login as normal (authenticate)
  2. Do a PUT to the user/session endpoint to refresh the token (this will blacklist your original token and create a new one)
  3. Try to reset the password after the PUT -> Now password reset fails due to token being blacklisted

This error only happens to people who are using PUT to refresh tokens (in my case forever tokens). I have version 2.3.0.

df-arif commented 6 years ago

I tried several ways to reproduce this but everything seems to be working correctly at my end. One thing I would double check in your case is that after your refresh your token, make sure to use the new token to reset password. In fact, for resetting password you actually don't even have to provide any token. The password reset api (api/v2/user/password) is not protected.

zerox1212 commented 6 years ago

I will do more research into this problem and get back to you. Can you confirm that doing GET to user/session will return a new token? We followed what was posted online about doing GET instead of PUT, but I don't see how DF would send a new token using GET.

zerox1212 commented 6 years ago

So I did more testing with this issue and found out what to is needed to remove the blacklisted error when resetting a password. You must Flush System-wide Cache. Then you can reset a user password without getting a token error. Any ideas where to look based on that info?

I think the real problem might be that even though the token is removed from the map, the old token is still in the cache. @df-arif

df-arif commented 6 years ago

@zerox1212 , What you are seeing with flushing system-wide cache is an expected behavior. A token is blacklisted by adding it to a list of blacklisted token in cache. When you clear system-wide cache (this is typically done by system admins) it clears the blacklisted tokens in cache and therefore you don't get the error again. Eventually when this token is expired it can never be reused or be refreshed regardless of whether it is blacklisted or not.

But I believe your issue is not related to this blacklisted token cache. You should not get the blacklisted token error in the first place when you are resetting password. When you reset the password just make sure not to provide any token (JWT) as part of that API call. As I mentioned earlier the API (api/v2/user/password) for resetting password is open and doesn't require a token. I am thinking that somehow when you are making the call to api/v2/user/password to reset your password, you are providing the old token with it which is blacklisted as you already refreshed it. Here are the APIs that are commonly used for auth and password reset.

POST api/v2/user/session : Used for authentication. Can be called without a JWT (Open API). This will authenticate the user, create a session and return the JWT.

GET api/v2/user/session : Used for retrieving current user session. Requires passing the JWT for the user session you are retrieving. IT DOESN'T CREATE A NEW SESSION OR JWT. IT VERIFIES THE JWT PASSED THAT YOU PASSED IN AND RETURNS THE ASSOCIATED USER INFORMATION FOR THAT SESSION.

PUT api/v2/user/session : Used for refreshing a JWT that is still inside the refresh TTL window. Requires passing the JWT that is being refreshed. Once a JWT is refreshed it is added to the blacklist.

POST api/v2/user/password: Used for resetting password. Can be (and should be) called without a JWT. When you are resetting a password it is expected that you currently don't have an active session therefore no JWT is required to pass.

If you are still getting the blacklisted error after calling the password reset API without passing any JWT then please provide the full error with the stack trace. Also I would recommend upgrading to the latest version of DF (version 2.9.0 as of 10/17/2017). The latest version has lots of improvements and bug fixes.

zerox1212 commented 6 years ago

I know I'm not sending a token. I will try to get a stack trace. Thanks for the detailed response.