Web Development with Symfony: An application capable of dispatching push notifications to mobile devices via FCM [part 6]

in #utopian-io7 years ago (edited)

email-marketing-2362038_640.png

Source: pixabay.com. Licensed under CC0 Creative Commons

What will I learn?

  • How to create a voter mechanism to check user permissions

Requirements

  • UNIX/Linux based OS
  • Apache2 server with PHP7 installed
  • MySQL database
  • Text editor of choice
  • Existing Firebase project
  • Composer
  • Base project found here

Difficulty

  • Intermediate

Tutorial contents

This tutorial is a sixth one in the Web Development series where we jump into details on how to develop applications with Symfony using a sample project capable of sending push notifications to mobile devices. In the previous article the process of creating a friendly configuration for a bundle with the Config Component was described. Additionally the concept of defining loose relationships between entities, during the ORM association mapping process, was laid down.

A sample project used with this tutorial can be found here. It is basically the code you will end up with after completing the previous tutorial. It is recommended to clone the aforementioned project from the given github repository.

What aspects of Symfony web development will be covered in this tutorial?

  • The process of creating a voting mechanism that allows for checking if a given user has permissions to access a specific RESTful API resource.

An example usage for the created functionalities will be shown at the end of this tutorial.

How to create a voter mechanism to check user permissions?

An example authentication system described in the fifth part of the Web Development with Symfony series allows for an access to RESTful API resources for authorised users only. This means that anyone who owns an api key, which corresponds to an existing user entity, can interact with our application via HTTP protocol.

In this tutorial we will go a step further. Besides an authentication system, our application will be extended with a voting mechanism, which will be responsible for verifying if a specific user has adequate permissions to access a given resource.

Modifying a device entity class

The responsibility of a device voting mechanism will be to make decision whether a user can can interact with a device entity. The decision will be made based on an existing relation between an authorised user and a device object. Before this can be achieved, an ORM association mapping definition for a device - user relation has to be added first.

An abstraction layer

Prior to adding a mapping definition, it is recommended to add setUser(UserInterface $user) and getUser() method declarations to a DeviceInterface interface.

    // src/Ptrio/MessageBundle/Model/DeviceInterface.php
   // other method declarations
    /**
     * @param UserInterface $user
     */
    public function setUser(UserInterface $user);
    /**
     * @return UserInterface|null
     */
    public function getUser(): ?UserInterface;

In the next step, add a Device::$user property to a base Device class.

    // src/Ptrio/MessageBundle/Model/Device.php
    // other properties
    /**
     * @var UserInterface|null
     */
    protected $user;

To finish off with an abstraction layer, User::setUser(UserInterface $user) and User::getUser() methods should be implemented.

    /**
     * {@inheritdoc}
     */
    public function setUser(UserInterface $user)
    {
        $this->user = $user;
    }
    /**
     * {@inheritdoc}
     */
    public function getUser(): ?UserInterface
    {
        return $this->user;
    }
A concrete class

Since a voting mechanism will only verify if a given device is related to a user making a request, adding a unidirectional many-to-one association mapping will be just enough.

    // src/Ptrio/MessageBundle/Entity/Device.php
    // other properties
    /**
     * @ORM\ManyToOne(targetEntity="App\Ptrio\MessageBundle\Model\UserInterface")
     * @ORM\JoinColumn(name="user_id", referencedColumnName="id")
     */
    protected $user;
Updating a bundle configuration

As shown in the example above, a targetEntity parameter is assigned a fully-qualified interface name, which equals to App\Ptrio\MessageBundle\Model\UserInterface.

As mentioned earlier, in the previous tutorial, the process of creating a loose relation between entities was presented. This was possible due to a ResolveTargetEntityListener type object which is capable of replacing an abstract type defined in a targetEntity parameter to a concrete class during a bundle initialisation.

To establish a device - user relation in a similar way, it is necessary to update files contained in a src/Ptrio/MessageBundle/DependencyInjection directory.

