Lazy loaded relationship not retrieved in async Messages with RabbitMQ - symfony

We have a route where we can either give access or decline access to keys. When either one is done, an async message will be sent (and consumed with RabbitMQ).
This is what is in our Message class (triple dots mean a lot more parameters that have nothing to do with the issue):
public function __construct(private User $user, ...) {
public function getUser(): User {
return $this->user;
}
And this is what is in our MessageHandler class:
dd($message->getUser()->getPerson()->getFirstName());
This is the relationship for getPerson:
/**
* #ORM\OneToOne(targetEntity="App\Entity\Person", inversedBy="user")
* #ORM\JoinColumn(name="person_id", referencedColumnName="id", onDelete="SET NULL", nullable=true)
*/
private $person;
However, consuming the message, it will always dump null. However, if I put this in the controller (right above where it dispatches the message):
dd($user->getPerson()->getFirstName());
It shows my first name fine. This is the function:
public function decline(Key $key, User $user, Form $declineForm, Actor $actor, Request $request, $bus) {
// ....
dd($user->getPerson()->getFirstName());
$bus->dispatch(new KeyConfirmationMessage($user, $key, $actor, $request->server->get('SERVER_NAME'), $reason));
// ...
}
And part of the route:
#[Route("/keys/save-access/{key}/{actor}/{user}", name: "keys_save_access")]
public function saveAccess(Key $key, Actor $actor, User $user, Request $request, KeyManager $keyManager, ManagerRegistry $doctrine, TranslatorInterface $translator, UnsafeKeyRepository $unsafeKeyRepo, SessionService $session, MessageBusInterface $bus) {
// ...
if ($declineForm->isSubmitted() && $declineForm->isValid()) {
return $keyManager->decline($key, $user, $declineForm, $actor, $request, $bus);
}
// ...
}
How can we make it so when trying to get the FirstName from getPerson it will load the relationship correctly, just like how it's done in the controller? Both are similar codes but it seems like the relationship isn't fetched correctly in Messages and we have no clue why that is.

If you need to pass a Doctrine entity in a message, you should pass the entity's primary key or any relevant information the handler actually needs (in your case : firstName) instead of the object.
You can add your class for these changes:
You message class will be :
public function __construct(string $firstName, ...) {
public function getFirstName(): string {
return $this->firstName;
}
Inside your message handler, we can do this :
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
public function __invoke(KeyConfirmationMessage $message)
{
//$firstName = $this->userRepository->findByFirstName($message->getFirstName());
//...
//Retrieve the person first name here anyway
}
Finally, in your controller, before you dispatch, you will do:
$firstName = $user->getPerson()->getFirstName();
//Use firstName string instead
$bus->dispatch(new KeyConfirmationMessage($firstName, $key, $actor, $request->server->get('SERVER_NAME'), $reason));
// ...

Related

Api-platform, JWT token and endpoints sending back data owned by the identified user

I'm using PHP symfony with API-platform with JWT token (through LexikJWTAuthenticationBundle), latest version as of today.
I've read quite a lot of things and I know how to do the basic stuff:
Create an API exposing my entities,
Protect certain endpoints with JWT
Protecting certain endpoints with user_roles
What I'm trying to do now is to have the API only sends back data that belongs to a user instead of simply sending back everything contained in the database and represented by an entity. I've based my work on this but this does not take into account the JWT token and I don't know how to use the token in the UserFilter class : https://api-platform.com/docs/core/filters/#using-doctrine-orm-filters
Here is my Book entity :
<?php
// api/src/Entity/Book.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use App\Entity\User;
use App\Attribute\UserAware;
/** A book. */
#[ORM\Entity]
#[ApiResource(operations: [
new Get(),
new GetCollection(),
new Post(),
new Put(),
new Patch(),
new Delete()
])]
#[UserAware(userFieldName: "id")]
class Book
{
/** The id of this book. */
#[ORM\Id, ORM\Column, ORM\GeneratedValue]
private ?int $id = null;
/** The ISBN of this book (or null if doesn't have one). */
#[ORM\Column(nullable: true)]
#[Assert\Isbn]
public ?string $isbn = null;
/** The title of this book. */
#[ORM\Column]
#[Assert\NotBlank]
public string $title = '';
/** The description of this book. */
#[ORM\Column(type: 'text')]
#[Assert\NotBlank]
public string $description = '';
/** The author of this book. */
#[ORM\Column]
#[Assert\NotBlank]
public string $author = '';
/** The publication date of this book. */
#[ORM\Column(type: 'datetime')]
#[Assert\NotNull]
public ?\DateTime $publicationDate = null;
/** #var Review[] Available reviews for this book. */
#[ORM\OneToMany(targetEntity: Review::class, mappedBy: 'book', cascade: ['persist', 'remove'])]
public iterable $reviews;
#[ORM\Column(length: 255, nullable: true)]
private ?string $publisher = null;
/** The book this user is about. */
#[ORM\ManyToOne(inversedBy: 'books')]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id')]
#[Assert\NotNull]
public ?User $user = null;
public function __construct()
{
$this->reviews = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getPublisher(): ?string
{
return $this->publisher;
}
public function setPublisher(?string $publisher): self
{
$this->publisher = $publisher;
return $this;
}
}
Here is my UserFilter class :
<?php
// api/src/Filter/UserFilter.php
namespace App\Filter;
use App\Attribute\UserAware;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use App\Entity\User;
final class UserFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
{
// The Doctrine filter is called for any query on any entity
// Check if the current entity is "user aware" (marked with an attribute)
$userAware = $targetEntity->getReflectionClass()->getAttributes(UserAware::class)[0] ?? null;
$fieldName = $userAware?->getArguments()['userFieldName'] ?? null;
if ($fieldName === '' || is_null($fieldName)) {
return '';
}
try {
$userId = $this->getParameter('id');
// Don't worry, getParameter automatically escapes parameters
} catch (\InvalidArgumentException $e) {
// No user id has been defined
return '';
}
if (empty($fieldName) || empty($userId)) {
return '';
}
return sprintf('%s.%s = %s', $targetTableAlias, $fieldName, $userId);
}
}
Here is my UserAware class :
<?php
// api/Annotation/UserAware.php
namespace App\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
final class UserAware
{
public $userFieldName;
}
I added this to my config/packages/api_platform.yaml file:
doctrine:
orm:
filters:
user_filter:
class: App\Filter\UserFilter
enabled: true
It obviously does not work, since I'm not making the bridge between the JWT token and the filter, but I have no idea how to do it. What am I missing?
The current results I have is that the GET /api/books sends back all the books stored in the database instead of sending only the ones belonging to the JWT authenticated user.
EDIT:
And for those who want the answer for ManyToMany related entities here it is : Api-platform, filtering collection result based on JWT identified user on a ManyToMany relational entity
Instead of Doctrine Filter, you could use Doctrine Extension as described here.
In your case it would need:
Create the doctrine extension:
<?php
// api/src/Doctrine/CurrentUserExtension.php
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Book;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Security\Core\Security;
final class CurrentUserExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
private $security;
public function __construct(Security $security)
{
$this->security = $security;
}
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void
{
$this->addWhere($queryBuilder, $resourceClass);
}
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, Operation $operation = null, array $context = []): void
{
$this->addWhere($queryBuilder, $resourceClass);
}
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
{
if (Book::class !== $resourceClass || $this->security->isGranted('ROLE_ADMIN') || null === $user = $this->security->getUser()) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere(sprintf('%s.user = :current_user', $rootAlias));
$queryBuilder->setParameter('current_user', $user->getId());
}
}
The main logic is in the addWhere() method:
applies only if you are dealing with Book entity (but you could extend the idea to a list of entities here)
check if the user is granted admin (if so here it skips the extension, allowing admin to fetch all books)
skip if the user isn't authenticated (you should prevent this access with firewall or security arribute in your endpoints)
Then it adds a where condition to the SQL query to filter by userId (or any other condition you'll need)
Don't forget to eanble your filter:
# api/config/services.yaml
services:
# ...
'App\Doctrine\CurrentUserExtension':
tags:
- { name: api_platform.doctrine.orm.query_extension.collection }
- { name: api_platform.doctrine.orm.query_extension.item }

Token Storage Problems Symfony 5 Custom Login Authenticator

When the user logs in to the system, I need to fill a class variable (Login-> testInfo) with information, but in the controller the variable always returns null.
Here is a generic example.
The Login class
class Login extends UserInterface
{
private $testInfo = null;
public function setTestInfo(string $testInfo)
{
$this->testInfo = $testInfo;
}
public function getTestInfo() : ?string
{
return $this->testInfo;
}
}
The Authenticator:
class FormAuthenticator extends AbstractFormLoginAuthenticator
{
...
public function getUser($credentials, UserProviderInterface $userProvider)
{
$user = $this->entityManager->getRepository(Login::class)->findByUsername(credentials['username']);
if (!$user)
{
throw new CustomUserMessageAuthenticationException('Username could not be found.');
}
//this prints NULL
dd($user->getTestInfo());
$user->setTestInfo('testing the string');
//this prints 'testing the string'
dd($user->getTestInfo());
return $user;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
//this prints 'testing the string'
dd($token->getUser()->getTestInfo());
}
...
}
The Controller Class:
class MyController extends AbstractController
{
private $login = null;
public function __construct(TokenStorageInterface $tokenStorage)
{
$this->login = $tokenStorage->getToken() ? $tokenStorage->getToken()->getUser() : null;
}
public function home()
{
//this prints null
dd($this->login->getTestInfo());
}
}
If $user goes to the tokenStorage with the new value ('testing the string'), why, when I try to use it on the controller, does the variable always return null? what am I doing wrong?
Is testInfo a transient variable? Because you gotta know that there is UserProvider that tries to refresh user from token (maybe it could be "changed" somehow between requests). I'm pretty sure you're losing those infos right in this process.
Are you sure your controller constructor isn't being executed too soon, prior to the authentication success event writing the token to the token storage service? I'd dd() the token in the constructor to verify if the token and Login instance are present at that point.
You may need to use setContainer() instead of __construct() in your controller to retrieve the authenticated token, which would look something like this:
private $tokenStorage = null;
private $login = null;
public function __construct(TokenStorageInterface $tokenStorage)
{
$this->tokenStorage = $tokenStorage;
}
/**
* #param ContainerInterface $container Symfony service container interface
* #return ContainerInterface|null
*/
public function setContainer(\Psr\Container\ContainerInterface $container): ?\Psr\Container\ContainerInterface
{
if ($this->tokenStorage instanceof TokenStorageInterface && $this->tokenStorage->getToken() instanceof TokenInterface && $this->tokenStorage->getToken()->getUser() instanceof Login) {
$this->login = $this->tokenStorage->getToken()->getUser();
}
return $container;
}

False "new entity found through the relationship" - Object actually exists in database

We have a service which purpose is to log user actions into database. The underlying entity ActionLog has a manyToOne relation with our User entity. Both those entities are tied to the same DBAL connection (and ORM EntityManager).
Issue is: an exception is raised when a new ActionLog entity is persisted and flushed, saying we should cascade persist the object set as the #user property because considered new:
A new entity was found through the relationship 'Doctrine\Model\ActionLog#user' that was not configured to cascade persist operations for entity: John Doe. To solve this issue: Either explicitly call EntityManager#persist() on this unknown entity or configure cascade persist this association in the mapping for example #ManyToOne(..,cascade={"persist"}).
And this is annoying because the User instance actually comes straight from the database and as such isn't new at all! We expect this User object to already be "MANAGED" by the entityManager and be referenced through the identity map (in other words, the object is not "detached").
So, why would Doctrine consider the User entity instance (authenticated user) as detached/new?
Using Symfony 4.0.6 ; doctrine/orm v2.6.1, doctrine/dbal 2.6.3, doctrine/doctrine-bundle 1.8.1
ActionLog model mapping extract
Doctrine\Model\ActionLog:
type: entity
table: action_log
repositoryClass: Doctrine\Repository\ActionLogRepository
manyToOne:
user:
targetEntity: Doctrine\Model\User
id: # …
fields: # …
Log service declaration
log_manager:
class: Service\Log\LogManager
public: true
arguments:
- "#?security.token_storage"
calls:
# setter required instead of the dependency injection
# to prevent circular dependency.
- ['setEntityManager', ["#doctrine.orm.entity_manager"]]
Log service implementation - Creates new ActionLog record
<?php
namespace Service\Log;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Security\Core\User\UserInterface;
use Doctrine\Model\User;
use Doctrine\Model\ActionLog;
class LogManager
{
/**
* #var ObjectManager
*/
protected $om;
/**
* #var TokenStorage
*/
protected $tokenStorage;
/**
* #var User
*/
protected $user;
/**
* #var bool
*/
protected $disabled = false;
public function __construct(TokenStorage $tokenStorage = null)
{
$this->tokenStorage = $tokenStorage;
}
public function setEntityManager(ObjectManager $om)
{
$this->om = $om;
}
public function log(string $namespace, string $action, string $message = null, array $changeSet = null)
{
$log = new ActionLog;
$log
->setNamespace($namespace)
->setAction($action)
->setMessage($message)
->setChangeset($changeSet)
;
if ($this->isDisabled()) {
return;
}
if (!$log->getUser()) {
$user = $this->getUser();
$log->setUsername(
$user instanceof UserInterface
? $user->getUsername()
: ''
);
$user instanceof User && $log->setUser($user);
}
$this->om->persist($log);
$this->om->flush();
}
public function setUser(User $user): self
{
$this->user = $user;
return $this;
}
public function getUser(): ?UserInterface
{
if (!$this->user) {
if ($token = $this->tokenStorage->getToken()) {
$this->user = $token->getUser();
}
}
return is_string($this->user) ? null : $this->user;
}
public function disable(bool $disabled = true): self
{
$this->disabled = $disabled;
return $this;
}
public function isDisabled(): bool
{
return $this->disabled;
}
}
User entity dump. As you can see infos come from the database.
User {#417 ▼
#name: "John Doe"
#email: "john_doe#example.com"
#password: "ec40577ad8057ee34ce0bb9414673bf3"
#createdAt: DateTime #1523344938 {#427 ▶}
#enabled: true
#lastLogin: null
#id: 1
}
# Associated database row
'1', 'John Doe', 'john_doe#example.com', 'ec40577ad8057ee34ce0bb9414673bf3', '2018-04-10 07:22:18', '1', '1', null
The assert was right, the User instance being passed to ActionLog::setUser method was not a known reference from the ORM point of view.
What happens: the object comes from the authentication process which unserialize the User data from session storage on each request (what suggested yceruto) and a User instance is created.
My custom userProvider should refresh the user object via the ORM but it doesn't, hence the "new reference" upon persist. I have no idea why although my UserProvider implementation lets suppose it should:
/**
* #var ObjectManager
*/
protected $em;
public function __construct(ObjectManager $em)
{
$this->em = $em;
}
public function loadUserByUsername($username)
{
$user = $this->em->getRepository(User::class)->loadUserByUsername($username);
if ($user && $user->isAdmin()) {
return $user;
}
throw new UsernameNotFoundException(
sprintf('Username "%s" does not exist.', $username)
);
}
public function refreshUser(UserInterface $user)
{
return $this->loadUserByUsername($user->getUsername());
}
public function supportsClass($class)
{
return UserInterface::class === $class;
}
This said, I managed to (temporarily) solve the issue using the ORM proxy mechanism with the help of the Doctrine\ORM\EntityManager::getReference method, this can be done since the rebuilt object from session holds the User id (primary key).
The fix consist in replacing the following instruction in the Log_manager service:
$this->user = $token->getUser();
# ↓ BECOMES ↓
$this->user = $this->om->getReference(User::class, $token->getUser()->getId());
Any idea on this? Misuse? Github issue? Whatever the reason, comments are quite welcome.

Symfony2: Call Voter from another Voter

I am using Voters to restrict access to entities in a REST API.
Step 1
Consider this voter that restricts users access to blog posts:
class BlogPostVoter extends Voter
{
public function __construct(AccessDecisionManagerInterface $decisionManager)
{
$this->decisionManager = $decisionManager;
}
/**
* Determines if the attribute and subject are supported by this voter.
*
* #param string $attribute An attribute
* #param int $subject The subject to secure, e.g. an object the user wants to access or any other PHP type
*
* #return bool True if the attribute and subject are supported, false otherwise
*/
protected function supports($attribute, $subject)
{
if (!in_array($attribute, $this->allowedAttributes)) {
return false;
}
if (!$subject instanceof BlogPost) {
return false;
}
return true;
}
/**
* Perform a single access check operation on a given attribute, subject and token.
*
* #param string $attribute
* #param mixed $subject
* #param TokenInterface $token
* #return bool
* #throws \Exception
*/
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
return $this->canUserAccess($attribute, $subject, $token);
}
public function canUserAccess($attribute, $subject, TokenInterface $token) {
if ($this->decisionManager->decide($token, array('ROLE_SUPPORT', 'ROLE_ADMIN'))) {
return true;
}
//other logic here omitted ...
return false;
}
}
You can see there is a public function canUserAccess to determine if the user is allowed to see the BlogPost. This all works just fine.
Step 2
Now I have another voter that checks something else, but also needs to check this same exact logic for BlogPosts. My thought was to:
add a new voter
perform some other checks
but then also perform this BlogPost check
So I thought I would inject the BlogPostVoter into my other voter like this:
class SomeOtherVoter extends Voter
{
public function __construct(BlogPostVoter $blogPostVoter)
{
$this->decisionManager = $decisionManager;
}
...
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
//other logic
if ($this->blogPostVoter->canUserAccess($attribute, $subject, $token)) {
return true;
}
return false;
}
}
Problem
When I do this I get the following error, using both setter and constructor injection:
Circular reference detected for service "security.access.decision_manager", path: "security.access.decision_manager"
I don't see where the security.access.decision_manager should depend on the Voter implementations. So I'm not seeing where the circular reference is.
Is there another way I can call VoterA from VoterB?
To reference VoterOne from VoterTwo you can inject the AuthorizationCheckerInterface into VoterTwo and then call ->isGranted('ONE'). Where ONE is the supported attribute of VoterOne.
Like:
class VoterTwo extends Voter
{
private $authorizationChecker;
public function __construct(AuthorizationCheckerInterface $authorizationChecker)
{
$this->authorizationChecker = $authorizationChecker;
}
protected function supports($attribute, $subject)
{
return in_array($attribute, ['TWO']);
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
return $this->authorizationChecker->isGranted('ONE', $subject);
}
}
In this example VoterTwo does just redirect the request to VoterOne (or the voter that supports the attribute ONE). This can then be extended through additional conditions.

