Symfony2: Unable to login successfully with two firewalls using two user providers

364 views Asked by At

I am setting up a website which I want to use separate firewalls and authentication systems for frontend and backend. So my security.yml is configured as below. I am using in_memory user provider in early development phase.

security:
    encoders:
        Symfony\Component\Security\Core\User\User: plaintext

    role_hierarchy:
        ROLE_ADMIN:       ROLE_USER
        ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]

    providers:
        backend_in_memory:
            memory:
                users:
                    admin: { password: admin, roles: [ 'ROLE_ADMIN' ] }
        frontend_in_memory:
            memory:
                users:
                    user:  { password: 12345, roles: [ 'ROLE_USER' ] }

    firewalls:

        # (Configuration for backend omitted)

        frontend_login_page:
            pattern:  ^/login$
            security: false

        frontend:
            pattern:   ^/
            provider: frontend_in_memory
            anonymous: ~
            form_login:
                check_path: login_check_route  # http://example.com/login_check
                login_path: login_route        # http://example.com/login


    access_control:
        # (Configuration for backend omitted)
        - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/, roles: ROLE_USER }

I have omitted the backend part because it doesn't matter. The problem is still there when the omitted part is commented out.

The problem is that frontend authentication won't work with the above configuration. Here's what I did:

  1. Visit http://example.com/login
  2. Enter the credential (user:12345), click login
  3. http://example.com/login_check authenticates the user
  4. The authentication service redirects user back to http://example.com/. No error is thrown. In fact, when I turned on the debug_redirects option, it clearly shows that "user" is authenticated on the redirect page.

Expected behavior: The security token should show that I'm logged in as "user" after following the redirect and go back to the index page.

Actual behavior: The security token still shows "anonymous" login after following the redirect and go back to the index page.

But with nearly identical settings (paths and route names aren't the same), the backend part works correctly.

After some investigation I found that the cause is the way user providers is currently written. Notice that frontend_in_memory section is placed below backend_in_memory that is used for backend authentication. So I explicitly specify the frontend_in_memory provider for the frontend firewall. And it kind of works - I must login with "user:12345" in the frontend login page. Logging in with "admin" won't work. So it must be using the correct user provider. But I suspect that the framework cannot update the security token correctly because it is still searching the "user" account from the first user provider which is backend_in_memory. In fact I can make the above config work with either one of the following changes:

  1. add "user" login to the backend_in_memory provider's user list (password needn't be the same), or
  2. swap frontend_in_memory with backend_in_memory so that frontend_in_memory becomes the first user provider.

Of course they are not the correct way of solving this problem. Adding "user" account to the backend makes no sense at all; swapping the order of two user providers fixes the frontend but breaks the backend.

I would like to know what's wrong and how to fix this. Thank you!

1

There are 1 answers

1
Edmund Tam On BEST ANSWER

I was stuck when I posted the question, but after a sleep the answer is found ;)

Turns out I came across an issue reported long ago: https://github.com/symfony/symfony/issues/4498

In short,

  • The problem isn't about the configuration.
  • And it isn't about authentication neither.
  • It actually relates to how an authenticated user is refreshed after redirection. That's why the app is correctly authenticated as "user" on the redirect page, but not after that.

Here is the code when the framework refreshes the user (can be found in \Symfony\Component\Security\Http\Firewall\ContextListener):

    foreach ($this->userProviders as $provider) {
        try {
            $refreshedUser = $provider->refreshUser($user);
            $token->setUser($refreshedUser);

            if (null !== $this->logger) {
                $this->logger->debug(sprintf('Username "%s" was reloaded from user provider.', $refreshedUser->getUsername()));
            }

            return $token;
        } catch (UnsupportedUserException $unsupported) {
            // let's try the next user provider // *1
        } catch (UsernameNotFoundException $notFound) {
            if (null !== $this->logger) {
                $this->logger->warning(sprintf('Username "%s" could not be found.', $notFound->getUsername()));
            }

            return; // *2
        }
    }

The above code shows how the framework loops through the user providers to find the particular user (refreshUser()). *1 and *2 are added by me. If a user provider throws an UnsupportedUserException, this means that the provider isn't responsible for the supplied UserInterface. The listener will then iterate to the next user provider (*1).

However, if what the user provider thrown is a UsernameNotFoundException, this means that the provider is responsible for the supplied UserInterface, but the corresponding account could not be found. The loop will then stop immediately. (*2)

In my question, the same user provider, \Symfony\Component\Security\Core\User\InMemoryUserProvider, is used in both frontend and backend environment. And InMemoryUserProvider is responsible for the UserInterface implemented by Symfony\Component\Security\Core\User\User.

In the frontend, "user" is in fact authenticated successfully. However, in the user refresh attempt,

  • The order of the user providers will be like this: backend in-memory provider, frontend in-memory provider.
  • So, backend in-memory provider will run first.
  • The backend in-memory provider believes it is responsible for the supplied UserInterface because it is also an instance of Symfony\Component\Security\Core\User\User.
  • But it fails to locate the "user" account (it only has the "admin" account).
  • It then throws a UsernameNotFoundException.
  • The refreshUser() routine won't bother to try with next provider because UsernameNotFoundException means that the responsible user provider is already found. Instead it stops trying and removes the authentication token.

This explains why the configuration won't work. Despite using a different user provider, the only way to work around this is to copy the framework's InMemoryUserProvider and User classes and change the refreshUser() method to check against the copied User class, so that the frontend and backend user provider uses different user classes and won't clash.