Symfony 验证器:在控制器中,Doctrine returns 带有空字符串字段的用户项,尽管设置了一个值

Symfony authenticator: In controllers, Doctrine returns user item with empty string field, although a value is set

我的数据库模式主要由以下实体组成:用户和 auth_token。每个用户可以有多个auth_token。

问题:在控制器中选择当前经过身份验证的用户时,字符串字段 saltedPasswordHash 为空 (""),尽管在数据库中设置了一个值。在 ApiKeyAuthenticator.php 中获取 saltedPasswordHash 有效(请查看两个 TODO 注释)。

无论出于何种原因,选择电子邮件(字符串)或创建(日期时间)字段都有效。使用 saltedPasswordHash 保留新用户实体或选择任何其他用户项目都可以正常工作。

APIKeyAuthenticator 正在处理授权。禁用防火墙和身份验证时,一切都按预期工作。我在下面包含了源文件。

我正在使用 PHP 7.2.15-1 和 mysql Ver 15.1 Distrib 10.3.13-MariaDB。

Security/ApiKeyAuthenticator.php

namespace App\Security;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authentication\SimplePreAuthenticatorInterface;

class ApiKeyAuthenticator implements SimplePreAuthenticatorInterface
{
    public function createToken(Request $request, $providerKey)
    {
        $apiKey = $request->headers->get('authToken');

        if (!$apiKey) {
            throw new BadCredentialsException();

            // or to just skip api key authentication
            // return null;
        }

        return new PreAuthenticatedToken(
            'anon.',
            $apiKey,
            $providerKey
        );
    }

    public function supportsToken(TokenInterface $token, $providerKey)
    {
        return $token instanceof PreAuthenticatedToken && $token->getProviderKey() === $providerKey;
    }

    public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
    {
        if (!$userProvider instanceof ApiKeyUserProvider) {
            throw new \InvalidArgumentException(
                sprintf(
                    'The user provider must be an instance of ApiKeyUserProvider (%s was given).',
                    get_class($userProvider)
                )
            );
        }

        $apiKey = $token->getCredentials();
        $username = $userProvider->getUsernameForApiKey($apiKey);

        if (!$username) {
            // CAUTION: this message will be returned to the client
            // (so don't put any un-trusted messages / error strings here)
            throw new BadCredentialsException(
                sprintf('API Key "%s" does not exist.', $apiKey)
            );
        }

        $user = $userProvider->loadUserByAuthToken($apiKey);

        if (!isset($user)) {
            throw new BadCredentialsException(
                sprintf('API Key "%s" does not exist.', $apiKey)
            );
        }

        // TODO: HERE, THE $user->getSaltedPasswordHash() RETURNS THE CORRECT VALUE!

        return new PreAuthenticatedToken(
            $user, // TODO: with "new User()" instead, it works!
            $apiKey,
            $providerKey,
            $user->getRoles()
        );
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {
        return new Response(
        // this contains information about *why* authentication failed
        // use it, or return your own message
            strtr($exception->getMessageKey(), $exception->getMessageData()),
            401
        );
    }
}

Security/ApiKeyUserProvider.php

namespace App\Security;

use App\Entity\AuthToken;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\User\User;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;

class ApiKeyUserProvider implements UserProviderInterface
{
    /**
     * @var EntityManagerInterface
     */
    private $em;

    /**
     * ApiKeyUserProvider constructor.
     * @param EntityManagerInterface $em
     */
    public function __construct(EntityManagerInterface $em)
    {
        $this->em = $em;
    }

    public function getUsernameForApiKey($apiKey)
    {
        return $apiKey;
    }

    public function loadUserByUsername($username)
    {
        // TODO: Implement loadUserByUsername() method.
    }

    /**
     * Auth token is used as username
     *
     * @param string $authToken
     * @return null|UserInterface
     */
    public function loadUserByAuthToken($authToken): ?UserInterface
    {
        if (!isset($authToken)) {
            return null;
        }

        $token = $this->em
            ->getRepository(AuthToken::class)
            ->findOneBy(['id' => AuthToken::hex2dec($authToken)]);

        if (!isset($token)) {
            return null;
        }

        return $token->getUser();
    }

