8000 Symfony custom authenticator still always try to re-authenticate and create a new session for user · Issue #54370 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

Symfony custom authenticator still always try to re-authenticate and create a new session for user #54370

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
bauermax opened this issue Mar 21, 2024 · 4 comments

Comments

@bauermax
Copy link
bauermax commented Mar 21, 2024

Symfony version(s) affected

6.4.5

Description

Hi everyone,

I'm currently developping a new Symfony 6.4.5 app which needs a custom Authenticator and a custom User Provider.
Basically the reverse proxy behind the application adds a specif http header with a token which should be used by the app to fetch user data through an dedicated external API.

Here is the problem i noticed:

I'm working on mode stateless = false; so i want to authenticate the user and fetch the api only for the first request. The other requests are supposed to retrieve the user from the session but it doesn't behave like that

How to reproduce

Here is the code of my Provider and my custom Authenticator (for my tests, i am very close to the default symfony documentation example):

use App\Security\User\CustomUserProvider;

class CustomUserProvider  implements UserProviderInterface
{

    /**
     * Symfony calls this method if you use features like switch_user
     * or remember_me. If you're not using these features, you do not
     * need to implement this method.
     *
     * @throws UserNotFoundException if the user is not found
     */
    public function loadUserByIdentifier(string $identifier): UserInterface
    {
        dump("load user by identifier");
        $user = new User(123456789,"email",['ROLE_ADMIN','ROLE_USER']); 

        return $user;
        // Load a User object from your data source or throw UserNotFoundException.
        // The $identifier argument is whatever value is being returned by the
        // getUserIdentifier() method in your User class.
        throw new \Exception('TODO: fill in loadUserByIdentifier() inside '.__FILE__);
    }

    /**
     * Refreshes the user after being reloaded from the session.
     *
     * When a user is logged in, at the beginning of each request, the
     * User object is loaded from the session and then this method is
     * called. Your job is to make sure the user's data is still fresh by,
     * for example, re-querying for fresh User data.
     *
     * If your firewall is "stateless: true" (for a pure API), this
     * method is not called.
     */
    public function refreshUser(UserInterface $user): UserInterface
    {
		dump("refresh User",$user);
        if (!$user instanceof UserInterface) {
            throw new UnsupportedUserException(sprintf('Invalid user class "%s".', get_class($user)));
        }

        return $user;


        // Return a User object after making sure its data is "fresh".
        // Or throw a UserNotFoundException if the user no longer exists.
        throw new \Exception('TODO: fill in refreshUser() inside '.__FILE__);
    }

    /**
     * Tells Symfony to use this provider for this User class.
     */
    public function supportsClass(string $class): bool
    {
        return User::class === $class || is_subclass_of($class, User::class);
    }

    /**
     * Upgrades the hashed password of a user, typically for using a better hash algorithm.
     */
    public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
    {
        // TODO: when hashed passwords are in use, this method should:
        // 1. persist the new password in the user storage
        // 2. update the $user object with $user->setPassword($newHashedPassword);
    }

}
<?php


namespace App\Security;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;

class CustomAuthenticator extends AbstractAuthenticator
{
    /**
     * Called on every request to decide if this authenticator should be
     * used for the request. Returning `false` will cause this authenticator
     * to be skipped.
     */
    public function supports(Request $request): ?bool
    {
        return true;
    }

    public function authenticate(Request $request): Passport
    {
        dump("authenticate");
        // implement your own logic to get the user identifier from `$apiToken`
        // e.g. by looking up a user in the database using its API key

        $userIdentifier = "fake id";


        return new SelfValidatingPassport(new UserBadge($userIdentifier));
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        // on success, let the request continue
        return null;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $data = [
            // you may want to customize or obfuscate the message first
            'message' => strtr($exception->getMessageKey(), $exception->getMessageData())

            // or to translate this message
            // $this->translator->trans($exception->getMessageKey(), $exception->getMessageData())
        ];

        return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
    }
}

(security.yaml)

