lexik / LexikJWTAuthenticationBundle

JWT authentication for your Symfony API
MIT License
2.53k stars 610 forks source link

Cannot extend JWTAuthenticator and override loadUser method #1027

Open faso-dev opened 2 years ago

faso-dev commented 2 years ago

Hi there!

I use this bundle in my symfony 6 project to authenticate my users by jwt token. Everything is going fine until I want to create a custom authenticator to add some logic in how I authenticate my users. After following the documentation https://github.com/lexik/LexikJWTAuthenticationBundle/blob/2.x/Resources/doc/6-extending-jwt-authenticator.rst, my authenticator is never called and I don't have any error either.

In my services.yaml file

# config/services.yaml
services:
      app.jwt_custom_api_authenticator:
           class: App\Http\Api\Security\Authenticator\JWTCustomApiAuthenticator
           parent: lexik_jwt_authentication.security.jwt_authenticator

In security.yaml file

# config/packages/security.yaml
security:
  enable_authenticator_manager: true
  password_hashers:
    Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
    App\Entity\User:
      algorithm: auto

  providers:
    app_user_provider:
      entity:
        class: App\Entity\User
        property: email
  firewalls:
    dev:
      pattern: ^/(_(profiler|wdt)|css|images|js)/
      security: false

    login:
      pattern: ^/api/auth
      stateless: true
      json_login:
        username_path: email
        check_path: api_login_check
        success_handler: lexik_jwt_authentication.handler.authentication_success
        failure_handler: lexik_jwt_authentication.handler.authentication_failure

      refresh_jwt:
        check_path: api_refresh_token
        provider: app_user_provider

    api:
      pattern: ^/api
      stateless: true
      jwt:
        authenticator: app.jwt_custom_api_authenticator

    main:
      lazy: true
      provider: app_user_provider

In my App\Http\Api\Security\Authenticator\JWTCustomApiAuthenticator file

<?php

namespace App\Http\Api\Security\Authenticator;

use App\Entity\User;
use Lexik\Bundle\JWTAuthenticationBundle\Security\Authenticator\JWTAuthenticator;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;

class JWTCustomApiAuthenticator extends JWTAuthenticator
{
  protected function loadUser(array $payload, string $identity): UserInterface
  {
    /** @var UserInterface|User $user */
    $user  = parent::loadUser($payload, $identity);
    if ($user->isBlocked()){
      $ex = new UserNotFoundException('Your account has been deactivated by the administrators');
      $ex->setUserIdentifier($identity);
      throw $ex;
    }
    return $user;
  }
}

SYMFONY 6 + PHP 8.1

I would like to know how to implement my authenticator logic.

fd6130 commented 2 years ago

Hi, can you post your security.yaml here?

faso-dev commented 2 years ago

Hi, can you post your security.yaml here?

# config/packages/security.yaml
security:
  enable_authenticator_manager: true
  password_hashers:
    Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
    App\Entity\User:
      algorithm: auto

  providers:
    app_user_provider:
      entity:
        class: App\Entity\User
        property: email
  firewalls:
    dev:
      pattern: ^/(_(profiler|wdt)|css|images|js)/
      security: false

    login:
      pattern: ^/api/auth
      stateless: true
      json_login:
        username_path: email
        check_path: api_login_check
        success_handler: lexik_jwt_authentication.handler.authentication_success
        failure_handler: lexik_jwt_authentication.handler.authentication_failure

      refresh_jwt:
        check_path: api_refresh_token
        provider: app_user_provider

    api:
      pattern: ^/api
      stateless: true
      jwt:
        authenticator: app.jwt_custom_api_authenticator

    main:
      lazy: true
      provider: app_user_provider
faso-dev commented 2 years ago

hi, someone can help me ?

fd6130 commented 2 years ago

hi, someone can help me ?

I can't reproduce your issue because my custom authenticator is working fine.

I wonder is it because of the refresh token feature? How about try remove the refresh token feature from your project?

pakolkf commented 2 years ago

Hi,

I have the same problem. I don't know how to solve it

The Symfony versión is: 5.4.* And the LexikJWTAuthentication is: ^2.15

https://github.com/pakolkf/sfUsuarios/blob/main/config/services.yaml https://github.com/pakolkf/sfUsuarios/blob/main/config/packages/security.yaml https://github.com/pakolkf/sfUsuarios/blob/main/src/Security/CustomAuthenticator.php https://github.com/pakolkf/sfUsuarios/blob/main/composer.json

fd6130 commented 2 years ago

