Symfony & Guard: “The security token was removed d

2020-02-01 05:33发布

问题:

I tried to create an authenticator for my login form, but I always am unlogged for some unclear reason.

[2016-10-05 18:54:53] security.INFO: Guard authentication successful! {"token":"[object] (Symfony\\Component\\Security\\Guard\\Token\\PostAuthenticationGuardToken: PostAuthenticationGuardToken(user=\"test@test.test\", authenticated=true, roles=\"ROLE_USER\"))","authenticator":"AppBundle\\Security\\Authenticator\\FormLoginAuthenticator"} []
[2016-10-05 18:54:54] security.INFO: An AuthenticationException was thrown; redirecting to authentication entry point. {"exception":"[object] (Symfony\\Component\\Security\\Core\\Exception\\AuthenticationExpiredException(code: 0):  at /space/products/insurance/vendor/symfony/symfony/src/Symfony/Component/Security/Guard/Provider/GuardAuthenticationProvider.php:86)"} []
[2016-10-05 18:54:54] security.INFO: The security token was removed due to an AccountStatusException. {"exception":"[object] (Symfony\\Component\\Security\\Core\\Exception\\AuthenticationExpiredException(code: 0):  at /space/products/insurance/vendor/symfony/symfony/src/Symfony/Component/Security/Guard/Provider/GuardAuthenticationProvider.php:86)"} []

I don't understand this "AuthenticationExpiredException" as I have nothing stateless, nor any expiration in any way nowhere in my app.

Does this issue speak to anyone?


Edit 1

After a bunch of hours, it looks like I am unlogged because of the {{ is_granted('ROLE_USER') }} in Twig. Don't see why anyway.

Edit 2

If I dump() my security token on the onAuthenticationSuccess authenticator's method, authenticated = true.

But, If I dump() my security token after a redirect or when accessing a new page, 'authenticated' = false.

Why the hell my authentication isn't stored.


app/config/security.yml

security:

    encoders:
        AppBundle\Security\User\Member:
            algorithm: bcrypt
            cost: 12

    providers:
        members:
            id: app.provider.member

    role_hierarchy:
        ROLE_ADMIN:       "ROLE_USER"

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

        main:
            pattern: "^/"
            anonymous: ~
            logout: ~
            guard:
                authenticators:
                    - app.authenticator.form_login

    access_control:
        - { path: "^/connect", role: "IS_AUTHENTICATED_ANONYMOUSLY" }
        - { path: "^/register", role: "IS_AUTHENTICATED_ANONYMOUSLY" }
        - { path: "^/admin", role: "ROLE_ADMIN" }
        - { path: "^/user", role: "ROLE_USER" }
        - { path: "^/logout", role: "ROLE_USER" }

AppBundle/Controller/SecurityController.php

<?php

namespace AppBundle\Controller;

use AppBundle\Base\BaseController;
use AppBundle\Form\Type\ConnectType;
use AppBundle\Security\User\Member;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Component\HttpFoundation\Request;

class SecurityController extends BaseController
{
    /**
     * @Route("/connect", name="security_connect")
     * @Template()
     */
    public function connectAction(Request $request)
    {
        $connectForm = $this
           ->createForm(ConnectType::class)
           ->handleRequest($request)
        ;

        return [
            'connect' => $connectForm->createView(),
        ];
    }
}

AppBundle/Form/Type/ConnectType.php

<?php

namespace AppBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\Extension\Core\Type;
use Symfony\Component\Validator\Constraints;
use EWZ\Bundle\RecaptchaBundle\Form\Type\EWZRecaptchaType;
use EWZ\Bundle\RecaptchaBundle\Validator\Constraints\IsTrue as RecaptchaTrue;

class ConnectType extends AbstractType
{
    /**
     * @param FormBuilderInterface $builder
     * @param array $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
           ->add('email', Type\EmailType::class, [
               'label'    => 'Your email',
               'required' => true,
               'constraints' => [
                   new Constraints\Length(['min' => 8])
               ],
           ])
           ->add('password', Type\PasswordType::class, [
                'label'       => 'Your password',
                'constraints' => new Constraints\Length(['min' => 8, 'max' => 4096]), /* CVE-2013-5750 */
            ])
           ->add('recaptcha', EWZRecaptchaType::class, [
               'label'       => 'Please tick the checkbox below',
               'constraints' => [
                   new RecaptchaTrue()
               ],
           ])
           ->add('submit', Type\SubmitType::class, [
               'label' => 'Connect',
           ])
        ;
    }
}

AppBundle/Security/Authenticator/FormLoginAuthenticator.php

<?php

namespace AppBundle\Security\Authenticator;

use Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use AppBundle\Form\Type\ConnectType;

class FormLoginAuthenticator extends AbstractFormLoginAuthenticator
{
    private $container; // ¯\_(ツ)_/¯

    public function __construct(ContainerInterface $container)
    {
        $this->container = $container;
    }

    public function getCredentials(Request $request)
    {
        if ($request->getPathInfo() !== '/connect') {
            return null;
        }

        $connectForm = $this
           ->container
           ->get('form.factory')
           ->create(ConnectType::class)
           ->handleRequest($request)
        ;

        if ($connectForm->isValid()) {
            $data = $connectForm->getData();

            return [
                'username' => $data['email'],
                'password' => $data['password'],
            ];
        }

        return null;
    }

    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        return $userProvider->loadUserByUsername($credentials['username']);
    }

    public function checkCredentials($credentials, UserInterface $user)
    {
        $isValid = $this
           ->container
           ->get('security.password_encoder')
           ->isPasswordValid($user, $credentials['password'])
        ;

        if (!$isValid) {
            throw new BadCredentialsException();
        }

        return true;
    }

    protected function getLoginUrl()
    {
        return $this
           ->container
           ->get('router')
           ->generate('security_connect')
        ;
    }

    protected function getDefaultSuccessRedirectUrl()
    {
        return $this
           ->container
           ->get('router')
           ->generate('home')
        ;
    }
}