Symfony2: SonataAdminBundle - How can i get the object representing the current user inside an admin class?

I use the sonata-admin bundle.
I have the relationship with the user (FOSUserBundle) in the PageEntity.
I want to save the current user which create or change a page.
My guess is get the user object in postUpdate and postPersist methods of the admin class and this object transmit in setUser method.
But how to realize this?
On the google's group I saw
public function setSecurityContext($securityContext) {
$this->securityContext = $securityContext;
}
public function getSecurityContext() {
return $this->securityContext;
}
public function prePersist($article) {
$user = $this->getSecurityContext()->getToken()->getUser();
$appunto->setOperatore($user->getUsername());
}
but this doesn't work
In the admin class you can get the current logged in user like this:
$this->getConfigurationPool()->getContainer()->get('security.token_storage')->getToken()->getUser()
EDIT based on feedback
And you are doing it this? Because this should work.
/**
* {#inheritdoc}
*/
public function prePersist($object)
{
$user = $this->getConfigurationPool()->getContainer()->get('security.token_storage')->getToken()->getUser();
$object->setUser($user);
}
/**
* {#inheritdoc}
*/
public function preUpdate($object)
{
$user = $this->getConfigurationPool()->getContainer()->get('security.token_storage')->getToken()->getUser();
$object->setUser($user);
}
Starting with symfony 2.8, you should use security.token_storage instead of security.context to retrieve the user. Use constructor injection to get it in your admin:
public function __construct(
$code,
$class,
$baseControllerName,
TokenStorageInterface $tokenStorage
) {
parent::__construct($code, $class, $baseControllerName);
$this->tokenStorage = $tokenStorage;
}
admin.yml :
arguments:
- ~
- Your\Entity
- ~
- '#security.token_storage'
then use $this->tokenStorage->getToken()->getUser() to get the current user.
I was dealing with this issue on the version 5.3.10 of symfony and 4.2 of sonata. The answer from greg0ire was really helpful, also this info from symfony docs, here is my approach:
In my case I was trying to set a custom query based on a property from User.
// ...
use Symfony\Component\Security\Core\Security;
final class YourClassAdmin extends from AbstractAdmin {
// ...
private $security;
public function __construct($code, $class, $baseControllerName, Security $security)
{
parent::__construct($code, $class, $baseControllerName);
// Avoid calling getUser() in the constructor: auth may not
// be complete yet. Instead, store the entire Security object.
$this->security = $security;
}
// customize the query used to generate the list
protected function configureQuery(ProxyQueryInterface $query): ProxyQueryInterface
{
$query = parent::configureQuery($query);
$rootAlias = current($query->getRootAliases());
// ..
$user = $this->security->getUser();
// ...
return $query;
}
}

Resources