21

First off, I'm not using FOSUserBundle and I can't because I'm porting a legacy system which has its own Model layer (no Doctrine/Mongo/whatsoever here) and other very custom behavior.

I'm trying to connect my legacy role system with Symfony's so I can use native symfony security in controllers and views.

My first attempt was to load and return all of the user's roles in the getRoles() method from the Symfony\Component\Security\Core\User\UserInterface. At first, it looked like that worked. But after taking a deeper look, I noticed that these roles are only refreshed when the user logs in. This means that if I grant or revoke roles from a user, he will have to log out and back in for the changes to take effect. However, if I revoke security roles from a user, I want that to be applied immediately, so that behavior isn't acceptable to me.

What I want Symfony to do is to reload a user's roles on every request to make sure they're up-to-date. I have implemented a custom user provider and its refreshUser(UserInterface $user) method is being called on every request but the roles somehow aren't being refreshed.

The code to load / refresh the user in my UserProvider looks something like this:

public function loadUserByUsername($username) {
    $user = UserModel::loadByUsername($username); // Loads a fresh user object including roles!
    if (!$user) {
        throw new UsernameNotFoundException("User not found");
    }
    return $user;
}

(refreshUser looks similar)

Is there a way to make Symfony refresh user roles on each request?

CSchulz
  • 10,882
  • 11
  • 60
  • 114
netmikey
  • 2,422
  • 2
  • 28
  • 35

8 Answers8

23

So after a couple of days trying to find a viable solution and contributing to the Symfony2 user mailing list, I finally found it. The following has been derived from the discussion at https://groups.google.com/d/topic/symfony2/NDBb4JN3mNc/discussion

It turns out that there's an interface Symfony\Component\Security\Core\User\EquatableInterface that is not intended for comparing object identity but precisely to

test if two objects are equal in security and re-authentication context

Implement that interface in your user class (the one already implementing UserInterface). Implement the only required method isEqualTo(UserInterface $user) so that it returns false if the current user's roles differ from those of the passed user.

Note: The User object is serialized in the session. Because of the way serialization works, make sure to store the roles in a field of your user object, and do not retrieve them directly in the getRoles() Method, otherwise all of that won't work!

Here's an example of how the specific methods might look like:

protected $roles = null;

public function getRoles() {

    if ($this->roles == null) {
        $this->roles = ...; // Retrieve the fresh list of roles
                            // from wherever they are stored here
    }

    return $this->roles;
}

public function isEqualTo(UserInterface $user) {

    if ($user instanceof YourUserClass) {
        // Check that the roles are the same, in any order
        $isEqual = count($this->getRoles()) == count($user->getRoles());
        if ($isEqual) {
            foreach($this->getRoles() as $role) {
                $isEqual = $isEqual && in_array($role, $user->getRoles());
            }
        }
        return $isEqual;
    }

    return false;
}

Also, note that when the roles actually change and you reload the page, the profiler toolbar might tell you that your user is not authenticated. Plus, looking into the profiler, you might find that the roles didn't actually get refreshed.

I found out that the role refreshing actually does work. It's just that if no authorization constraints are hit (no @Secure annotations, no required roles in the firewall etc.), the refreshing is not actually done and the user is kept in the "unauthenticated" state.

As soon as you hit a page that performs any kind of authorization check, the user roles are being refreshed and the profiler toolbar displays the user with a green dot and "Authenticated: yes" again.

That's an acceptable behavior for me - hope it was helpful :)

