markitosgv / JWTRefreshTokenBundle

Implements a Refresh Token system over Json Web Tokens in Symfony
MIT License
654 stars 159 forks source link

Password becomes null after JWT login (MongoDB) #237

Closed ilyavlasoff closed 3 years ago

ilyavlasoff commented 3 years ago

Hi! I'm using MongoDB as database for storing Users and I've faced a problem while using JWTRefreshTokenBundle. When new a account is created and API client tries to get JWT token and refresh token using his credentials, the password field in database becomes null. When I use LexikJWTAuthenticationBundle without JWTRefreshTokenBundle the problem does not appear.

Here is example:

// create a user and persist it to mongo in register method
        $user = new User();
        $user->setEmail($userIdentifier->getUsername());
        $user->setPassword($passwordEncoder->encodePassword($user, 'test_password'));
        $user->setDbOid('test_oid');
        $user->setRoles(['ROLE_IDENTIFIED']);
        $documentManager->persist($user);
        $documentManager->flush();

After calling /api/v1/reg a new document is created in database, client successfully receives JWT and refresh token.

> db.usr.find()
{ "_id" : 1, "email" : "test@test.com", "password" : "$2y$13$0rtUetPBv43O0pY2f8vtu.zCOiKhMAi9Cmz7Lw3lTIY/F.QSJpVn6", "dbOid" : "test_oid", "roles" : [ "ROLE_IDENTIFIED" ] }

While calling /api/v1/auth, it seems that everything is fine: client receives generated JWT and refresh token, but after this password field in this user document becomes null. Request /api/v1/auth { "username": "test@test.com", "password": "test_password" } Response: {"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE2MTY2MDcxMzcsImV4cCI6MTYxNjYxMDczNywicm9sZXMiOlsiUk9MRV9JREVOVElGSUVEIl0sInVzZXJuYW1lIjoidGVzdEB0ZXN0LmNvbSJ9.PW77WTNX9lQz6AK50hKWCibe0CezcvKB2kpg5RAtQncsoUAvNTnJyu2I_Fz_83anqBL7cjEvHvOps2D_HksyV2YR5vo-7hVoMRUHl6xFGcUXYrQInJtygXn8a3S5-2NfGe-Ry6AfdYajszcUghxTLLiz9gKmfpHvN0MWQUlH0d6VQqxC3bp3VOiW7z-JEtI-Dqob6NUe0zwFxum0CCDrEInQ2vsMe6QJvmrNVBaoEts2yPZFVUDKEjER5c6IROP7cbNbEt-8e3G78B1kGUktk-GgBmC9c6zamtuA9lFRGfGai84IIvp2pyRokLXx7ayp8qlCCbvkI77Xl0U-AE8gVA","refresh_token":"7e5d45de0a73f94cefd9e9de4e830e2191269f49f6fb61beda89109f711921202158cd22dcc8fa396155bc739d341101fa86d5373f3124daf74caa4fe6c642ed"} MongoDB:

> db.usr.find()
{ "_id" : 1, "email" : "test@test.com", "password" : null, "dbOid" : "test_oid", "roles" : [ "ROLE_IDENTIFIED" ] }

So the next login attempt fails. {"code":401,"message":"Invalid credentials."}

My configuration: security.yaml

security:
    encoders:
        App\Document\User:
            algorithm: bcrypt

    providers:
        app_user_provider:
            id: App\Security\UserAuthenticationProvider

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        auth:
            pattern: ^/api/v1/auth
            anonymous: true
            stateless: true
            provider: app_user_provider
            json_login:
                check_path: /api/v1/auth
                login_path: email
                password_path: password
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure
        identify:
            pattern: ^/api/v1/identify
            anonymous: true
            stateless: true
        token_refresh:
            pattern: ^/api/v1/token/refresh
            anonymous: true
            stateless: true
        docs:
            pattern: ^/api/v1/docs
            anonymous: true
        api:
            pattern: ^/api/v1/
            stateless: true
            anonymous: false
            provider: app_user_provider
            guard:
                authenticators:
                    - lexik_jwt_authentication.jwt_token_authenticator
    access_control:
         - { path: ^/api/v1/(auth|identify|refresh|docs), roles: IS_AUTHENTICATED_ANONYMOUSLY }
         - { path: ^/api/v1/token/refresh, roles: IS_AUTHENTICATED_ANONYMOUSLY }
         - { path: ^/api/v1/reg, roles: ROLE_IDENTIFIED }
         - { path: ^/api/v1/, roles: ROLE_STUDENT }

gesdinet_jwt_refresh_token.yaml

gesdinet_jwt_refresh_token:
  manager_type: mongodb
  user_identity_field: email
  single_use: false
  firewall: token_refresh

debug:config gesdinet_jwt_refresh_token

gesdinet_jwt_refresh_token:
    manager_type: mongodb
    user_identity_field: email
    single_use: false
    firewall: token_refresh
    ttl: 2592000
    ttl_update: false
    user_provider: null
    refresh_token_class: null
    object_manager: null
    user_checker: security.user_checker
    refresh_token_entity: null
    entity_manager: null
    token_parameter_name: refresh_token
    doctrine_mappings: true

User is pretty standard:

<?php

namespace App\Document;

use Symfony\Component\Security\Core\User\UserInterface;
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;

/**
 * Class User
 * @package App\Document
 * @ODM\Document(collection="usr")
 */