Not sure if it is relevant or not but can you try place the code at the end of the services in services.yaml?

For example:

services:
    #....

    app.custom_authenticator:
        class: App\Security\CustomAuthenticator
        parent: lexik_jwt_authentication.security.jwt_authenticator

Hi,

I have the same problem. I don't know how to solve it

The Symfony versión is: 5.4.* And the LexikJWTAuthentication is: ^2.15

https://github.com/pakolkf/sfUsuarios/blob/main/config/services.yaml https://github.com/pakolkf/sfUsuarios/blob/main/config/packages/security.yaml https://github.com/pakolkf/sfUsuarios/blob/main/src/Security/CustomAuthenticator.php https://github.com/pakolkf/sfUsuarios/blob/main/composer.json

pakolkf commented 2 years ago

Hi, I've tried what you say too and I get the same result, my custom class doesn't take it. debugging and using the dump function I don't see that it stops by the original loadUser method either

fd6130 commented 2 years ago

Hi, I've tried what you say too and I get the same result, my custom class doesn't take it. debugging and using the dump function I don't see that it stops by the original loadUser method either

Hi, can you write down your use case here? The repository you provided have no controller, thus I'm not sure what is your question about custom authenticator is not working here.

@faso-dev @pakolkf From my understanding, the authenticator is a function that will decode the JWT and load the user from database whenever user access the API with JWT. So if you want to try something in authenticator without accessing API with JWT nothing will work since the authenticator haven't trigger.

Perhaps both of you want this instead: https://symfony.com/doc/current/security/user_checkers.html

faso-dev commented 2 years ago

hi, someone can help me ?

I can't reproduce your issue because my custom authenticator is working fine.

I wonder is it because of the refresh token feature? How about try remove the refresh token feature from your project?

To tell the truth, I don't know if it's related to the RefreshToken, but I think that it intervenes only if the token is expired.

pakolkf commented 2 years ago

In my case, what I want to achieve is a JWT login through email or username. It seems that the LexikJWTAuthentication bundle does not do it by itself and I want to implement this login in the loadUser since this is where it is supposed to be accessed to retrieve the user from the database when launching the api/login_check request through postman

faso-dev commented 2 years ago

Hi, I've tried what you say too and I get the same result, my custom class doesn't take it. debugging and using the dump function I don't see that it stops by the original loadUser method either

Hi, can you write down your use case here? The repository you provided have no controller, thus I'm not sure what is your question about custom authenticator is not working here.

@faso-dev @pakolkf From my understanding, the authenticator is a function that will decode the JWT and load the user from database whenever user access the API with JWT. So if you want to try something in authenticator without accessing API with JWT nothing will work since the authenticator haven't trigger.

Perhaps both of you want this instead: https://symfony.com/doc/current/security/user_checkers.html

The authenticator actually retrieves the JWT token if it exists in the incoming request, and then retrieves the user from the database from the $payload.

Now if we are in the case of a login process where a token does not exist yet, the userIdentifier is used to retrieve the user from the database as well.

So all in all, the loader method should be called even once.

And indeed it is called, but only in the parent class (JWTTokenAuthenticator). The local one(CustomJWTTokenAuthenticator) to override the loadUser method is never called.

fd6130 commented 2 years ago

In my case, what I want to achieve is a JWT login through email or username. It seems that the LexikJWTAuthentication bundle does not do it by itself and I want to implement this login in the loadUser since this is where it is supposed to be accessed to retrieve the user from the database when launching the api/login_check request through postman

May refer to #1025

faso-dev commented 2 years ago

In my case, what I want to achieve is a JWT login through email or username. It seems that the LexikJWTAuthentication bundle does not do it by itself and I want to implement this login in the loadUser since this is where it is supposed to be accessed to retrieve the user from the database when launching the api/login_check request through postman

I also searched in vain how it proceeds to authenticate the default user just in api/login_check in the routes.yaml file

We are almost in the same need because I too don't want to generate the token if the user's account is locked, and the only way to do it and during the user retrieval in the loadUser method, unlike you who are looking to generate the token either by the email address or by the username.

fd6130 commented 2 years ago

The authenticator actually retrieves the JWT token if it exists in the incoming request, and then retrieves the user from the database from the $payload.

Now if we are in the case of a login process where a token does not exist yet, the userIdentifier is used to retrieve the user from the database as well.

So all in all, the loader method should be called even once.

And indeed it is called, but only in the parent class (JWTTokenAuthenticator). The local one(CustomJWTTokenAuthenticator) to override the loadUser method is never called.

