In SF2 I have the following scenario, according to the site itself documentation:
app\config\security.yml
security:
access_control:
- { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/, roles: ROLE_ADMIN }
I need to manually add each "path" and "roles" that can access this "path".
How can I do this dynamically?
Like RBAC on Yii2:
Is there any ready Bundle or something in SF2 own documentation that allows this? As the hypothetical example:
app\config\security.yml
security:
access_control:
type: dynamically
If you want to make adding the roles easier you can use annotations.
Your question asks for dynamic security, which is complicated. The routes, and all roles, are compiled during the cache warmup phase. So, for this to work you'll first need to store your dynamic values. The database would be a good option for this. Here I am only going to show how to check the roles, the actual role manipulation I'll leave to you.
The easiest method is to inject the authorization checker into your controller.
services:
acme_controller:
class: "AcmeDemoBundle\Controller"
arguments: ["#security.authorization_checker"]
Then check the roles in the action(s):
public function __construct(AuthorizationCheckerInterface $authorizationChecker)
{
$this->authorizationChecker = $authorizationChecker;
}
public function listAction()
{
$role = /* load your role here */;
if (false === $this->authorizationChecker->isGranted($role)) {
throw new AccessDeniedException();
}
// ...
}
The above will cause duplicated code if you want it in many controllers, so you could also create a voter:
services:
acme.route.voter:
class: AcmeDemoBundle\RouteVoter
arguments:
- #security.role_hierarchy
public: false
tags:
- { name: security.voter, priority: 300 }
Example:
public function __construct ( RoleHierarchyInterface $roleHierarchy )
{
$this->roleVoter = new RoleHierarchyVoter( $roleHierarchy );
}
public function vote ( TokenInterface $token, $object, array $attributes )
{
if ( !$object instanceof Request ) {
return VoterInterface::ACCESS_ABSTAIN;
}
$requestUri = $object->getPathInfo();
if ( isset($this->votes[ $requestUri ]) ) {
return $this->votes[ $requestUri ];
}
$roles = /* load your roles */;
return $this->votes[ $requestUri ] = $this->roleVoter->vote( $token, $object, $roles );
}
Another method would be to replace the router service with your own implementation. This is the approach taken my the CMF Bundle.
You can manage role/route relation dynamically like this :
You create a listener on the kernel
<service id="toto.security.controller_listener" class="Administration\SecurityBundle\EventListener\SecurityListener">
<tag name="kernel.event_listener" event="kernel.controller" method="onKernelController" />
<argument type="service" id="service_container" />
</service>
and after in the listener you implement this method
public function onKernelController(FilterControllerEvent $event)
{
$controller = $event->getController();
if (!is_array($controller)) {
return;
}
$request = $event->getRequest();
$baseUrl = $request->getBaseUrl();
$requestUri = $request->getRequestUri();
$route = str_replace($baseUrl, "", $requestUri);
//you put your check logic
//you can implement a relation beetween routes and roles/ users in database etc. you got the entire control on what you do
if(!$this->accessMananager->isGrantAccess(User $user, $route)){
throw new AccessDeniedException("blah blah blah")
}
}
since this listener will always be called before any of your controller, consider creating a cache system
Related
I created a custom module to create a /store/ID/tasks page
https://www.drupal.org/project/commerce
How to limit access to this page to the store owner ?
If the current user is owner of store ID 76, he can access this page :
/store/76/tasks
But if he goes to another store, he must have denied access :
/store/89/tasks
https://git.drupalcode.org/sandbox/zenimagine-3076032
task_notify/task_notify.routing.yml
task_notify.store_page.tasks:
path: '/store/{store}/tasks'
defaults:
_controller: '\Drupal\task_notify\Controller\TaskNotifyStoreController::Tasks'
_title: 'Liste des tâches'
requirements:
_custom_access: '\Drupal\task_notify\Controller\TaskNotifyStoreController::taskAccess'
task_notify/src/Controller/TaskNotifyStoreController.php
<?php
namespace Drupal\task_notify\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Session\AccountInterface;
use Drupal\commerce_store\Entity\StoreInterface;
class TaskNotifyStoreController extends ControllerBase {
public function Tasks() {
return [
'#theme' => 'task_notify_store_template',
];
}
public function taskAccess(StoreInterface $store, AccountInterface $account = NULL, $return_as_object = FALSE) {
$result = $store->access('edit', $account, TRUE);
return $return_as_object ? $result : $result->isAllowed();
}
}
This page should be accessible only if the current user can edit the store (the site administrator and the store owner).
Access in the module code must have the same conditions as in this view :
https://i.stack.imgur.com/ZfUMo.png
I was inspired by the two files below :
https://git.drupalcode.org/project/commerce_marketplace/-/blob/8.x-1.x/src/Plugin/Action/MarketplaceIncreaseStoreLimitByOne.php
https://git.drupalcode.org/project/commerce_marketplace/-/blob/8.x-1.x/src/Plugin/Action/MarketplaceMarkAsDefault.php
In this case, we can tell Drupal that {store} is an entity and it will load the object. So we don't have to do that in the Controller function.
So your routing file can include "parameters" settings to do that.
task_notify.store_page.tasks:
path: '/store/{store}/tasks'
defaults:
_controller: '\Drupal\task_notify\Controller\TaskNotifyStoreController::Tasks'
_title: 'Liste des tâches'
requirements:
_custom_access: '\Drupal\task_notify\Controller\TaskNotifyStoreController::taskAccess'
options:
parameters:
store:
type: entity:commerce_store
Now your controller function has access to that object.
public function Tasks(StoreInterface $store) { ...
In my experience, that is NOT true of the access() method (at least when using a type-hinted parameter as we are doing here). You get a string, so you'll have to load the store manually.
public function taskAccess(string $store, AccountInterface $account) {
$store = \Drupal\commerce_store\Entity\Store::load($store);
// Check store owner against current user.
if ($store->access('edit', $account)) {
return AccessResult::allowed();
}
else {
return AccessResult::forbidden();
}
Also we need to define $account in the routing file now, as we are using type-hinted parameters (I think). So add that to the options:.
task_notify.store_page.tasks:
path: '/store/{store}/tasks'
defaults:
_controller: '\Drupal\task_notify\Controller\TaskNotifyStoreController::Tasks'
_title: 'Liste des tâches'
requirements:
_custom_access: '\Drupal\task_notify\Controller\TaskNotifyStoreController::taskAccess'
options:
parameters:
store:
type: entity:commerce_store
account: \Drupal\Core\Session\AccountProxy
$account is one of a few special parameters that we can type-hint this way. More info: https://www.drupal.org/docs/8/api/routing-system/access-checking-on-routes/advanced-route-access-checking
I'm turning to this forum because I can't find a valid solution to my problem.
I have taken over the management of a Symfony2 application which processes orders, invoices... inside a company and the problem is that there isn't archiving functions on it. So, the manager asked me to add archiving 'by year' functionalities to the application (simply display data depending on a chosen year).
So, I decided to prefix all application routes by /{year}/, parameter which will match the year the manager want to see and, as all the documents are dated, I just have to update Doctrine requests for picking those that match the chosen year. So far no problems.
routes.yml
mes_routes:
resource: "mes_routes.yml"
prefix: /{year}
defaults: {'year': %current_year%}
With this, I have created a Symfony Extension which fills the 'current_year' var by default in my route definition, with the actual year if no year is provided.
MyAppExtension.php
class MyAppExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container)
{
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
$loader->load('services.yml');
// Fill parameter with the current year
$container->setParameter('current_year', date("Y"));
}
}
Next, I have created a RouteListener that stores a previous route and its parameters inside a session var, when a user displays a new page (in order to display a same page but with a different year next)
LastRouteListener.php
class LastRouteListener
{
public function onKernelRequest(GetResponseEvent $event)
{
// Don't store subrequests
if ($event->getRequestType() !== HttpKernel::MASTER_REQUEST) {
return;
}
$request = $event->getRequest();
$session = $request->getSession();
$routeName = $request->get('_route');
$routeParams = $request->get('_route_params');
if ($routeName[0] == "_") {
return;
}
$routeData = ['name' => $routeName, 'params' => $routeParams];
// Don't store the same route twice
$thisRoute = $session->get('this_route', []);
if ($thisRoute == $routeData) {
return;
}
$session->set('last_route', $thisRoute);
$session->set('this_route', $routeData);
}
}
services.yml
myapp.last_route_event_listener:
class: MyApp\EventListener\LastRouteListener
tags:
- { name: kernel.event_listener, event: kernel.request, method: onKernelRequest, priority: 30 }
And finally, I have added a new controller which, via a dropdown menu in the application navbar, displays the current page the user is viewing, but with a different year
ArchiveController.php
class ArchiveController extends Controller
{
public function switchYearAction(Request $request, $year)
{
$session = $request->getSession();
$lastRoute = $session->get('last_route');
$route = $lastRoute["name"];
$routeParams = $lastRoute["params"];
if (array_key_exists("year", $routeParams)) {
$routeParams["year"] = $year;
$session->set("current_year", $year);
}
return $this->redirect($this->generateUrl($route, $routeParams));
}
}
Arrived here, everything work. If a user chose an other date, the application will display the same page but with the new date chosen.
However, and there is my problem, if, from a previous year, the user clicks on a link in the page, we come back to the actual year. Quite normal, because Twig paths in the application doesn't fill the 'year' routing parameter, and the router provide the current year by default.
So, my question is : How can I keep the chosen year in memory, and use it as a route parameter ?
First, I had thought about setting the local var 'current_year' when the application uses the switchYearAction(), but Symfony returns an error ('Frozen variable')
Next, I had thought about using a session var to store the chosen year, but I can't access to the session within my extension MyAppExtension.
There might be a third solution which consists in update all Twig paths and Controller redirect(), but it represents some much line to edit...
Or maybe with a routeEventListener... but I don't know how to proceed.
Thanks you in advance.
You can access the application session in Twig using {{ app.session }}. So something like this is possible:
{{ url('myapp.whatever', {year: app.session.get('current_year')}) }}
Since you have a bit of logic around your current year stuff (if it's not set in the session fallback to the current year, etc), a twig extension that provides a funtion to fetch the current year may be a better way to go. Quick, untested example:
<?php
use Symfony\Component\HttpFoundation\Session\SessionInterface;
class CurrentYearExtension extends \Twig_Extension
{
private $session;
private $defaultYear;
public function __construct(SessionInterface $session, $defaultYear)
{
$this->session = $session;
$this->defaultYear = $defaultYear;
}
public function getFunctions()
{
return [
new \Twig_SimpleFunction('current_year', [$this, 'currentYear']),
];
}
public function currentYear()
{
return $this->session->get('current_year') ?: $this->defaultYear;
}
}
Then add it to your container and tag it with the twig.extension tag.
<service id="myapp.currentyear_extension" class="CurrentYearExtension" public="false">
<argument type="service" id="session" />
<argument>%current_year%</argument>
<tag name="twig.extension" />
</service>
And use it in your route generation:
{{ url('myapp.whatever', {year: current_year()}) }}
If you need the current year other places than twig, then pull a re-usable object out of the twig extension and use that both with the extension and elsewhere.
Thanks to the answer given by #chrisguitarguy : https://stackoverflow.com/a/13495302/1031898 I found a way to resolve my problem.
In fact, I could use my routeListener to do the job.
I just needed to implement the Router Interface.
LastRouteListener.php (updated)
class LastRouteListener
{
private $router;
public function __construct(RouterInterface $router)
{
$this->router = $router;
}
public function onKernelRequest(GetResponseEvent $event)
{
// Don't store subrequests
if ($event->getRequestType() !== HttpKernel::MASTER_REQUEST) {
return;
}
$request = $event->getRequest();
$session = $request->getSession();
$context = $this->router->getContext();
// If current_year exists in session, replace route parameter with it
if ($session->has('current_year')) {
$context->setParameter('year', $session->get('current_year'));
}
// Else, we set the current year by default
else {
$context->setParameter('year', date('Y'));
}
$routeName = $request->get('_route');
$routeParams = $request->get('_route_params');
if ($routeName[0] == "_") {
return;
}
$routeData = ['name' => $routeName, 'params' => $routeParams];
// On ne sauvegarde pas la même route plusieurs fois
$thisRoute = $session->get('this_route', []);
if ($thisRoute == $routeData) {
return;
}
$session->set('last_route', $thisRoute);
$session->set('this_route', $routeData);
}
}
Don't forget to inject the #router argument in services.yml
myapp.last_route_event_listener:
class: MyApp\EventListener\LastRouteListener
tags:
- { name: kernel.event_listener, event: kernel.request, method: onKernelRequest, priority: 30 }
arguments: ['#router']
And then, no need to use 'current_year' default parameter in route config anymore.
routes.yml
mes_routes:
resource: "mes_routes.yml"
prefix: /{year}
MyAppExtension.php
class MyAppExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container)
{
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
$loader->load('services.yml');
}
}
I'm a little stuck and unable to find the answer to this.
In my app test I've created two Entities User and Comment both are mapped correctly.
I have created a small controller which depending on the user will add the comment and the data to the ACL tables, if I create my comment as a standard user with the associated for of 'ROLE_USER', and Try to access it as user with the role 'ROLE_ADMIN' I get access denied, it seems to completely ignore the security.yml hierarchy.
I know this works by adding instead of the userid the ROLE_USER etc but I don't want to do it this way.
Examples of my code are below.
CommentController
<?php
namespace ACL\TestBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Component\HttpFoundation\Request;
use ACL\TestBundle\Forms\Type\commentType;
use ACL\TestBundle\Entity\Comment;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Acl\Domain\ObjectIdentity;
use Symfony\Component\Security\Acl\Domain\UserSecurityIdentity;
use Symfony\Component\Security\Acl\Permission\MaskBuilder;
class DefaultController extends Controller
{
/**
* #Route("/", name="_default")
* #Template()
*/
public function indexAction()
{
die('success');
}
/**
* #Route("/comment/new/")
* #Template()
*/
public function newAction(Request $request)
{
$comment = new Comment();
$form = $this->createForm(new commentType(), $comment);
$form->handleRequest($request);
if ($form->isValid()) {
$comment->setUsers($this->getUser());
$em = $this->getDoctrine()->getManager();
$em->persist($comment);
$em->flush();
// creating the ACL
$aclProvider = $this->get('security.acl.provider');
$objectIdentity = ObjectIdentity::fromDomainObject($comment);
$acl = $aclProvider->createAcl($objectIdentity);
// retrieving the security identity of the currently logged-in user
$securityIdentity = UserSecurityIdentity::fromAccount($this->getUser());
// grant owner access
$acl->insertObjectAce($securityIdentity, MaskBuilder::MASK_OWNER);
$aclProvider->updateAcl($acl);
}
return array(
'form' => $form->createView(),
);
}
/**
* #Route("/comment/{id}/", requirements={"id":"\d+"})
* #Template()
*/
public function editAction(Request $request,$id)
{
$em = $this->getDoctrine()->getManager();
$comment = $em->find('ACLTestBundle:Comment', $id);
$securityContext = $this->get('security.context');
// check for edit access
if (false === $securityContext->isGranted('EDIT',$comment)) {
throw new AccessDeniedException();
}
$form = $this->createForm(new commentType(), $comment);
$form->handleRequest($request);
if($form->isValid()){
$em->persist($comment);
$em->flush();
}
return array('form' => $form->createView());
}
}
security.yml
security:
encoders:
ACL\TestBundle\Entity\User: plaintext
acl:
connection: default
providers:
database:
entity: { class: ACLTestBundle:User }
role_hierarchy:
ROLE_ADMIN: [ROLE_USER, ROLE_ALLOWED_TO_SWITCH]
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
pattern: ^/
provider: database
anonymous: true
logout: true
switch_user: true
form_login:
login_path: _security_login
access_control:
- { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/, roles: IS_AUTHENTICATED_FULLY }
I appreciate any advice!
The problem is that you are adding adding ACL base on UserIdentity and want to check the gran base on RoleIdentity. If you want to do it Role base change the creating ACL as below
// creating the ACL
$aclProvider = $this->get('security.acl.provider');
$objectIdentity = ObjectIdentity::fromDomainObject($comment);
$acl = $aclProvider->createAcl($objectIdentity);
// retrieving the security identity of the currently logged-in user
$securityIdentity = UserSecurityIdentity::fromAccount($this->getUser());
// grant owner access
$acl->insertObjectAce($securityIdentity, MaskBuilder::MASK_OWNER);
// grant EDIT access to ROLE_ADMIN
$securityIdentity = new RoleSecurityIdentity('ROLE_ADMIN');
$acl->insertObjectAce($securityIdentity, MaskBuilder::MASK_EDIT);
$aclProvider->updateAcl($acl);
As you see I kept the owner access for the specific user then I added Edit access for ROLE_ADMIN. You can keep the controller as is.
If you don't want to make it Role base but just want to give an exception for admin users you can change your controller as
// check for edit access
if (false === $securityContext->isGranted('EDIT',$comment) && false === $securityContext->isGranted('ROLE_ADMIN') ) {
throw new AccessDeniedException();
}
I am developing a new Symfony2 project (eibrowser) and am trying to set up two things:
1. Custom Authentication Provider
2. A custom User provider
I followed the example / tutorial (word by word nearly) given on the Symfony2 documentation pages:
http://symfony.com/doc/current/cookbook/security/custom_authentication_provider.html
My bundle in which the security folder lives is called 'NiwaUtilitiesBundle'.
In the end of all I end up with this (horribly) confusing error message
Catchable Fatal Error: Argument 3 passed to Niwa\UtilitiesBundle\Security\Firewall
\WsseListener::__construct() must be an instance of Niwa\UtilitiesBundle\SecurityFirewall
\SessionAuthenticationStrategyInterface, none given, called in /home/uwe/www/eibrowser2
/app/cache/dev/appDevDebugProjectContainer.php on line 2007 and defined in /home/uwe
/www/eibrowser2/src/Niwa/UtilitiesBundle/Security/Firewall/WsseListener.php line 21
I am not even sure which parts of my code to show here ....
But the code below shows the WsseProvider which I needed to change a bit as we are using a user management system in house which does the authentication for us
<?php
namespace Niwa\UtilitiesBundle\Security\Authentication\Provider;
use Symfony\Component\Security\Core\Authentication\Provider AuthenticationProviderInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\NonceExpiredException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Niwa\UtilitiesBundle\Security\Authentication\Token\WsseUserToken;
use Niwa\UsermanagementBundle\Lib\UserManagement;
class WsseProvider implements AuthenticationProviderInterface
{
private $userProvider;
private $cacheDir;
private $userManagement;
public function __construct(UserProviderInterface $userProvider, $cacheDir,$userManagement)
{
$this->userProvider = $userProvider;
$this->cacheDir = $cacheDir;
$this->userManagement = $userManagement;
}
public function authenticate(TokenInterface $token)
{
$user = $this->userProvider->loadUserByName($token->getUsername());
if ($user && $this->validateDigest($token->digest, $token->nonce, $token->created, $user->getPassword())) {
$authenticatedToken = new UmUserToken($user->getRoles());
$authenticatedToken->setUser($user);
return $authenticatedToken;
}
throw new AuthenticationException('The WSSE authentication failed.');
}
/**
* This function is specific to Wsse authentication and is only used to help this example
*
* For more information specific to the logic here, see
* https://github.com/symfony/symfony-docs/pull/3134#issuecomment-27699129
*/
protected function validateDigest($digest, $nonce, $created, $secret)
{
// Check created time is not in the future
if (strtotime($created) > time()) {
return false;
}
// Expire timestamp after 5 minutes
if (time() - strtotime($created) > 300) {
return false;
}
// Validate that the nonce is *not* used in the last 5 minutes
// if it has, this could be a replay attack
if (file_exists($this->cacheDir.'/'.$nonce) && file_get_contents($this->cacheDir.'/'.$nonce) + 300 > time()) {
throw new NonceExpiredException('Previously used nonce detected');
}
// If cache directory does not exist we create it
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0777, true);
}
file_put_contents($this->cacheDir.'/'.$nonce, time());
// Validate Secret
// $expected = base64_encode(sha1(base64_decode($nonce).$created.$secret, true));
//return $digest === $expected;
$username = $this->extractUsername($token->getUsername());
$domain = $this->extractDomain($token->getUsername());
$response = $this->userManagement->authenticate($username,$token->getCredentials(),$domain);
return 200 == $response[$status];
}
public function supports(TokenInterface $token)
{
return $token instanceof WsseUserToken;
}
}
I am trying to make Symfony use my custom provider by setting configuring my security,yml this way:
security:
providers:
user_provider:
id: um.user.provider
encoders:
Niwa\UtilitiesBundle\Security\User\UmUser: plaintext
firewalls:
wsse_secured:
pattern: ^/
provider: user_provider
wsse: true
form_login:
login_path: /login
check_path: /login_check
anonymous: ~
The user provider is my custom user provider which I also created following the documentation. I created unit tests around it and it seems to work fine...
If I take the line 'wsse: true' out, Symfony2 will work without that error message but will simply use its standard authentication...
I know this ticket is a bit messy - but I just don't know where to start with his problem really.
If you have any idea what could be wrong - please let me know,
Uwe
My application has the following hierarchy of roles:
ROLE_SUPER_ADMIN
ROLE_ADMIN
ROLE_USER
I have my controller that makes this work:
/**
* #Route("/admin/delete/{id}", name="_admin_delete")
* #Secure(roles="ROLE_ADMIN")
*/
public function deleteuserAction($id)
{
$user = $this->container->get('fos_user.user_manager')->findUserBy(array('id' => $id));
if (null === $user) {
throw $this->createNotFoundException('User id not found');
}
if ($user->hasRole('ROLE_ADMIN')) {
// You can not delete this user!
throw new ...
}
// delete user
$this->container->get('fos_user.user_manager')->deleteUser($user);
// ...
}
deleteuserAction is accessible to all those with ROLE_ADMIN.. but I want that they can not delete users who have the role ROLE_ADMIN or HIGHER.
There is a proper way to do this?
With this configuration you can delete ROLE_SUPER_ADMIN... yeah, you can add it to the list to block it, but with a complicated hierarchy can become difficult to manage.
If you don't want to use ACLs, another option would be to create a custom voter that handles your hierarchy. A basic voter is described here: http://symfony.com/doc/current/cookbook/security/voters.html