netmikey
  • 2,422
  • 2
  • 28
  • 35
  • +1 for the "make sure to store the roles in a field of your user object" tip, this saved my life – Laurent W. Jun 21 '13 at 15:11
  • dont forget to implement \Serializable interface and include id, salt and isActive. Since you also need to check if roles has changes, add it too to serializable data. – ken Jan 14 '14 at 08:19
  • 1
    **Wonderful!** I had already FOSUserBundle implemented, which provided me with `FOS\UserBundle\Model\User` that my user entity extended. I just implemented the `EquatableInterface` to that and it worked like a charm (since FOS's User already did the serialization). – juuga Jan 23 '14 at 16:36
  • what way would you recommend to "Retrieve the fresh list of roles" if you're using doctrine. I was under the impression that making db calls in an entity was considered a bit taboo? – Derick F Apr 15 '14 at 19:25
  • Great point! I'd go so far as to say: Serializing a Doctrine Entity to the session in the first place seems to be a massive taboo to me. Unfortunately, I don't have a solution at hand for this right now - but you might want to have a look at the other responses in this question! :) – netmikey Aug 08 '14 at 10:10
  • I dont understand the point of comparing if roles are equal, I just wanna refresh symfony2 magical refreshing of the roles – delmalki Jun 30 '15 at 21:18
  • 1
    When you implement `EquatableInterface`, symfony skips all the other checks for checking if a user has changed (see https://github.com/symfony/security-core/blob/master/Authentication/Token/AbstractToken.php#L250). So i guess you have to do all those thing yourself in the `isEqualTo` function? – vincecore Aug 21 '15 at 17:10
  • "make sure to store the roles in a field of your user object" where and how to do that? – Karl Adler Apr 22 '16 at 14:10
  • 2
    you can write the main part in one line `return count($this->getRoles()) == count($user->getRoles()) && count(array_diff($this->getRoles(), $user->getRoles())) == 0;` – fracz Jan 11 '17 at 23:37
12

In your security.yml (or the alternatives):

security:
    always_authenticate_before_granting: true

Easiest game of my life.

Marc
  • 441
  • 1
  • 6
  • 11
8

From a Controller, after adding roles to a user, and saving to the database, simply call:

// Force refresh of user roles
$token = $this->get('security.context')->getToken()->setAuthenticated(false);
Simon Epskamp
  • 8,813
  • 3
  • 53
  • 58
  • 4
    Works great for me, but if you want to avoid a deprecation notice in newer versions of Symfony, use `security.token_storage` instead of `security.context` :) – inanimatt Oct 09 '15 at 10:40
  • On a service you can add it on event 'kernel.controller' to force other users already logged to refresh the permission roles – gastonnina Aug 04 '17 at 06:21
4

Take a look here, set always_authenticate_before_granting to true at security.yml.

Community
  • 1
  • 1
psylosss
  • 3,469
  • 3
  • 16
  • 26
3

I achieve this behaviour by implementing my own EntityUserProvider and overriding loadByUsername($username) method :

   /**
    * Load an user from its username
    * @param string $username
    * @return UserInterface
    */
   public function loadUserByUsername($username)
   {
      $user = $this->repository->findOneByEmailJoinedToCustomerAccount($username);

      if (null === $user)
      {
         throw new UsernameNotFoundException(sprintf('User "%s" not found.', $username));
      }

      //Custom function to definassigned roles to an user
      $roles = $this->loadRolesForUser($user);

      //Set roles to the user entity
      $user->setRoles($roles);

      return $user;
   }

The trick is to call setRoles each time you call loadByUsername ... Hope it helps

AlterPHP
  • 12,667
  • 5
  • 49
  • 54
  • This solution seems bound to Doctrine. I'm not using Doctrine though. Also, regardless of Doctrine, I don't see how setting the property on the user entity can affect what's in Symfony's security context? – netmikey Dec 10 '12 at 12:40
  • User object is set in the token and retrieved in the persistence layer used for the token (session/cookies/bdd). If you don't specifically refreshRoles each time you refresh user, security context works with token roles (persisted with the PHP session). Let's see DaoAuthenticationProvider::retirieveUser method. Any way you use to manage your users, you have to tweak UserProvider::loadByUsername method to refresh roles on each request.. – AlterPHP Dec 10 '12 at 12:48
  • Yes, I see what you mean: the User object is stored in the token, which is (in my case) stored in the user's session. But isn't that exactly what the `refreshUser(UserInterface $user)` method is for? My UserProvider loads and returns a fresh user from the database (including roles!), but they still don't get refreshed in the token. – netmikey Dec 10 '12 at 13:08
  • It works for me (explicitely use setRoles in refreshUser or loadUserByUsername)... Please post your refreshUser method, it may help to find out. – AlterPHP Dec 10 '12 at 13:25
  • Unless `roles` is an array field (DB column) of your User object, it's not refreshed in your loadUserByUsername method... – AlterPHP Dec 10 '12 at 13:43
  • For the time being, `getRoles()` even goes out and fetches roles from the database on each call to it! The thing is: even if I put an exception in it (to quickly see if it's being called), it doesn't fail, so it looks like it isn't being called at all after the session has been initially opened. – netmikey Dec 10 '12 at 15:01
  • let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/20895/discussion-between-netmikey-and-pece) – netmikey Dec 10 '12 at 21:21
1

Solution is to hang a subscriber on a Doctrine postUpdate event. If updated entity is User, same user as logged, then I do authenticate using AuthenticationManager service. You have to inject service container (or related services) to subscriber, of course. I prefer to inject whole container to prevent a circular references issue.

