I have pages like 'localhost/articles/show/id', representing the article details with the corresponding id.
I'd like to restrict the page access to a group of people.
In my database, each User belongs to a Family and each Article belongs to a Family as well.
And I want the users to be able to access article informations only if the article has been created by the family that the user is member of.
I could just verify manually by comparing the article's family to the current user family with some request in the Controller before rendering but I would to duplicated this code for every page like '/show/id', '/edit/id', ... Yet I'd like to know if there is a more beautiful way of doing it with symfony, something like 'every page that refers to a specific Article (/edit/id, /show/id and so on so forth) use a specific class to verify if the user is a member of the Family that created the article.
I think the thing you're looking for is Voter.
Security voters are the most granular way of checking permissions. All voters are
called each time you use the isGranted() method on Symfony’s authorization
checker or call denyAccessUnlessGranted() in a controller.
see: https://symfony.com/doc/current/security/voters.html
// src/Security/PostVoter.php
//....
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ArticleVoter extends Voter
{
// these strings are just invented: you can use anything
const VIEW = 'view';
const EDIT = 'edit';
/**
* return true if the voter support your entity ($subject) type
*/
protected function supports(string $attribute, $subject)
{
// if the attribute isn't one we support, return false
if (!in_array($attribute, [self::VIEW, self::EDIT])) {
return false;
}
// only vote on `Article` objects
if (!$subject instanceof Article) {
return false;
}
return true;
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token)
{
$user = $token->getUser();
if (!$user instanceof User) {
// the user must be logged in; if not, deny access
return false;
}
// you know $subject is a Article object, thanks to `supports()`
/** #var Article $post */
$article = $subject;
switch ($attribute) {
case self::VIEW:
return $this->canView($article, $user);
case self::EDIT:
return $this->canEdit($article, $user);
}
throw new \LogicException('This code should not be reached!');
}
private function canView(Article $article, User $user)
{
//Return true if user can view article, false otherwise
}
private function canEdit(Article $article, User $user)
//Return true if user can edit article, false otherwise
}
}
Voters are used when you call $this->denyAccessUnlessGranted(String $actionName, $entity) from your controllers. this method will throws an exception if a voter that support your $entity type and your $actionName return false.
// src/Controller/ArticleController.php
// ...
class ArticleController extends AbstractController
{
/**
* #Route("/article/{id}", name="article_show")
*/
public function show($id)
{
$article = ...;
// check for "view" access: calls all voters
$this->denyAccessUnlessGranted('view', $article);
// ...do your stuff
}
/**
* #Route("/article/{id}/edit", name="article_edit")
*/
public function edit($id)
{
$article = ...;
// check for "edit" access: calls all voters
$this->denyAccessUnlessGranted('edit', $article);
// ... do your stuff
}
}
Related
I have a specific authorisation system in my application (asked by my managers). It is based on Joomla. Users are attached to usergroups. Every action (i.e page) in my application are resources and for each resources I have set an access level. I have then to compare the resource access level with the usergroups of the current user to grant access or not to this specific resource.
All those informations are stored in database which are in return entities in Symfony :
User <- ManyToMany -> Usergroups
Menu (all resources with path and access level)
I thought about the Voter system. It is kind alike of what I would want, I think. Can I hijack the support function for this ?
protected function supports($user, $resource)
{
//get usergroups of the $user => $usergroups
//get the access level of the resource => $resource_access
// if the attribute isn't one we support, return false
if (!in_array($usergroups, $resource_access)) {
return false;
}
return true;
}
The get the usergroups and the access level of the resource I will have to do some queries in the database. To use this, then I would to use the denyAccessUnlessGranted() function in all my controller (seems redundant by the way) ?
Do you think it would work or there is another system more suited for this case ? I thought of doing the control in a listener to the kernel.request event too.
Hope I am clear enough, I'm new to symfony and still have some issues to understand how everything are related and working.
The voter component should be a good fit for this, as its a passive approach that lets you implement any logic in a way where its fixable through code, without modifying any database specific acl tree not managed by symfony itself.
Voters are called if you use denyAccessUnlessGranted() or isGranted() either through code, annotation or twig.
Lets take a look at how you want to check if the current user has access to view the index page:
class SomeController {
public function index() {
$this->denyAccessUnlessGranted('VIEW', '/index');
// or use some magic method to replace '/index' with wathever you require,
// like injecting $request->getUri(), just make sure your voter can
// parse it quickly.
// ...
}
}
Now build the a very simple voter:
class ViewPageVoter extends Voter
{
/**
* #var EntityManagerInterface
*/
private $em;
public function __construct(EntityManagerInterface $em) {
$this->em = $em;
}
protected function supports($attribute, $subject)
{
return is_string($subject) && substr($subject, 0, 1) === '/';
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$currentUser = $token->getUser();
if(!$currentUser) {
// no user or authentication, deny
return false;
}
// Do the query to see if the user is allowed to view the resource.
// $this->em->getRepository(...) or
// $this->em->getConnection()
//
// $attribute = VIEW
// $subject = '/index'
// $currentUser = authenticated user
// return TRUE if allowed, return FALSE if not.
}
}
As a nice bonus you can easily see additional details on security voters in the /_profiler of that request, also indicating their respective vote on the subject.
I'm trying to implement a custom Voter.
From the controller I call it this way:
$prj = $this->getDoctrine()->getRepository('AppBundle:Project')->findOneById($id);
if (false === $this->get('security.authorization_checker')->isGranted('responsible', $prj)) {
throw new AccessDeniedException('Unauthorised access!');
}
The first line properly retrieves the Project object (I checked with a dump).
The problem occurs inside the voter
<?php
namespace AppBundle\Security\Authorization\Voter;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\User\UserInterface;
class ProjectVoter implements VoterInterface
{
const RESPONSIBLE = 'responsible';
const ACCOUNTABLE = 'accountable';
const SUPPORT = 'support';
const CONSULTED = 'consulted';
const INFORMED = 'informed';
public function supportsAttribute($attribute)
{
return in_array($attribute, array(
self::RESPONSIBLE,
self::ACCOUNTABLE,
self::SUPPORT,
self::CONSULTED,
self::INFORMED,
));
}
public function supportsClass($class)
{
$supportedClass = 'AppBundle\Entity\Project';
return $supportedClass === $class || is_subclass_of($class, $supportedClass);
}
/**
* #var \AppBundle\Entity\Project $project
*/
public function vote(TokenInterface $token, $project, array $attributes)
{
// check if class of this object is supported by this voter
if (!$this->supportsClass(get_class($project))) {
return VoterInterface::ACCESS_ABSTAIN;
}
// check if the voter is used correct, only allow one attribute
// this isn't a requirement, it's just one easy way for you to
// design your voter
if (1 !== count($attributes)) {
throw new \InvalidArgumentException(
'Only one attribute is allowed'
); //in origin it was 'for VIEW or EDIT, which were the supported attributes
}
// set the attribute to check against
$attribute = $attributes[0];
// check if the given attribute is covered by this voter
if (!$this->supportsAttribute($attribute)) {
return VoterInterface::ACCESS_ABSTAIN;
}
// get current logged in user
$user = $token->getUser();
// make sure there is a user object (i.e. that the user is logged in)
if (!$user instanceof UserInterface) {
return VoterInterface::ACCESS_DENIED;
}
$em = $this->getDoctrine()->getManager();
$projects = $em->getRepository('AppBundle:Project')->findPrjByUserAndRole($user, $attribute);
foreach ($projects as $key => $prj) {
if ($prj['id'] === $project['id'])
{
$granted = true;
$index = $key; // save the index of the last time a specifif project changed status
}
}
if($projects[$index]['is_active']===true) //if the last status is active
return VoterInterface::ACCESS_GRANTED;
else
return VoterInterface::ACCESS_DENIED;
}
}
I get the following error
Attempted to call method "getDoctrine" on class
"AppBundle\Security\Authorization\Voter\ProjectVoter".
I understand that the controller extends Controller, that is why I can use "getDoctrine" there. How can I have access to my DB from inside the Voter?
I solved it. This is pretty curious: I spend hours or days on a problem, then post a question here, and I solve it myself within an hour :/
I needed to add the following in my voter class:
public function __construct(EntityManager $em)
{
$this->em = $em;
}
I needed to add the following on top:
use Doctrine\ORM\EntityManager;
I also needed to add the arguments in the service.yml
security.access.project_voter:
class: AppBundle\Security\Authorization\Voter\ProjectVoter
arguments: [ #doctrine.orm.entity_manager ]
public: false
tags:
- { name: security.voter }
I need some advice on how to handle access control for the following scenario:
Corporation
Has one or many companies
Has one or many ROLE_CORP_ADMIN
Company
Has one or many regions.
Has one or many ROLE_COMPANY_ADMIN.
Region:
Has zero or many stores.
Has one or many ROLE_REGION_ADMIN.
Store:
Has zero or many assets.
Has one or many ROLE_STORE_ADMIN.
Has zero or many ROLE_STORE_EMPLOYEE.
Has zero or many ROLE_STORE_CUSTOMER (many is better).
The application should support many corporations.
My instinct is to create either a many-to-many relationship per entity for their admins (eg region_id, user_id). Depending on performance, I could go with a more denormalized table with user_id, corporation_id, company_id, region_id, and store_id. Then I'd create a voter class (unanimous strategy):
public function vote(TokenInterface $token, $object, array $attributes)
{
// If SUPER_ADMIN, return ACCESS_GRANTED
// If User in $object->getAdmins(), return ACCESS_GRANTED
// Else, return ACCESS_DENIED
}
Since the permissions are hierarchical, the getAdmins() function will check all owners for admins as well. For instance:
$region->getAdmins() will also return admins for the owning company, and corporation.
I feel like I'm missing something obvious. Depending on how I implement the getAdmins() function, this approach will require at least one hit to the db every vote. Is there a "better" way to go about this?
Thanks in advance for your help.
I did just what I posed above, and it is working well. The voter was easy to implement per the Symfony cookbook. The many-to-many <entity>_owners tables work fine.
To handle the hierarchical permissions, I used cascading calls in the entities. Not elegant, not efficient, but not to bad in terms of speed. I'm sure refactor this to use a single DQL query soon, but cascading calls work for now:
class Store implements OwnableInterface
{
....
/**
* #ORM\ManyToMany(targetEntity="Person")
* #ORM\JoinTable(name="stores_owners",
* joinColumns={#ORM\JoinColumn(name="store_id", referencedColumnName="id", nullable=true)},
* inverseJoinColumns={#ORM\JoinColumn(name="person_id", referencedColumnName="id")}
* )
*
* #var ArrayCollection|Person[]
*/
protected $owners;
...
public function __construct()
{
$this->owners = new ArrayCollection();
}
...
/**
* Returns all people who are owners of the object
* #return ArrayCollection|Person[]
*/
function getOwners()
{
$effectiveOwners = new ArrayCollection();
foreach($this->owners as $owner){
$effectiveOwners->add($owner);
}
foreach($this->getRegion()->getOwners() as $owner){
$effectiveOwners->add($owner);
}
return $effectiveOwners;
}
/**
* Returns true if the person is an owner.
* #param Person $person
* #return boolean
*/
function isOwner(Person $person)
{
return ($this->getOwners()->contains($person));
}
...
}
The Region entity would also implement OwnableInterface and its getOwners() would then call getCompany()->getOwners(), etc.
There were problems with array_merge if there were no owners (null), so the new $effectiveOwners ArrayCollection seems to work well.
Here is the voter. I stole most of the voter code and OwnableInterface and OwnerInterface from KnpRadBundle:
use Acme\AcmeBundle\Security\OwnableInterface;
use Acme\AcmeBundle\Security\OwnerInterface;
use Acme\AcmeUserBundle\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
class IsOwnerVoter implements VoterInterface
{
const IS_OWNER = 'IS_OWNER';
private $container;
public function __construct(\Symfony\Component\DependencyInjection\ContainerInterface $container) {
$this->container = $container;
}
public function supportsAttribute($attribute)
{
return self::IS_OWNER === $attribute;
}
public function supportsClass($class)
{
if (is_object($class)) {
$ref = new \ReflectionObject($class);
return $ref->implementsInterface('Acme\AcmeBundle\Security\OwnableInterface');
}
return false;
}
public function vote(TokenInterface $token, $object, array $attributes)
{
foreach ($attributes as $attribute) {
if (!$this->supportsAttribute($attribute)) {
continue;
}
if (!$this->supportsClass($object)) {
return self::ACCESS_ABSTAIN;
}
// Is the token a super user? This will check roles, not user.
if ( $this->container->get('security.context')->isGranted('ROLE_SUPER_ADMIN') ) {
return VoterInterface::ACCESS_GRANTED;
}
if (!$token->getUser() instanceof User) {
return self::ACCESS_ABSTAIN;
}
// check to see if this token is a user.
if (!$token->getUser()->getPerson() instanceof OwnerInterface) {
return self::ACCESS_ABSTAIN;
}
// Is this person an owner?
if ($this->isOwner($token->getUser()->getPerson(), $object)) {
return self::ACCESS_GRANTED;
}
return self::ACCESS_DENIED;
}
return self::ACCESS_ABSTAIN;
}
private function isOwner(OwnerInterface $owner, OwnableInterface $ownable)
{
return $ownable->isOwner($owner);
}
}
I'm using HWIOAuthBundle to let an user login with Oauth, I've created a custom user provider which creates an user in case it doesn't exist:
public function loadUserByOAuthUserResponse(UserResponseInterface $response)
{
$attr = $response->getResponse();
switch($response->getResourceOwner()->getName()) {
case 'google':
if(!$user = $this->userRepository->findOneByGoogleId($attr['id'])) {
if(($user = $this->userRepository->findOneByEmail($attr['email'])) && $attr['verified_email']) {
$user->setGoogleId($attr['id']);
if(!$user->getFirstname()) {
$user->setFirstname($attr['given_name']);
}
if(!$user->getLastname()) {
$user->setLastname($attr['family_name']);
}
$user->setGoogleName($attr['name']);
}else{
$user = new User();
$user->setUsername($this->userRepository->createUsernameByEmail($attr['email']));
$user->setEmail($attr['email']);
$user->setFirstname($attr['given_name']);
$user->setLastname($attr['family_name']);
$user->setPassword('');
$user->setIsActive(true);
$user->setGoogleId($attr['id']);
$user->setGoogleName($attr['name']);
$user->addGroup($this->groupRepository->findOneByRole('ROLE_USER'));
$this->entityManager->persist($user);
}
}
break;
case 'facebook':
if(!$user = $this->userRepository->findOneByFacebookId($attr['id'])) {
if(($user = $this->userRepository->findOneByEmail($attr['email'])) && $attr['verified']) {
$user->setFacebookId($attr['id']);
if(!$user->getFirstname()) {
$user->setFirstname($attr['first_name']);
}
if(!$user->getLastname()) {
$user->setLastname($attr['last_name']);
}
$user->setFacebookUsername($attr['username']);
}else{
$user = new User();
$user->setUsername($this->userRepository->createUsernameByEmail($attr['email']));
$user->setEmail($attr['email']);
$user->setFirstname($attr['first_name']);
$user->setLastname($attr['last_name']);
$user->setPassword('');
$user->setIsActive(true);
$user->setFacebookId($attr['id']);
$user->setFacebookUsername($attr['username']);
$user->addGroup($this->groupRepository->findOneByRole('ROLE_USER'));
$this->entityManager->persist($user);
}
}
break;
}
$this->entityManager->flush();
if (null === $user) {
throw new AccountNotLinkedException(sprintf("User '%s' not found.", $attr['email']));
}
return $user;
}
The problem is that twitter for example doesn't give the email or i want some additional fields to be included before a new user is created. Is there a way to redirect an user to a "complete registration" form before creating it?
I've tried to add a request listener, that on each request, if the user is logged, checks if the email is there and if it doesn't it redirects to the complete_registration page, but it will redirect also if the user goes to the homepage, to logout or anything else, I want to redirect him only if he tries to access some user restricted pages.
Or better, don't create it until he gives all the required informations.
I've found the solution by myself, I've manually created a new exception:
<?php
namespace Acme\UserBundle\Exception;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use HWI\Bundle\OAuthBundle\Security\Core\Exception\OAuthAwareExceptionInterface;
/**
* IncompleteUserException is thrown when the user isn't fully registered (e.g.: missing some informations).
*
* #author Alessandro Tagliapietra http://www.alexnetwork.it/
*/
class IncompleteUserException extends AuthenticationException implements OAuthAwareExceptionInterface
{
private $user;
private $accessToken;
private $resourceOwnerName;
/**
* {#inheritdoc}
*/
public function setAccessToken($accessToken)
{
$this->accessToken = $accessToken;
}
/**
* {#inheritdoc}
*/
public function getAccessToken()
{
return $this->accessToken;
}
/**
* {#inheritdoc}
*/
public function getResourceOwnerName()
{
return $this->resourceOwnerName;
}
/**
* {#inheritdoc}
*/
public function setResourceOwnerName($resourceOwnerName)
{
$this->resourceOwnerName = $resourceOwnerName;
}
public function setUser($user)
{
$this->user = $user;
}
public function getUser($user)
{
return $this->user;
}
public function serialize()
{
return serialize(array(
$this->user,
$this->accessToken,
$this->resourceOwnerName,
parent::serialize(),
));
}
public function unserialize($str)
{
list(
$this->user,
$this->accessToken,
$this->resourceOwnerName,
$parentData
) = unserialize($str);
parent::unserialize($parentData);
}
}
In this way, in the custom Oauth user provider when i check if an user exist or I create a new user i check if the required fields are missing:
if (!$user->getEmail()) {
$e = new IncompleteUserException("Your account doesn't has a mail set");
$e->setUser($user);
throw $e;
}
In that case the user will be redirected to the login form, with that exception in session, so in the login page I do:
if($error instanceof IncompleteUserException) {
$session->set(SecurityContext::AUTHENTICATION_ERROR, $error);
return $this->redirect($this->generateUrl('register_complete'));
}
And it will be redirected to a form with the $user in the exception so it can ask only for the missing information and then login the user.
I ran into a similar issue while migrating to a sf site. After the migration I wanted the migrated users to complete their profile whereas the newly registered users could get started immediately.
I solved this similar to your idea using a RequestListener but added a whitelist of pages that the user is allowed without completion of his profile. Depending on the number of pages you want the user to have access to without completion of his profile you might also consider using a blacklist.
I did not check for th existance of a particular field uppon redirecting to the profile completion page but added a role "ROLE_MIGRATION" when migrating the usersdatabase. In your case you can add the role when creating the user through oauth.
Here the code of my request listener:
public function onRequest(GetResponseEvent $evt)
{
if (HttpKernelInterface::MASTER_REQUEST !== $evt->getRequestType())
{
return;
}
$token = $this->securityContext->getToken();
if(!is_object($token)) or not migrating
{
// user is not logged in
return;
}
$user = $token->getUser();
if(!$user instanceof User || !$user->hasRole('ROLE_MIGRATING'))
{
// different user class or already migrated
return;
}
$openPaths = array(
'/start-2.0',
'/css',
'/js',
'/images',
'/media',
'/geo',
'/_wdt',
'/logout', or not migrating
'/terms',
'/contact',
'/about',
'/locale/set'
);
foreach($openPaths as $p)
{
if(strpos($evt->getRequest()->getPathInfo(),$p)===0)
{
// path is open for migrating users
return;
}
}
header('Location: /start-2.0');
exit;
}
We are using Symfony2's roles feature to restrict users' access to certain parts of our app. Users can purchase yearly subscriptions and each of our User entities has many Subscription entities that have a start date and an end.
Now, is there a way to dynamically add a role to a user based on whether they have an 'active' subscription? In rails i would simply let the model handle whether it has the necessary rights but I know that by design symfony2 entities are not supposed to have access to Doctrine.
I know that you can access an entity's associations from within an entity instance but that would go through all the user's subscription objects and that seems unnecessaryly cumbersome to me.
I think you would do better setting up a custom voter and attribute.
/**
* #Route("/whatever/")
* #Template
* #Secure("SUBSCRIPTION_X")
*/
public function viewAction()
{
// etc...
}
The SUBSCRIPTION_X role (aka attribute) would need to be handled by a custom voter class.
class SubscriptionVoter implements VoterInterface
{
private $em;
public function __construct($em)
{
$this->em = $em;
}
public function supportsAttribute($attribute)
{
return 0 === strpos($attribute, 'SUBSCRIPTION_');
}
public function supportsClass($class)
{
return true;
}
public function vote(TokenInterface $token, $object, array $attributes)
{
// run your query and return either...
// * VoterInterface::ACCESS_GRANTED
// * VoterInterface::ACCESS_ABSTAIN
// * VoterInterface::ACCESS_DENIED
}
}
You would need to configure and tag your voter:
services:
subscription_voter:
class: SubscriptionVoter
public: false
arguments: [ #doctrine.orm.entity_manager ]
tags:
- { name: security.voter }
Assuming that you have the right relation "subscriptions" in your User Entity.
You can maybe try something like :
public function getRoles()
{
$todayDate = new DateTime();
$activesSubscriptions = $this->subscriptions->filter(function($entity) use ($todayDate) {
return (($todayDate >= $entity->dateBegin()) && ($todayDate < $entity->dateEnd()));
});
if (!isEmpty($activesSubscriptions)) {
return array('ROLE_OK');
}
return array('ROLE_KO');
}
Changing role can be done with :
$sc = $this->get('security.context')
$user = $sc->getToken()->getUser();
$user->setRole('ROLE_NEW');
// Assuming that "main" is your firewall name :
$token = new \Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken($user, null, 'main', $user->getRoles());
$sc->setToken($token);
But after a page change, the refreshUser function of the provider is called and sometimes, as this is the case with EntityUserProvider, the role is overwrite by a query.
You need a custom provider to avoid this.