security:
    # 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:
        # users_in_memory: { memory: null }
        rush_user_provider:
            id: App\Security\Provider\CustomUserProvider
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            stateless: false
            provider: custom_user_provider
            custom_authenticators:
                - App\Security\CustomAuthenticator

            # activate different ways to authenticate
            # https://symfony.com/doc/current/security.html#the-firewall

            # https://symfony.com/doc/current/security/impersonating_user.html
            switch_user: false

    # Easy way to control access for large sections of your site
    # Note: Only the *first* access control that matches will be used
    access_control:
        # - { path: ^/admin, roles: ROLE_ADMIN }
        # - { path: ^/profile, roles: ROLE_USER }


When i try the two scenarios:
1 (first request for a user)
--> enter in "support" function (twice)
--> enter in authenticate function
--> enter in 'loadUserByIdentifier' function a create the user

This scenario works as expected: The user has no session: he is authenticated and the user is loaded from the API and the stored in session.

2 (others requests)
--> enter in 'refreshUser' function( and the user is correctly retrieved from the session)
--> enter in "support" function (twice)
--> enter in authenticate function (again ?)
--> enter in 'loadUserByIdentifier' function and re-create the user

I was expceting to enter only in 'refreshUser' function when the session exists
I'm not using the stateless: true option in this case.

Is this the normal behavior ?

Possible Solution

No response

Additional Context

I found an old similar issue (which was patched) with the 'REMOTE_USER' provider.
#43648

@MatTheCat
Copy link
Contributor

Feels like the ContextListener finds the user in session different from the one that is refreshed. Did you check the logs (for Cannot refresh token because user has changed. e.g.)? How is your User class implemented?

@bauermax
Copy link
Author
bauermax commented Mar 21, 2024

Cannot refresh token because

I have no errors like this, but here are some logs about the refreshing:

[2024-03-21T18:57:03.673257+00:00] security.DEBUG: Read existing security token from the session. {"key":"_security_main","token_class":"Symfony\\Component\\Security\\Http\\Authenticator\\Token\\PostAuthenticationToken"} []
[2024-03-21T18:57:07.728694+00:00] security.DEBUG: User was reloaded from a user provider. {"provider":"App\\Security\\Provider\\CustomUserProvider","username":"maxime.bauer@test.com"} []
[2024-03-21T18:57:07.729156+00:00] security.DEBUG: Checking for authenticator support. {"firewall_name":"main","authenticators":1} []

It seems that the user is correctly reloaded from the provider

My user implementation cannot be simplier, i just implements UserInterface with 2 custom fields for the tests:

<?php

namespace App\Security\User;

use Symfony\Component\Security\Core\User\UserInterface;

class User implements UserInterface
{

    private  $id;
    private  $email;
    private  $roles;

    public function __construct($id,$email,$roles = []){

        $this->id = $id;
        $this->email = $email;
        $this->roles = $roles;

    }





    public function setId(): void
    {
        $this->id = $id;
    }

    public function getId(): int
    {
        return $this->id;
    }

    public function setEmail(): void
    {
     $this->email = $email;   
    }
    public function getEmail(): string
    {
        return $this->email;
    }

    public function setRoles(array $roles): self
    {
        $this->roles = $roles;

        return $this;
    }

    /**
     * @see UserInterface
     */
    public function getRoles(): array
    {
        return array_unique($this->roles);
    }

    /**
     * The public representation of the user (e.g. a username, an email address, etc.)
     *
     * @see UserInterface
     */
    public function getUserIdentifier(): string
    {
        return (string) $this->email;
    }

    /**
     * @see UserInterface
     */
    public function eraseCredentials(): void
    {
        // If you store any temporary, sensitive data on the user, clear it here
        // $this->plainPassword = null;
    }


}

@MatTheCat
Copy link
Contributor

Okay, I guess the issue is that your authenticator’s supports method always return true, even when there is already a token in the storage 🤔

Could you try returning false in this case?

@bauermax
Copy link
Author

Damn i thought the auth process would be automaticly skipped when the user can be retrieved from the session.

Works well with the following code:

    public function supports(Request $request): ?bool
    {

        //check if the user is logged in 
        $session = $this->tokenStorage->getToken();

        if($session != null && $session->getUser() != null){
            return false;
        }
    
        return true;
    }

Guess that was thinked that way. Thank you !

@xabbuh xabbuh closed this as not planned Won't fix, can't repro, duplicate, stale Mar 22, 2024
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

4 participants
0