Begin by adding a interface node to a classes node in a Configuration.php file.

->arrayNode('user')
                    ->children()
                        ->arrayNode('classes')
                            ->children()
                                ->scalarNode('model')->isRequired()->cannotBeEmpty()->end()
                                ->scalarNode('interface')->cannotBeEmpty()->defaultValue('App\Ptrio\MessageBundle\Model\UserInterface')->end() // add this line
                            ->end()
                        ->end()
                    ->end()
                ->end()

A default value for a interface node is App\Ptrio\MessageBundle\Model\UserInterface.

An example array created with the above configuration tree is presented below.

[
    [
        // other array elements
        'user' => [
            'classes' => [
                'model' => 'App\Ptrio\MessageBundle\Entity\User',
                'interface' => 'App\Ptrio\MessageBundle\Model\UserInterface'
            ]
        ]
    ]
]

In the next step, a PtrioMessageExtension::prepend(ContainerBuilder $container) method body should be updated with an information about an interface that is supposed to be replaced with a concrete class.

        if (isset($bundles['DoctrineBundle'])) {
            $container
                ->loadFromExtension('doctrine', [
                    'orm' => [
                        'resolve_target_entities' => [
                            $config['device']['classes']['interface'] => $config['device']['classes']['model'],
                            $config['user']['classes']['interface'] => $config['user']['classes']['model'], // add this line
                        ],
                    ],
                ])
            ;
        }

A Doctrine migrations script has to be executed to update the database schema.

A voting mechanism class

The responsibility of a voting mechanism is to grant access to a device resource if it is related to a user making a request, or throw an exception, with a corresponding HTTP code, if a user does not have required permissions.

Note: Since a base Symfony\Component\Security\Core\Authorization\Voter\Voter voter class comes bundled with the Symfony framework, creating an abstraction layer will not be necessary.

A concrete class

Start by adding a file called DeviceVoter.php to a src/Ptrio/MessageBundle/Security/Voter directory.

A DeviceVoter concrete class has to implement both Voter::supports($attribute, $subject) and Voter::voteOnAttribute($attribute, $subject, TokenInterface $token) methods.

<?php
// src/Ptrio/MessageBundle/Security/Voter/DeviceVoter.php
namespace App\Ptrio\MessageBundle\Security\Voter;
use App\Ptrio\MessageBundle\Model\DeviceInterface;
use App\Ptrio\MessageBundle\Model\UserInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class DeviceVoter extends Voter
{
    /**
     * {@inheritdoc}
     */
    protected function supports($attribute, $subject)
    {
        return $subject instanceof DeviceInterface;
    }
    /**
     * {@inheritdoc}
     */
    protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
    {
        $user = $token->getUser();
        if (!$user instanceof UserInterface) {
            return false;
        }        
        /** @var DeviceInterface $device */
        $device = $subject;        
        return $device->getUser() === $user;
    }
}

Let’s go over some of the most important aspects found in an example DeviceVoter class presented above.

A DeviceVoter::supports($attribute, $subject) method checks whether a given attribute and a subject are supported by a voting mechanism.

Note: In the example above, a $attribute argument is not being used, since the decision on granting or denying an access is made based only on an existing device - user relation.

A DeviceVoter::voteOnAttribute($attribute, $subject, TokenInterface $token) contains logic responsible for validating if an access should be granted. In our example, it comes down to checking if an authorised user is assigned to a given device. The aforementioned method returns a boolean value as a result.

Service configuration

Before a voting mechanism service can be used with our application, adding a ptrio_message.device_voter service definition to a services.yaml file is necessary.

    # src/Ptrio/MessageBundle/Resources/config/services.yaml
    ptrio_message.device_voter:
        class: 'App\Ptrio\MessageBundle\Security\Voter\DeviceVoter'
        tags:
            - { name: security.voter }

A ptrio_message.device_voter has to be tagged as a security.voter.

Updating a device controller class