class User implements UserInterface
{
    /**
     * @ODM\Id(strategy="INCREMENT")
     */
    private $id;

    /**
     * @ODM\Field(type="string", nullable=false)
     *
     */
    private $email;

    /**
     * @ODM\Field(type="string", nullable=true)
     */
    private $password;

    /**
     * @ODM\Field(type="string", nullable=false)
     *
     */
    private $dbOid;

    /**
     * @ODM\Field(type="collection")
     */
    private $roles;

    /**
     * @inheritDoc
     */
    public function getRoles()
    {
        return array_unique($this->roles);
    }

    /**
     * @inheritDoc
     */
    public function getPassword()
    {
        return (string)$this->password;
    }

    /**
     * @inheritDoc
     */
    public function getSalt()
    {
    }

    /**
     * @inheritDoc
     */
    public function getUsername()
    {
        return (string)$this->email;
    }

    /**
     * @inheritDoc
     */
    public function eraseCredentials()
    {
        $this->password = null;
    }

    /**
     * @param mixed $id
     */
    public function setId($id): void
    {
        $this->id = $id;
    }

    /**
     * @param mixed $email
     */
    public function setEmail($email): void
    {
        $this->email = $email;
    }

    /**
     * @param mixed $password
     */
    public function setPassword($password): void
    {
        $this->password = $password;
    }

    /**
     * @param mixed $dbOid
     */
    public function setDbOid($dbOid): void
    {
        $this->dbOid = $dbOid;
    }

    /**
     * @param mixed $roles
     */
    public function setRoles($roles): void
    {
        $this->roles = $roles;
    }

    /**
     * @return mixed
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @return mixed
     */
    public function getEmail()
    {
        return $this->email;
    }

}

composer.json

{
    "type": "project",
    "license": "proprietary",
    "minimum-stability": "dev",
    "prefer-stable": true,
    "require": {
        "php": ">=7.2.5",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "ext-mysql_xdevapi": "*",
        "composer/package-versions-deprecated": "1.11.99.1",
        "doctrine/annotations": "^1.12",
        "doctrine/doctrine-bundle": "^2.3",
        "doctrine/doctrine-migrations-bundle": "^3.0",
        "doctrine/mongodb-odm-bundle": "^4.3",
        "doctrine/orm": "^2.8",
        "gesdinet/jwt-refresh-token-bundle": "^0.11.1",
        "jms/serializer-bundle": "^3.9",
        "lexik/jwt-authentication-bundle": "^2.11",
        "symfony/console": "5.2.*",
        "symfony/dotenv": "5.2.*",
        "symfony/flex": "^1.3.1",
        "symfony/framework-bundle": "5.2.*",
        "symfony/http-client": "5.2.*",
        "symfony/proxy-manager-bridge": "5.2.*",
        "symfony/validator": "5.2.*",
        "symfony/yaml": "5.2.*",
        "zircote/swagger-php": "^3.1"
    },
    "config": {
        "optimize-autoloader": true,
        "preferred-install": {
            "*": "dist"
        },
        "sort-packages": true
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "App\\Tests\\": "tests/"
        }
    },
    "replace": {
        "symfony/polyfill-ctype": "*",
        "symfony/polyfill-iconv": "*",
        "symfony/polyfill-php72": "*"
    },
    "scripts": {
        "auto-scripts": {
            "cache:clear": "symfony-cmd",
            "assets:install %PUBLIC_DIR%": "symfony-cmd"
        },
        "post-install-cmd": [
            "@auto-scripts"
        ],
        "post-update-cmd": [
            "@auto-scripts"
        ]
    },
    "conflict": {
        "symfony/symfony": "*"
    },
    "extra": {
        "symfony": {
            "allow-contrib": false,
            "require": "5.2.*"
        }
    },
    "require-dev": {
        "symfony/maker-bundle": "^1.30"
    }
}
bjoern-hempel commented 2 years ago

I am not aware of the consequences. Just remove the line $this->password = null; from the method User::eraseCredentials and it works.

bjoern-hempel commented 2 years ago

I had the same problem. ;)

alex3493 commented 4 months ago

I was debugging the same issue when using refresh token Doctrine entity with MySQL. As for Symfony 7, when AuthenticationManager (vendor/symfony/security-http/Authentication/AuthenticatorManager.php:204) erases user credentials (default behavior), user entity is marked as modified in Doctrine UnitOfWork.

Later, when refresh token is persisted, flush() is also called.

    public function save(RefreshTokenInterface $refreshToken, $andFlush = true)
    {
        $this->objectManager->persist($refreshToken);

        if ($andFlush) {
            $this->objectManager->flush();
        }
    }

Flush checks for all UOW entity updates, so modified user entity (with NULL password) is also persisted.

I am not aware of the consequences. Just remove the line $this->password = null; from the method User::eraseCredentials and it works.

We can also add a parameter to services.yaml:

parameters:
    ...
    security.authentication.manager.erase_credentials: false

so User::eraseCredentials will not be called.

However, this is not the best way to go IMHO. Keeping sensitive data in user object after authentication is complete exposes it to the rest of request cycle code and user password may leak to a response (e.g.) if there is an error at a later time.

As far as I see, all this stuff happens in package code. We know that user entity should never change when a refresh token is generated and persisted.

Can we do something to detach user from UOW right before flushing refresh token update? What side effects it may cause?

Another option might be to refresh (\Doctrine\ORM\EntityManager::refresh) user before we persist refresh token, so it might be excluded from flush flow.