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

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
- Web Development with Symfony: An application capable of dispatching push notifications to mobile devices via FCM [part 5]
- Web Development with Symfony: An application capable of dispatching push notifications to mobile devices via FCM [part 4]
- Web Development with Symfony: An application capable of dispatching push notifications to mobile devices via FCM [part 3]
- Web Development with Symfony: An application capable of dispatching push notifications to mobile devices via FCM [part 2]
- Web Development with Symfony: An application capable of dispatching push notifications to mobile devices via FCM [part 1]
Posted on Utopian.io - Rewarding Open Source Contributors
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
Community-Driven Witness!
I am the first and only Steem Community-Driven Witness. Participate on Discord. Lets GROW TOGETHER!
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