At the next stage some adjustments have to be made to a DeviceController controller class, so a voting mechanism can be used. DeviceController::getDeviceAction(string $deviceName), DeviceController::postDeviceAction(Request $request) and DeviceController::deleteDeviceAction(string $deviceName) methods should be updated.

Add a ControllerTrait::denyAccessUnlessGranted($attributes, $subject = null, string $message = 'Access Denied.') method call to a DeviceController::getDeviceAction(string $deviceName) method body.

    /**
     * @param string $deviceName
     * @return Response
     */
    public function getDeviceAction(string $deviceName): Response
    {
        if ($device = $this->deviceManager->findDeviceByName($deviceName)) {
            $this->denyAccessUnlessGranted(null, $device); // add this line
            $view = $this->view($device, 200);
        } else {
            $view = $this->view(null, 404);
        }
        return $this->handleView($view);
    }

As visible in the example above, a ControllerTrait::denyAccessUnlessGranted($attributes, $subject = null, string $message = 'Access Denied.') method is within a DeviceController class scope. The reason behind that is the fact, that a DeviceController class extends a base FOS\RestBundle\Controller\FOSRestController controller class, which on the other hand uses a Symfony\Bundle\FrameworkBundle\Controller\ControllerTrait trait.

In the next step, add logic, responsible for assigning a user object to a newly created device object, to a DeviceController::postDeviceAction(Request $request) method body.

    /**
     * @param Request $request
     * @return Response
     */
    public function postDeviceAction(Request $request): Response
    {
        /** @var DeviceInterface $device */
        $device = $this->deviceManager->createDevice();
        $form = $this->formFactory->create(DeviceType::class, $device);
        $form->submit($request->request->all());
        if ($form->isSubmitted() && $form->isValid()) {
            $device = $form->getData();
            $device->setUser($this->getUser()); // add this line
            $this->deviceManager->updateDevice($device);
            $view = $this->view(null, 204);
        } else {
            $view = $this->view($form->getErrors(), 422);
        }
        return $this->handleView($view);
    }

The last step involves updating a DeviceController::deleteDeviceAction(string $deviceName) method.

    /**
     * @param string $deviceName
     * @return Response
     */
    public function deleteDeviceAction(string $deviceName): Response
    {
        if ($device = $this->deviceManager->findDeviceByName($deviceName)) {
            $this->denyAccessUnlessGranted(null, $device); // add this line
            $this->deviceManager->removeDevice($device);
            $view = $this->view(null, 204);
        } else {
            $view = $this->view(null, 404);
        }
        return $this->handleView($view);
    }
Updating a AddDeviceCommand class

Due to the fact that our application also uses a command-line interface to interact with device objects, it is required to update a AddDeviceCommand command class, which takes a responsibility of creating new devices.

A user object related to a device will be looked up in a database by a username passed as a ptrio:message:add-device command argument.

Begin by adding a UserManagerInterface import statement between a namespace and a class operators.

use App\Ptrio\MessageBundle\Model\UserManagerInterface;

Next, add a AddDeviceCommand::$userManager property which will hold a device manager instance.

    /**
     * @var UserManagerInterface
     */
    private $userManager;

In the next step, a AddDeviceCommand class constructor should be updated with a new argument.

    /**
     * AddDeviceCommand constructor.
     * @param DeviceManagerInterface $deviceManager
     * @param UserManagerInterface $userManager
     */
    public function __construct(
        DeviceManagerInterface $deviceManager,
        UserManagerInterface $userManager // add this line
    )
    {
        $this->deviceManager = $deviceManager;
        $this->userManager = $userManager; // add this line
        parent::__construct();
    }

Adding a required username argument inside a AddDeviceCommand::configure() method is also necessary.

    /**
     * {@inheritdoc}
     */
    protected function configure()
    {
        $this->setDefinition([
            new InputArgument('name', InputArgument::REQUIRED),
            new InputArgument('token', InputArgument::REQUIRED),
            new InputArgument('username', InputArgument::REQUIRED), // add this line
        ]);
    }