    public
    function refreshUser(UserInterface $user)
    {
        // this is used for storing authentication in the session
        // but in this example, the token is sent in each request,
        // so authentication can be stateless. Throwing this exception
        // is proper to make things stateless
        throw new UnsupportedUserException();
    }

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

Entity/User.php

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\EquatableInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
 */
class User implements UserInterface, EquatableInterface
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * This needs to be nullable because the email includes the id of newly created users, which can only be obtained after inserting the new record.
     * @ORM\Column(type="string", length=255, nullable=true, unique=true)
     * @Assert\Length(max=255)
     * @Assert\NotBlank()
     */
    private $email;

    /**
     * Set null to disable login
     * @ORM\Column(type="string", length=255, nullable=true)
     * @Assert\Length(max=255)
     * @Assert\NotBlank()
     */
    private $saltedPasswordHash;

    /**
     * @ORM\Column(type="datetime")
     */
    private $created;

    // ...

    /**
     * @ORM\OneToMany(targetEntity="App\Entity\AuthToken", mappedBy="user", fetch="LAZY")
     */
    private $authTokens;

    /**
     * @ORM\Column(type="string", length=5)
     */
    private $role;

    // ...

    public function __construct()
    {
        $this->role = 'user';
        $this->saltedPasswordHash = null;
    }

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

    public function getSaltedPasswordHash(): ?string
    {
        return $this->saltedPasswordHash;
    }

    public function setSaltedPasswordHash(?string $saltedPasswordHash): self
    {
        $this->saltedPasswordHash = $saltedPasswordHash;

        return $this;
    }

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

    public function setEmail(?string $email): self
    {
        $this->email = $email;

        return $this;
    }

    public function getCreated(): ?\DateTimeInterface
    {
        return $this->created;
    }

    public function setCreated(\DateTimeInterface $created): self
    {
        $this->created = $created;

        return $this;
    }


    /**
     * @return ArrayCollection
     */
    public function getAuthTokens()
    {
        return $this->authTokens;
    }

    /**
     * @param ArrayCollection $authTokens
     * @return User
     */
    public function setAuthTokens(ArrayCollection $authTokens): User
    {
        $this->authTokens = $authTokens;

        return $this;
    }

    /**
     * @param AuthToken $authToken
     * @return User
     */
    public function addAuthToken(AuthToken $authToken): User
    {
        $this->authTokens->add($authToken);

        return $this;
    }

    /**
     * @param AuthToken $authToken
     * @return User
     */
    public function removeAuthToken(AuthToken $authToken): User
    {
        $this->authTokens->removeElement($authToken);

        return $this;
    }

    // ...

    /**
     * Returns the password used to authenticate the user.
     *
     * This should be the encoded password. On authentication, a plain-text
     * password will be salted, encoded, and then compared to this value.
     *
     * @return string The password
     */
    public function getPassword()
    {
        return $this->getSaltedPasswordHash();
    }

    /**
     * Returns the salt that was originally used to encode the password.
     *
     * This can return null if the password was not encoded using a salt.
     *
     * @return string|null The salt
     */
    public function getSalt()
    {
        // TODO: Implement getSalt() method.
    }

    /**
     * Returns the username used to authenticate the user.
     *
     * @return string The username
     */
    public function getUsername()
    {
        return $this->getEmail();
    }

    /**
     * Removes sensitive data from the user.
     *
     * This is important if, at any given point, sensitive information like
     * the plain-text password is stored on this object.
     */
    public function eraseCredentials()
    {
        $this->setSaltedPasswordHash('');
    }

    /**
     * @return mixed
     */
    public function getRole()
    {
        return $this->role;
    }

    /**
     * @param mixed $role
     * @return User
     */
    public function setRole($role): User
    {
        $this->role = $role;

        return $this;
    }

    /**
     * @return string
     */
    public function __toString()
    {
        return "User " . $this->email;
    }