public function postUpdate(LifecycleEventArgs $ev) {
    $entity = $ev->getEntity();

    if ($entity instanceof User) {
        $sc = $this->container->get('security.context');
        $user = $sc->getToken()->getUser();

        if ($user === $entity) {
            $token = $this->container->get('security.authentication.manager')->authenticate($sc->getToken());

            if ($token instanceof TokenInterface) {
                $sc->setToken($token);
            }
        }
    }
}
0

Sorry i cant reply in comment so i replay to question. If someone new in symfony security try to get role refresh work in Custom Password Authentication then inside function authenticateToken :

if(count($token->getRoles()) > 0 ){
        if ($token->getUser() == $user ){
            $passwordValid=true;
        }
    }

And do not check for passwords from DB/LDAP or anywhere. If user come in system then in $token are just username and had no roles.

Agris
  • 105
  • 2
  • 12
0

I've been battling this for Symfony4, and I think I've finally settled down to a solution.

The thing is that in my case, the roles depend on the "company" the user is working with. It may be a CEO in one company, but an operator in another one, and the menus, permissions, etc. depend on the company. When switching companies, the user must not re-login.

Finally I've done the following:

  • Set the firewall to stateless.
  • In the FormAuthentication class, I set an attribute in the session explicitely, with the username.
  • I set up another Guard, which essentially take this attribute and loads the user for it from the database, for every single request.
class FormAuthenticator extends AbstractFormLoginAuthenticator
{
    /** Constructor omitted */

    public function supports(Request $request)
    {
        return 'app_login' === $request->attributes->get('_route')
            && $request->isMethod('POST');
    }

    public function getCredentials(Request $request)
    {
        $credentials = [
            'nomusuari' => $request->request->get('nomusuari'),
            'password' => $request->request->get('password'),
            'csrf_token' => $request->request->get('_csrf_token'),
        ];
        $request->getSession()->set(
            Security::LAST_USERNAME,
            $credentials['nomusuari']
        );

        return $credentials;
    }

    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        $token = new CsrfToken('authenticate', $credentials['csrf_token']);
        if (!$this->csrfTokenManager->isTokenValid($token)) {
            throw new InvalidCsrfTokenException();
        }

        $user = $userProvider->loadUserByUsername($credentials['nomusuari']);

        if (!$user) {
            // fail authentication with a custom error
            throw new CustomUserMessageAuthenticationException('Invalid user/password');
        }

        return $user;
    }

    public function checkCredentials($credentials, UserInterface $user)
    {
        $valid = $this->passwordEncoder->isPasswordValid($user, $credentials['password']);
        return $valid;
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
        $request->getSession()->set("user_username",$token->getUsername());

        return new RedirectResponse(
          $this->urlGenerator->generate("main")
        );
    }

    protected function getLoginUrl()
    {
        return $this->urlGenerator->generate('app_login');
    }
}

The SessionAuthenticator (returns JSON, you may have to adapt it):

class SessionAuthenticator extends AbstractGuardAuthenticator
{
    /**
     * 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)
    {
        return $request->getSession()->has("user_username");
    }

    /**
     * Called on every request. Return whatever credentials you want to
     * be passed to getUser() as $credentials.
     */
    public function getCredentials(Request $request)
    {
        return $request->getSession()->get("user_username","");
    }

    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        if (null === $credentials) {
            // The token header was empty, authentication fails with HTTP Status
            // Code 401 "Unauthorized"
            return null;
        }

        // if a User is returned, checkCredentials() is called
        /*return $this->em->getRepository(User::class)
            ->findOneBy(['apiToken' => $credentials])
        ;*/
        return $userProvider->loadUserByUsername($credentials);
    }

    public function checkCredentials($credentials, UserInterface $user)
    {
        // Check credentials - e.g. make sure the password is valid.
        // In case of an API token, no credential check is needed.

        // Return `true` to cause authentication success
        return true;
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
        // on success, let the request continue
        return null;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {
        $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);
    }

    /**
     * Called when authentication is needed, but it's not sent
     */
    public function start(Request $request, AuthenticationException $authException = null)
    {
        $data = [
            // you might translate this message
            'message' => 'Authentication Required'
        ];

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

    public function supportsRememberMe()
    {
        return false;
    }
}

Finally, my security.yaml:

main:
            anonymous:
            stateless: true
            guard:
                entry_point: App\Security\FormAuthenticator
                authenticators:
                    - App\Security\SessionAuthenticator
                    - App\Security\FormAuthenticator

Working fine. I can see the changes in the toolbar, and the Roles are refreshed.

HTH,

Esteve