I come from Magento and I now use Sylius to modernize things a bit and have access to a less xml oriented platform because I find it really painful in 2022...
Unfortunately I did not find anything in Sylius regarding the management of prices according to the client currently logged.
So I want to use groups and channels: I added a channel relation to a user group to be able to use a channel according to the logged in user.
/**
* #ORM\Entity
* #ORM\Table(name="sylius_customer_group")
*/
class CustomerGroup extends BaseCustomerGroup
{
/**
* #ORM\ManyToOne(targetEntity=Channel::class)
*/
private $channel;
public function getChannel(): ?Channel
{
return $this->channel;
}
public function setChannel(?Channel $channel): self
{
$this->channel = $channel;
return $this;
}
}
Here is what I am trying to do with a service
services:
ChangingContextWithCustomerGroup:
class: App\Context\RequestQueryChannelContext
arguments:
- '#sylius.repository.channel'
- '#request_stack'
- '#sylius.context.customer'
tags:
- { name: sylius.context.channel, priority: 150 }
// src/Context/RequestQueryChannelContext.php
public function getChannel(): ChannelInterface
{
$request = $this->requestStack->getMainRequest();
if (!$request) {
throw new ChannelNotFoundException('Request Not Found!');
}
$customer = $this->customerContext->getCustomer();
if (!$customer instanceof Customer) {
throw new ChannelNotFoundException('Customer Not Found!');
}
$group = $customer->getGroup();
if (!$group instanceof CustomerGroup) {
throw new ChannelNotFoundException('Group Not Found!');
}
$channel = $group->getChannel();
if (!$channel instanceof ChannelInterface) {
throw new ChannelNotFoundException('Channel Not Found!');
}
return $channel;
}
My problem is that I can't get the customer on the mainRequest. It is null, so I cant have the customer => group => channel.
It works very well when I force the channel like this :
public function getChannel(): ChannelInterface
{
// ...
return $this->channelRepository->findOneByCode('fooBar');
}
so my system doesn't work. Is there a better solution?
thanks
The problem here is that the Channel context is called from the Sylius\Bundle\ShopBundle\EventListener\NonChannelLocaleListener which has priority 10 on the kernel.request event. In the channel context you want to use the customer context class, which in turn uses the TokenStorage to retrieve the user information.
The token however is not yet populated in the token storage at that point, because that happens in the firewall listener (Symfony\Bundle\SecurityBundle\Debug\TraceableFirewallListener in dev environments), which has priority 8.
The solution I found is to lower the priority of the NonChannelLocaleListener to 7. That will make sure that the token is available and that the customer context can be used to retrieve the customer/shop user information.
Lowering the priority of that listener can be done by overriding the service definition in config/services.yaml:
services:
...
sylius.listener.non_channel_request_locale:
class: Sylius\Bundle\ShopBundle\EventListener\NonChannelLocaleListener
arguments:
- '#router'
- '#sylius.locale_provider'
- '#security.firewall.map'
- ['%sylius_shop.firewall_context_name%']
tags:
- { name: kernel.event_listener, event: kernel.request, method: restrictRequestLocale, priority: 7 }
Please note that this is on Sylius 1.10, so it might be possible that on other Sylius versions the priority of these listeners are slightly different. In that case, just use the bin/console debug:event-dispatcher command to figure out what the right priority should be.
Related
Suppose I have two authentication mechanisms. How do I allow/deny a user's access to content depending on which authentication mechanism they used to authenticate themselves.
For example, let's say I have:
two type of users: admin and user such that all admins are users but users are not admins
two authentication mechanisms: admin_login and user_login
I would like to make it so that an admin has to authenticate through admin_login in order to have admin accesses. This means he would be considered a regular user if he was authenticated through user_login.
I first thought about using firewall context, but quickly realized it wasn't gonna work as I would need a firewall to support multiple contexts :
# config/packages/security.yaml
security:
# ...
firewalls:
admin:
# ...
context: // not currently supported
- admin
- user
user:
# ...
context: user
The other idea I came up with was creating a property called is_allow_admin in the User class and using it to change the way the roles are retrieved by setting it in the admin_login authenticator :
// src/Entity/User.php
// ...
class User implements UserInterface
{
// ...
private $is_allow_admin = false;
// ...
public function setIsAllowAdmin(bool $is_allow_admin)
{
$this->is_allow_admin = $is_allow_admin ;
}
public function getRoles(): array
{
if (!$this->is_allow_admin) {
return ['ROLE_USER'];
}
//...
return $roles;
}
// ...
}
// src/Security/AdminAuthenticator.php
class AdminAuthenticator extends AbstractFormLoginAuthenticator
{
//...
public function getUser($credentials, UserProviderInterface $userProvider): ?UserInterface
{
$user = $userProvider->loadUserByUsername($credentials['username']);
if (!$user) {
throw new CustomUserMessageAuthenticationException('username could not be found.');
}
$user->setIsAllowAdmin(true); // <-
return $user;
}
//...
}
This unfortunately doesn't work. Everything goes smoothly with the authenticator, onAuthenticationSuccess is triggered. But somehow in the end, no user is authenticated.
I know it has to do with $this->setIsAllowAdmin(true); since it works correctly when I remove the line.
Is there another way to tackle this problem?
Thank you in advance.
After a lot of effort I was finally able to configure Sonata Admin with ACL following this guide:
https://sonata-project.org/bundles/admin/master/doc/reference/security.html
I wanted to users to be able to view and edit only items with the same country property as the user.
This is my config.yml:
parameters:
locale: en
sonata.user.admin.user.class: AppBundle\Admin\UserAdmin
sonata.admin.security.mask.builder.class: Sonata\AdminBundle\Security\Acl\Permission\MaskBuilder
# SonataAdminBundle Configuration
sonata_admin:
security:
handler: sonata.admin.security.handler.acl
role_admin: ROLE_ADMIN
role_super_admin: ROLE_SUPER_ADMIN
# acl security information
information:
GUEST: [VIEW, LIST]
STAFF: [EDIT, LIST, CREATE]
EDITOR: [OPERATOR, EXPORT]
ADMIN: [MASTER]
# permissions not related to an object instance and also to be available when objects do not exist
# the DELETE admin permission means the user is allowed to batch delete objects
admin_permissions: [CREATE, LIST, DELETE, UNDELETE, EXPORT, OPERATOR, MASTER]
# permission related to the objects
object_permissions: [VIEW, EDIT, DELETE, UNDELETE, OPERATOR, MASTER, OWNER]
I created an AclVoter in order to show/hides elements:
services:
security.acl.voter.country_owned_permissions:
class: AppBundle\Security\Authorization\Voter\CountryOwnedAclVoter
arguments:
- "#security.acl.provider"
- "#security.acl.object_identity_retrieval_strategy"
- "#security.acl.security_identity_retrieval_strategy"
- "#security.acl.permission.map"
- "#logger"
tags:
- { name: monolog.logger, channel: security }
- { name: security.voter, priority: 255 }
public: false
This is the actual class:
<?php
namespace AppBundle\Security\Authorization\Voter;
use FOS\UserBundle\Model\UserInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Acl\Voter\AclVoter;
class CountryOwnedAclVoter extends AclVoter
{
public function supportsClass($class)
{
// support the Class-Scope ACL for votes with the custom permission map
// return $class === 'Sonata\UserBundle\Admin\Entity\UserAdmin' || is_subclass_of($class, 'FOS\UserBundle\Model\UserInterface');
// if you use php >=5.3.7 you can check the inheritance with is_a($class, 'Sonata\UserBundle\Admin\Entity\UserAdmin');
// support the Object-Scope ACL
return is_subclass_of($class, 'AppBundle\Model\CountryOwnedInterface');
}
public function supportsAttribute($attribute)
{
return in_array($attribute, array(
'LIST',
'VIEW',
'EDIT',
'DELETE',
'EXPORT',
));
}
public function vote(TokenInterface $token, $object, array $attributes)
{
if (!$this->supportsClass(get_class($object))) {
return self::ACCESS_ABSTAIN;
}
foreach ($attributes as $attribute) {
if ($this->supportsAttribute($attribute)) {
if ($object->getCountry() != $token->getUser()->getCountry()) {
//if ($object->isSuperAdmin() && !$token->getUser()->isSuperAdmin()) {
// deny a non super admin user to edit a super admin user
return self::ACCESS_DENIED;
}
}
}
// use the parent vote with the custom permission map:
// return parent::vote($token, $object, $attributes);
// otherwise leave the permission voting to the AclVoter that is using the default permission map
return self::ACCESS_ABSTAIN;
}
}
It seems to work fine since a user can only edit items which have the same country as the users. The problem is that he can still view the items in the list.
What am I doing wrong?
As specified in the official documentation I just needed to install a specific bundle:
5.4.6. LIST FILTERING
List filtering using ACL is available as a third party bundle: CoopTilleulsAclSonataAdminExtensionBundle. When enabled,
the logged in user will only see the objects for which it has the VIEW
right (or superior).
This will suffice:
composer require tilleuls/acl-sonata-admin-extension-bundle
In AppKernel.php:
// ACL list filter
new CoopTilleuls\Bundle\AclSonataAdminExtensionBundle\CoopTilleulsAclSonataAdminExtensionBundle(),
I created a EventListener to set the locale based on the user preferences, i set the langage like this in my listener:
$request->setLocale($user->getLanguage());
$request->getSession()->set('_locale',$user->getLanguage());
I tried both..
I register the Listener in the service.yml:
app.event_listener.locale:
class: 'AppBundle\EventListener\LocaleListener'
arguments:
- '#security.token_storage'
tags:
- {name: 'kernel.event_listener', event: 'kernel.request', method: 'onKernelRequest'}
I also tried to add a priority: 17 to the service but it does not change anything...
The listener seems to works, i can get the Locale in my controller with a $request->getLocale()(or session).
But Twig is still in the default language I defined in the config.yml:
parameters:
locale: fr
I'm pretty lost now, any tips ?
I tried a lot of stuff (change the priority, check if the locale is passed to the front etc...)
Finally i forced the translator in my EventListener:
public function onKernelRequest(GetResponseEvent $event)
{
$request = $event->getRequest();
if ($this->tokenStorage->getToken()) {
$user = $this->tokenStorage->getToken()->getUser();
if ($user && $user instanceof User) {
$request->setLocale($user->getLanguage());
} elseif ($request->query->has('locale')) {
$request->setLocale($request->query->get('locale'));
} else {
$request->setLocale($request->getPreferredLanguage());
}
}
$this->translator->setLocale($request->getLocale());
}
I don't understand why, this should be done in the Symfony translator, but it works...
You have to set the locale for the translator to get the right translation in templates.
E.g in controller:
$this->get('translator')->setLocale($user->getLanguage());
I've followed the guide for implementing authentication/authorization and I can login.
I have one main difference though from what's in the guide. Instead of an isActive property I have a status table in my database.
I'm at a loss as to how I would deny/accept logins based on the values in the status table rather than the isActive property referenced in the guide.
I'm not sure what code to post because it works as it does in the guide and I'm pretty sure the Symfony security system handles all the authentication stuff where I can't see it.
Even if you just point me in the right direction I would be grateful.
Edit:
Using ChadSikorra's advice I came up with this code to implement the AdvancedUserInterface functions:
public function isAccountNonExpired()
{
$status = $this->getTblStatus()->getStatustext();
switch ($status){
case "expired":
return false;
default:
return true;
}
}
public function isAccountNonLocked()
{
$status = $this->getTblStatus()->getStatustext();
switch ($status){
case "locked":
return false;
case "suspended":
return false;
case "registered":
return false;
default:
return true;
}
}
public function isCredentialsNonExpired()
{
return $this->pwdexpired;
}
public function isEnabled()
{
$status = $this->getTblStatus()->getStatustext();
if($status != 'active')
return false
else
return true;
}
The next question I have then is how do I handle the exceptions that are thrown when a user has one of the statuses?
Based on what I have so far I think this is doable by catching the errors in the loginAction. What I don't know how to do is identify the errors, but I'll keep digging.
/**
* #Route("/Login", name="wx_exchange_login")
* #Template("WXExchangeBundle:User:login.html.twig")
* User login - Open to public
* Authenticates users to the system
*/
public function loginAction(Request $request)
{
$session = $request->getSession();
if ($this->get('security.context')->isGranted('IS_AUTHENTICATED_REMEMBERED'))
{
// redirect authenticated users to homepage
return $this->redirect($this->generateUrl('wx_exchange_default_index'));
}
// get the login error if there is one
if ($request->attributes->has(SecurityContext::AUTHENTICATION_ERROR)) {
$error = $request->attributes->get(
SecurityContext::AUTHENTICATION_ERROR
);
} else {
$error = $session->get(SecurityContext::AUTHENTICATION_ERROR);
$session->remove(SecurityContext::AUTHENTICATION_ERROR);
}
if($error instanceof LockedException)
{
}
return $this->render(
'WXExchangeBundle:User:login.html.twig',
array(
// last username entered by the user
'last_username' => $session->get(SecurityContext::LAST_USERNAME),
'error' => $error,
)
);
}
I am now able to check for the type of Exception, but I'm at a loss as to how to get the specific status so that I can redirect to the correct place. This is the last piece of the puzzle.
You could add mapping to your custom status table on the user entity, like so:
/**
* #ORM\OneToOne(targetEntity="AccountStatus")
* #ORM\JoinColumn(name="status_id", referencedColumnName="id", nullable=true)
*/
private $accountStatus;
This would also require creating an entity describing the custom status table. Then you could use this mapping in your user entity by implementing Symfony\Component\Security\Core\User\AdvancedUserInterface as referenced in the guide you linked. Then implement the isEnabled function something like this...
public function isEnabled()
{
return $this->getAccountStatus()->getIsActive(); /* Or whatever you named it */
}
EDIT:
Based on the API Doc for AdvancedUserInterface, if you want to do custom logic for handling the different statuses you'll need to register an exception listener...
If you need to perform custom logic for any of these situations, then
you will need to register an exception listener and watch for the
specific exception instances thrown in each case. All exceptions are a
subclass of AccountStatusException
There's a pretty good Cookbook article for creating something like this here. The basic process in this instance would be to create the class for the listener...
src/Acme/DemoBundle/EventListener/AcmeExceptionListener.php
namespace Acme\DemoBundle\EventListener;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\DisabledException;
use Symfony\Component\Security\Core\Exception\LockedException;
class AcmeExceptionListener
{
public function onKernelException(GetResponseForExceptionEvent $event)
{
$exception = $event->getException();
if ($exception instanceof DisabledException) {
// Customize your response object to display the exception details
$response = new Response();
$response->setContent('<html><body><h1>Custom disabled page!</h1></body></html>');
// Send the modified response object to the event
$event->setResponse($response);
}
elseif ($exception instanceof LockedException) {
// Or render a custom template as a subrequest instead...
$kernel = $event->getKernel();
$response = $kernel->forward('AcmeDemoBundle:AccountStatus:locked', array(
'exception' => $exception,
));
$event->setResponse($response);
}
// ... and so on
}
}
The above are just basic examples, but it gives you the gist anyway. Technically I guess you could also make custom exceptions by extending AccountStatusException and then throw them in your logic for your AdvancedUserInterface implementation. Then you would know exactly which status you are catching. Anyway, then make sure to register the listener as a service.
app/config/config.yml
services:
kernel.listener.your_listener_name:
class: Acme\DemoBundle\EventListener\AcmeExceptionListener
tags:
- { name: kernel.event_listener, event: kernel.exception, method: onKernelException }
Another way to go about this would be to implement some sort of a custom User Checker. See this question: Symfony2 custom user checker based on accepted eula
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