AppBundle/Security/Provider/MemberProvider.php

<?php

namespace AppBundle\Security\Provider;

use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use AppBundle\Security\User\Member;
use Api\Gateway\RequestResponse\RequestResponseHandlerInterface;
use Api\Business\InsuranceWebsite\Action\GetInsuranceMember\GetInsuranceMemberRequest;
use Api\Gateway\Exception\NoResultException;

class MemberProvider implements UserProviderInterface
{
    protected $gateway;

    public function __construct(RequestResponseHandlerInterface $gateway)
    {
        $this->gateway = $gateway;
    }

    public function loadUserByUsername($username)
    {
        try {
            $response = $this->gateway->handle(
               new GetInsuranceMemberRequest($username)
            );
        } catch (NoResultException $ex) {
            throw new UsernameNotFoundException(
                sprintf('Username "%s" does not exist.', $username)
            );
        }

        $member = new Member();
        $member->setId($response->getId());
        $member->setUsername($response->getEmail());
        $member->setPassword($response->getPassword());
        $member->setCompanyId($response->getCompanyId());
        $member->setFirstname($response->getFirstname());
        $member->setLastname($response->getLastname());
        $member->setIsManager($response->isManager());
        $member->setIsEnabled($response->isEnabled());

        return $member;
    }

    public function refreshUser(UserInterface $user)
    {
        if (!$user instanceof Member) {
            throw new UnsupportedUserException(
                sprintf('Instances of "%s" are not supported.', get_class($user))
            );
        }

        return $this->loadUserByUsername($user->getUsername());
    }

    public function supportsClass($class)
    {
        return $class === Member::class;
    }
}

AppBundle/Security/User/Member.php

<?php

namespace AppBundle\Security\User;

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

class Member implements UserInterface
{
    private $id;
    private $username;
    private $password;
    private $companyId;
    private $firstname;
    private $lastname;
    private $isManager;
    private $isEnabled;
    private $roles = ['ROLE_USER'];

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

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

        return $this;
    }

    public function getUsername()
    {
        return $this->username;
    }

    public function setUsername($username)
    {
        $this->username = $username;

        return $this;
    }

    public function getPassword()
    {
        return $this->password;
    }

    public function setPassword($password)
    {
        $this->password = $password;
        return $this;
    }

    public function getCompanyId()
    {
        return $this->companyId;
    }

    public function setCompanyId($companyId)
    {
        $this->companyId = $companyId;

        return $this;
    }

    public function getFirstname()
    {
        return $this->firstname;
    }

    public function setFirstname($firstname)
    {
        $this->firstname = $firstname;

        return $this;
    }

    public function getLastname()
    {
        return $this->lastname;
    }

    public function setLastname($lastname)
    {
        $this->lastname = $lastname;

        return $this;
    }

    public function isManager()
    {
        return $this->isManager;
    }

    public function setIsManager($isManager)
    {
        $this->isManager = $isManager;

        return $this;
    }

    public function IsEnabled()
    {
        return $this->isEnabled;
    }

    public function setIsEnabled($isEnabled)
    {
        $this->isEnabled = $isEnabled;

        return $this;
    }

    public function eraseCredentials()
    {
        $this->password = null;
    }

    public function hasRole($role)
    {
        return in_array($role, $this->roles);
    }

    public function getRoles()
    {
        return $this->roles;
    }

    public function addRole($role)
    {
        if (!$this->hasRole($role)) {
            $this->roles[] = $role;
        }

        return $this;
    }

    public function removeRole($role)
    {
        $index = array_search($role, $this->roles);
        if ($index !== false) {
            unset($this->roles[$index]);
            $this->roles = array_values($this->roles);
        }

        return $this;
    }

    public function getSalt()
    {
        return null;
    }
}

src/AppBundle/Resources/config/services.yml

imports:

parameters:
    app.provider.member.class: AppBundle\Security\Provider\MemberProvider
    app.authenticator.form_login.class: AppBundle\Security\Authenticator\FormLoginAuthenticator

services:
    app.provider.member:
        class: %app.provider.member.class%
        arguments: ['@gateway']

    app.authenticator.form_login:
        class: %app.authenticator.form_login.class%
        arguments: ["@service_container"]

回答1:

I found my bug, after 8 hours of hard work. I promise, I'll drink a bulk of beers after this comment!

I located my issue in the Symfony\Component\Security\Core\Authentication\Token\AbstractToken::hasUserChanged() method, which compares user stored in the session, and the one returned by the refreshUser of your provider.

My user entity was considered changed because of this condition:

    if ($this->user->getPassword() !== $user->getPassword()) {
        return true;
    }

In fact, before being stored in the session, the eraseCredentials() method is called on your user entity so the password is removed. But the password exists in the user the provider returns.

That's why in documentations, they show plainPassword and password properties... They keep password in the session, and eraseCredentials just cleans up `plainPassword. Kind of tricky.

Se we have 2 solutions:

  • having eraseCredentials not touching password, can be useful if you want to unauthent your member when he changes his password somehow.

  • implementing EquatableInterface in our user entity, because the following test is called before the one above.

    if ($this->user instanceof EquatableInterface) {
        return !(bool) $this->user->isEqualTo($user);
    }
    

I decided to implement EquatableInterface in my user entity, and I'll never forget to do it in the future.

<?php

namespace AppBundle\Security\User;

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

class Member implements UserInterface, EquatableInterface
{

    // (...)

    public function isEqualTo(UserInterface $user)
    {
        return $user->getId() === $this->getId();
    }
}