Isn't that JWTTokenAuthenticator is Guard component which already deprecated since Symfony 5.4?

You can try add dd(); in JWTAuthenticator::loadUser() either directly in vendor or fork a version and change it there, mine does nothing there and I'm not really sure if it is really called or not.

fd6130 commented 2 years ago

I also searched in vain how it proceeds to authenticate the default user just in api/login_check in the routes.yaml file

We are almost in the same need because I too don't want to generate the token if the user's account is locked, and the only way to do it and during the user retrieval in the loadUser method, unlike you who are looking to generate the token either by the email address or by the username.

Have you thought about user checker? How about give it a try? https://symfony.com/doc/current/security/user_checkers.html

fd6130 commented 2 years ago

You can try add dd(); in JWTAuthenticator::loadUser() either directly in vendor or fork a version and change it there, mine does nothing there and I'm not really sure if it is really called or not.

I tested, it only works when you access API with JWT but not in the middle of login process.. or maybe I'm wrong here?

faso-dev commented 2 years ago

The authenticator actually retrieves the JWT token if it exists in the incoming request, and then retrieves the user from the database from the $payload. Now if we are in the case of a login process where a token does not exist yet, the userIdentifier is used to retrieve the user from the database as well. So all in all, the loader method should be called even once. And indeed it is called, but only in the parent class (JWTTokenAuthenticator). The local one(CustomJWTTokenAuthenticator) to override the loadUser method is never called.

Isn't that JWTTokenAuthenticator is Guard component which already deprecated since Symfony 5.4?

You can try add dd(); in JWTAuthenticator::loadUser() either directly in vendor or fork a version and change it there, mine does nothing there and I'm not really sure if it is really called or not.

Yes when, i put an dd in the loadUser method on vendor JWTAuthenticator class, it's called.

You can try add dd(); in JWTAuthenticator::loadUser() either directly in vendor or fork a version and change it there, mine does nothing there and I'm not really sure if it is really called or not.

I tested, it only works when you access API with JWT but not in the middle of login process.. or maybe I'm wrong here?

That's exactly it

faso-dev commented 2 years ago

I also searched in vain how it proceeds to authenticate the default user just in api/login_check in the routes.yaml file We are almost in the same need because I too don't want to generate the token if the user's account is locked, and the only way to do it and during the user retrieval in the loadUser method, unlike you who are looking to generate the token either by the email address or by the username.

Have you thought about user checker? How about give it a try? https://symfony.com/doc/current/security/user_checkers.html

I implement and get back to you

fd6130 commented 2 years ago

And indeed it is called, but only in the parent class (JWTTokenAuthenticator). The local one(CustomJWTTokenAuthenticator) to override the loadUser method is never called.

Mine custom authenticator is working, the loadUser() (the override one) is called every time i access my API, so i think it is definitely use case issue.

Thus, my conclusion are:

If you want to do something in the middle of login progress (like check user is banned or not), use user checker https://symfony.com/doc/current/security/user_checkers.html

If you want to do something when user accessing the API with provided JWT, use a custom authenticator.