Finally, logic responsible for finding a user entity object and assigning it to a device object can be added to a AddDeviceCommand::execute(InputInterface $input, OutputInterface $output) method body.

    /**
     * {@inheritdoc}
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $name = $input->getArgument('name');
        $token = $input->getArgument('token');
        $username = $input->getArgument('username');  // add this line
        if (null === $this->deviceManager->findDeviceByName($name)) {
            $device = $this->deviceManager->createDevice();
            $device->setName($name);
            $device->setToken($token);
            $device->setUser($this->userManager->findUserByUsername($username)); // add this line
            $this->deviceManager->updateDevice($device);
            $output->writeln('Device `'.$name.'` created!');
        } else {
            $output->writeln('Device with this name already exists!');
        }
    }
Updating service configuration

A ptrio_message.add_device_command command service definition arguments should be updated by adding a ptrio_message.user_manager service as a constructor argument.

    ptrio_message.add_device_command:
        class: 'App\Ptrio\MessageBundle\Command\AddDeviceCommand'
        arguments:
            - '@ptrio_message.device_manager'
            - '@ptrio_message.user_manager' // add this argument
        tags:
            - { name: console.command }
Updating a FOSRestBundle configuration

Before we jump into examples, it is required to update a FOSRestBundle bundle configuration, so the exceptions thrown by a DeviceController controller can be serialised to a JSON/XML format.

Add the following definition to a config/packages/fos_rest.yaml file.

    exception:
            enabled: true

Examples

Adding a new device
php bin/console ptrio:message:add-device ipad-piotr 'd1KeQHgkIoo:APA91b...' piotr42

A new device can be also added via HTTP protocol.

curl -H 'X-AUTH-TOKEN: MMYKUE63gCyFc9blOwcrdr0EP9ghb7yiWZ2lr4fRauM' -H 'Content-Type: application/json' -X POST -d '{"name":"ipad-piotr","token":"d1KeQHgkIoo:APA91bGBG7vg9rfX0PFb.."}' http://localhost/api/v1/devices
Displaying a device details
curl -H 'X-AUTH-TOKEN: MMYKUE63gCyFc9blOwcrdr0EP9ghb7yiWZ2lr4fRauM' http://localhost/api/v1/devices/ipad-piotr

In case an access is granted by a voting mechanism, a response, similar to the one below, should be returned.

{"id":3,"name":"ipad-piotr","token":"d1KeQHgkIoo:APA91bGBG7vg9rfX0PFb...","user":{"id":1,"username":"piotr42","apiKey":"MMYKUE63gCyFc9blOwcrdr0EP9ghb7yiWZ2lr4fRauM","roles":["ROLE_USER"],"password":null,"salt":null}}

Now, let’s try to view a device resource which is not related to our user object.

curl -H 'X-AUTH-TOKEN: MMYKUE63gCyFc9blOwcrdr0EP9ghb7yiWZ2lr4fRauM' http://localhost/api/v1/devices/redmi-ewa

A received response should contain information indicating that an access was denied.

{"code":403,"message":"Access Denied."}

Curriculum



Posted on Utopian.io - Rewarding Open Source Contributors

Sort:  

Thank you for the contribution. It has been approved.

You can contact us on Discord.
[utopian-moderator]

Hey @zonguin, I just gave you a tip for your hard work on moderation. Upvote this comment to support the utopian moderators and increase your future rewards!

Hey @piotr42 I am @utopian-io. I have just upvoted you!

Achievements

  • You have less than 500 followers. Just gave you a gift to help you succeed!
  • Seems like you contribute quite often. AMAZING!

Community-Driven Witness!

I am the first and only Steem Community-Driven Witness. Participate on Discord. Lets GROW TOGETHER!

mooncryption-utopian-witness-gif

Up-vote this comment to grow my power and help Open Source contributions like this one. Want to chat? Join me on Discord https://discord.gg/Pc8HG9x