    /**
     * The equality comparison should neither be done by referential equality
     * nor by comparing identities (i.e. getId() === getId()).
     *
     * However, you do not need to compare every attribute, but only those that
     * are relevant for assessing whether re-authentication is required.
     *
     * Also implementation should consider that $user instance may implement
     * the extended user interface `AdvancedUserInterface`.
     *
     * 
     *
     * @param UserInterface $user
     * @return bool
     */
    public function isEqualTo(UserInterface $user)
    {
        return (
                $this->getUsername() == $user->getUsername()
            ) && (
                $this->getRoles() == $user->getRoles()
            );
    }

    // ...
}

Entitiy/AuthToken.php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="App\Repository\AuthTokenRepository")
 */
class AuthToken
{
    /**
     * @ORM\Id()
     * @ORM\Column(type="decimal", precision=32, scale=0, options={"unsigned": true})
     */
    private $id;

    /**
     * @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="authTokens")
     */
    private $user;

    /**
     * @ORM\Column(type="datetime")
     */
    private $added;

    /**
     * @ORM\Column(type="datetime", nullable=true)
     */
    private $lastSeen;

    /**
     * @ORM\Column(type="string", length=12, nullable=true)
     */
    private $apiVersion;

    /**
     * @return string
     */
    public function getId(): string
    {
        return $this->id;
    }

    /**
     * @return string
     */
    public function getHexId(): string
    {
        return $this->dec2hex($this->id);
    }

    /**
     * @param mixed $id
     */
    public function setId($id): void
    {
        $this->id = $id;
    }

    /**
     * @param mixed $id
     * @throws \Exception
     */
    public function generateId(): void
    {
        $length = 32;

        $str = "";
        $characters = range('0', '9');
        $max = count($characters) - 1;

        for ($i = 0; $i < $length; $i++) {
            $rand = random_int(0, $max);
            $str .= $characters[$rand];
        }

        $this->id = $str;
    }

    /**
     * @return mixed
     */
    public function getUser()
    {
        return $this->user;
    }

    /**
     * @param mixed $user
     */
    public function setUser($user): void
    {
        $this->user = $user;
    }

    /**
     * @return mixed
     */
    public function getAdded()
    {
        return $this->added;
    }

    /**
     * @param mixed $added
     */
    public function setAdded($added): void
    {
        $this->added = $added;
    }

    /**
     * @return mixed
     */
    public function getLastSeen()
    {
        return $this->lastSeen;
    }

    /**
     * @param mixed $lastSeen
     */
    public function setLastSeen($lastSeen): void
    {
        $this->lastSeen = $lastSeen;
    }

    public function getApiVersion(): ?string
    {
        return $this->apiVersion;
    }

    public function setApiVersion(string $apiVersion): self
    {
        $this->apiVersion = $apiVersion;

        return $this;
    }

    public static function dec2hex(string $dec): string
    {
        $hex = '';
        do {
            $last = bcmod($dec, 16);
            $hex = dechex($last) . $hex;
            $dec = bcdiv(bcsub($dec, $last), 16);
        } while ($dec > 0);
        return $hex;
    }

    public static function hex2dec($hex)
    {
        $dec = '0';
        $len = strlen($hex);
        for ($i = 1; $i <= $len; $i++)
            $dec = bcadd($dec, bcmul(strval(hexdec($hex[$i - 1])), bcpow('16', strval($len - $i))));

        return $dec;
    }

    /**
     * @return string
     */
    public function __toString()
    {
        return "AuthToken " . $this->id . " (" . $this->user . ")";
    }

}

你的Entity/User.php的这个功能是你行为的原因:

/**
 * Removes sensitive data from the user.
 *
 * This is important if, at any given point, sensitive information like
 * the plain-text password is stored on this object.
 */
public function eraseCredentials()
{
    $this->setSaltedPasswordHash('');
}

当身份验证在 Symfony 上发挥作用时,在身份验证之后,the AuthenticationProviderManager would call that eraseCredentials function,所以它不会泄露敏感信息,或者更糟的是,敏感信息不会在您的会话中结束。

只需尝试在该函数中注释 setter,您应该会得到您期望的结果。