If you want to apply login with username or email (#1025), just create a custom login api and generate the JWT there.

faso-dev commented 2 years ago

I also searched in vain how it proceeds to authenticate the default user just in api/login_check in the routes.yaml file We are almost in the same need because I too don't want to generate the token if the user's account is locked, and the only way to do it and during the user retrieval in the loadUser method, unlike you who are looking to generate the token either by the email address or by the username.

Have you thought about user checker? How about give it a try? https://symfony.com/doc/current/security/user_checkers.html

It's work for me fine and the jwt is not generated. I think this is the best solution to do this instead of to override the JWTAuthenticator laodUser method.

In security.yaml

firewalls:
  dev:
    pattern: ^/(_(profiler|wdt)|css|images|js)/
    security: false

  login:
    ...
    user_checker: App\Http\Api\Security\Checker\JWTUserChecker
    json_login:
      username_path: email
      check_path: api_login_check
      success_handler: lexik_jwt_authentication.handler.authentication_success
      failure_handler: lexik_jwt_authentication.handler.authentication_failure
faso-dev commented 2 years ago

And indeed it is called, but only in the parent class (JWTTokenAuthenticator). The local one(CustomJWTTokenAuthenticator) to override the loadUser method is never called.

Mine custom authenticator is working, the loadUser() (the override one) is called every time i access my API, so i think it is definitely use case issue.

Thus, my conclusion are:

If you want to do something in the middle of login progress (like check user is banned or not), use user checker https://symfony.com/doc/current/security/user_checkers.html

If you want to do something when user accessing the API with provided JWT, use a custom authenticator.

If you want to apply login with username or email (#1025), just create a custom login api and generate the JWT there.

can u share your example work, maybe it's configuration issues too.

fd6130 commented 2 years ago

can u share your example work, maybe it's configuration issues too.

I test it with memory user config.

security.yaml

security:
    enable_authenticator_manager: true
    # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
    # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
    providers:
        user_in_memory:
            memory:
                users:
                    test: { password: '$2y$13$.kwOziT0ZefkvNKnrCdUKOvzPSQ/Zp4TDSo5pwXnGP0FCXanqw7ym', roles: ['ROLE_ADMIN'] }

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false

        login:
            pattern: ^/api/login
            stateless: true
            json_login:
                check_path: /api/login_check
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure

        api:
            pattern:   ^/api
            stateless: true
            jwt:
                authenticator: app.custom_authenticator

        main:
            lazy: true

    access_control:
        - { path: ^/api/login, roles: PUBLIC_ACCESS }
        - { path: ^/api,       roles: IS_AUTHENTICATED_FULLY }

when@test:
    security:
        password_hashers:
            # By default, password hashers are resource intensive and take time. This is
            # important to generate secure password hashes. In tests however, secure hashes
            # are not important, waste resources and increase test times. The following
            # reduces the work factor to the lowest possible values.
            Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
                algorithm: auto
                cost: 4 # Lowest possible value for bcrypt
                time_cost: 3 # Lowest possible value for argon
                memory_cost: 10 # Lowest possible value for argon

CustomAuthenticator.php

<?php

namespace App\Security;

use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Security\Authenticator\JWTAuthenticator;

class CustomAuthenticator extends JWTAuthenticator
{
    protected function loadUser(array $payload, string $identity): UserInterface
    {
        /** @var UserInterface|User $user */
        $user  = parent::loadUser($payload, $identity);
        if (true){
            $ex = new UserNotFoundException('Your account has been deactivated by the administrators');
            $ex->setUserIdentifier($identity);
            throw $ex;
        }
        return $user;
    }
}

services.yaml

# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.

# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:

services:
    # default configuration for services in *this* file
    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.

    # makes classes in src/ available to be used as services
    # this creates a service per class whose id is the fully-qualified class name
    App\:
        resource: '../src/'
        exclude:
            - '../src/DependencyInjection/'
            - '../src/Entity/'
            - '../src/Kernel.php'

    # add more service definitions when explicit configuration is needed
    # please note that last definitions always *replace* previous ones
    app.custom_authenticator:
        class: App\Security\CustomAuthenticator
        parent: lexik_jwt_authentication.security.jwt_authenticator

And use curl to access the login and api:

curl -X POST -H "Content-Type: application/json" http://127.0.0.1:8000/api/login_check -d '{"username":"test","password":"123456"}'

replace the JWT to your JWT

curl -X GET -H "Content-Type: application/json" -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE2NTQ1MjgyODAsImV4cCI6MTY1NDUzMTg4MCwicm9sZXMiOlsiUk9MRV9BRE1JTiJdLCJ1c2VybmFtZSI6InRlc3QifQ.fADcr2RQxmUXj54w8-6lR6LPcA85Zq3F7RJ4PNz9xij8vZjclFjTJolqFZaPinUmWtpnoA11QMUOCU8QVIkCVacocMgHqvXuv4vAapZ9MBTMugDF2J1ZM80zUySfS4oD9nsFJEdeIaHrPNKdD4ZLJPv4oTiCtjEWzIg2PGX1bNwYVHq_yy0TkNpOrcafLYGS7QS2LmQRx_bJmtyvkYEScQskRKyMlF1vblDJFT8JZSdo5xNUS_1u5_V8fVf6WPg9hSagbl_Rq28RKvsVLDyv-RHSXsfqS5WyYqVK1F9n_nSg-84k3yj4PB1dkpcOISTfw2yfawt05EAGShQ8YaNOlA" http://127.0.0.1:8000/api/test
fd6130 commented 2 years ago

I also searched in vain how it proceeds to authenticate the default user just in api/login_check in the routes.yaml file We are almost in the same need because I too don't want to generate the token if the user's account is locked, and the only way to do it and during the user retrieval in the loadUser method, unlike you who are looking to generate the token either by the email address or by the username.

Have you thought about user checker? How about give it a try? https://symfony.com/doc/current/security/user_checkers.html

It's work for me fine and the jwt is not generated. I think this is the best solution to do this instead of to override the JWTAuthenticator laodUser method.

In security.yaml

firewalls:
  dev:
    pattern: ^/(_(profiler|wdt)|css|images|js)/
    security: false

  login:
    ...
    user_checker: App\Http\Api\Security\Checker\JWTUserChecker
    json_login:
      username_path: email
      check_path: api_login_check
      success_handler: lexik_jwt_authentication.handler.authentication_success
      failure_handler: lexik_jwt_authentication.handler.authentication_failure

Glad you solve the issue.

faso-dev commented 2 years ago

can u share your example work, maybe it's configuration issues too.

I test it with memory user config.

security.yaml

security:
    enable_authenticator_manager: true
    # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
    # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
    providers:
        user_in_memory:
            memory:
                users:
                    test: { password: '$2y$13$.kwOziT0ZefkvNKnrCdUKOvzPSQ/Zp4TDSo5pwXnGP0FCXanqw7ym', roles: ['ROLE_ADMIN'] }

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false

        login:
            pattern: ^/api/login
            stateless: true
            json_login:
                check_path: /api/login_check
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure

        api:
            pattern:   ^/api
            stateless: true
            jwt:
                authenticator: app.custom_authenticator

        main:
            lazy: true

    access_control:
        - { path: ^/api/login, roles: PUBLIC_ACCESS }
        - { path: ^/api,       roles: IS_AUTHENTICATED_FULLY }

when@test:
    security:
        password_hashers:
            # By default, password hashers are resource intensive and take time. This is
            # important to generate secure password hashes. In tests however, secure hashes
            # are not important, waste resources and increase test times. The following
            # reduces the work factor to the lowest possible values.
            Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
                algorithm: auto
                cost: 4 # Lowest possible value for bcrypt
                time_cost: 3 # Lowest possible value for argon
                memory_cost: 10 # Lowest possible value for argon

CustomAuthenticator.php

<?php

namespace App\Security;

use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Security\Authenticator\JWTAuthenticator;

class CustomAuthenticator extends JWTAuthenticator
{
    protected function loadUser(array $payload, string $identity): UserInterface
    {
        /** @var UserInterface|User $user */
        $user  = parent::loadUser($payload, $identity);
        if (true){
            $ex = new UserNotFoundException('Your account has been deactivated by the administrators');
            $ex->setUserIdentifier($identity);
            throw $ex;
        }
        return $user;
    }
}

services.yaml

# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.

# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:

services:
    # default configuration for services in *this* file
    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.

    # makes classes in src/ available to be used as services
    # this creates a service per class whose id is the fully-qualified class name
    App\:
        resource: '../src/'
        exclude:
            - '../src/DependencyInjection/'
            - '../src/Entity/'
            - '../src/Kernel.php'

    # add more service definitions when explicit configuration is needed
    # please note that last definitions always *replace* previous ones
    app.custom_authenticator:
        class: App\Security\CustomAuthenticator
        parent: lexik_jwt_authentication.security.jwt_authenticator

And use curl to access the login and api:

curl -X POST -H "Content-Type: application/json" http://127.0.0.1:8000/api/login_check -d '{"username":"test","password":"123456"}'

replace the JWT to your JWT

curl -X GET -H "Content-Type: application/json" -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE2NTQ1MjgyODAsImV4cCI6MTY1NDUzMTg4MCwicm9sZXMiOlsiUk9MRV9BRE1JTiJdLCJ1c2VybmFtZSI6InRlc3QifQ.fADcr2RQxmUXj54w8-6lR6LPcA85Zq3F7RJ4PNz9xij8vZjclFjTJolqFZaPinUmWtpnoA11QMUOCU8QVIkCVacocMgHqvXuv4vAapZ9MBTMugDF2J1ZM80zUySfS4oD9nsFJEdeIaHrPNKdD4ZLJPv4oTiCtjEWzIg2PGX1bNwYVHq_yy0TkNpOrcafLYGS7QS2LmQRx_bJmtyvkYEScQskRKyMlF1vblDJFT8JZSdo5xNUS_1u5_V8fVf6WPg9hSagbl_Rq28RKvsVLDyv-RHSXsfqS5WyYqVK1F9n_nSg-84k3yj4PB1dkpcOISTfw2yfawt05EAGShQ8YaNOlA" http://127.0.0.1:8000/api/test

i'll retry this if needed. Thank u for your support. So i think this issue can be closed now