Symfony : how to set init data on login - symfony

I'm facing a dilemna as well as an optimization problem :
In my Symfony 2.8 application, I have custom settings and other business logic data to load (from database tables, not from SF parameters) that a logged in user can be needed to use at different pages.
At first those data where scarcely needed, so i loaded them only when the page required it. But now as the application grows, i need them more often.
So i was thinking about loading them when the user logs in, and save them as localStorage on client side because cookies are too small.
But i'm not sure how to best do it.
I have a login success handler, that allows to redirect on the correct page when user is successfully logged.
For the moment i have this one :
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationChecker;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Router;
class LoginSuccessHandler implements AuthenticationSuccessHandlerInterface
{
protected $router;
protected $authorizationChecker;
public function __construct(Router $router, AuthorizationChecker $authorizationChecker)
{
$this->router = $router;
$this->authorizationChecker = $authorizationChecker;
}
/**
* What to do when user logs in.
*/
public function onAuthenticationSuccess(Request $request, TokenInterface $token)
{
$response = null;
if ($this->authorizationChecker->isGranted('ROLE_ADMIN')) {
//an admin is redirected towards this page
$response = new RedirectResponse($this->router->generate('my_back_admin'));
} else if ($this->authorizationChecker->isGranted('ROLE_USER')) {
//a user is redirected towards this page
$response = new RedirectResponse($this->router->generate('my_back_user'));
}
//redirect to any last visited page if any
$key = '_security.main.target_path';
if ($request->getSession()->has($key)) {
$url = $request->getSession()->get($key);
$request->getSession()->remove($key);
$response = new RedirectResponse($url);
}
return $response;
}
}
So i was thinking about adding a setInitialData() method in which i would get all the settings i need and modifying onAuthenticationSuccess :
public function onAuthenticationSuccess(Request $request, TokenInterface $token)
{
$response = null;
//retrieve array of data to be set in the init
$toBeSaved = $this->setInitialData();
if ($this->authorizationChecker->isGranted('ROLE_ADMIN')) {
//an admin is redirected towards this page
$response = new RedirectResponse($this->router->generate('my_back_admin', ['initdata'=>$toBeSaved]));
} else if ($this->authorizationChecker->isGranted('ROLE_USER')) {
//a user is redirected towards this page
$response = new RedirectResponse($this->router->generate('my_back_user', ['initdata'=>$toBeSaved]));
}
//redirect to any last visited page if any
$key = '_security.main.target_path';
if ($request->getSession()->has($key)) {
$url = $request->getSession()->get($key);
$request->getSession()->remove($key);
$response = new RedirectResponse($url, ['initdata'=>$toBeSaved]);
}
return $response;
}
And then on the main template, i would retrieve that data
{% for paramName, paramValue in app.request.query %}
{% if paramName == 'initdata' %}
<div id="initdata" data-init="{{paramValue|json_encode}}"></div>
{% endif %}
{% endfor %}
and add a javascript block with something like :
<script>
if ($('#initdata').length > 0){
localStorage.removeItem('initdata');
localStorage.setItem('initdata', JSON.stringify($('#initdata').data('init')));
}
</script>
But this method doesn't seems right : i'm not sure this is the best way to do it.
And furthermore, since these are sent in a redirect, the data are shown in the query string, which is not ideal :(

This will not fly as by having multiple parameters you create multiple <div> elements with identical ID = initdata. Subsequent jQuery selector will only capture the first one (afaik).
I see that you indeed send params via query string. This takes care of multiple value, but this also exposes your user setting in user URL, doesn't it? If it does, it has security vulnerability all over the wall. Remember, such URLs are persisted in your browser's history.
Instead, I suggest you create a separate controller action /_get_user_settings which you will call via AJAX GET. Server will serve JSON response which you can save to your localStorage with little or no problem at all.
Hope this helps...

Related

How can I use something like the onAfterPublish() hook on a versioned dataobject in SilverStripe

I have a simple versioned dataobject in SilverStripe. I'm trying to hook into the publication action and send out an email whenever the dataobject is published.
I don't think the onAfterPublish() method is available on dataobjects (only pages), so I'm looking to either mimic that or get enough logic working in the onAfterWrite() function.
Here's my code at the moment:
static $has_written = false; // Hack so it only fires once on write()
public function onAfterWrite()
{
parent::onAfterWrite();
if (!self::$has_written) {
$stage = $this->getSourceQueryParam("Versioned.stage");
if ($stage === 'Live') {
$email = new Email();
...
$email->send();
}
}
self::$has_written = true;
}
The Versioned class, that is used for versioning DataObjects, does not have an onAfterPublish hook but it does have an onBeforeVersionedPublish hook that could be used to send out emails:
public function onBeforeVersionedPublish($fromStage, $toStage, $createNewVersion = false) {
$email = Email::create();
// ...
$email->send();
}

Can JMSI18nRoutingBundle use HTTP Accept-Language array?

I'm trying to do an internationalized website, with an URL prefix for each language I translated (eg. /fr/my/page or /it/my/page).
I tried JMSI18nRoutingBundle and it works pretty good with almost no additional configuration. But I really want to determine automatically the user preferred language.
The user's favorite languages are transmitted into the Accept-Language HTTP header, and I want to choose the first language I have a translation for.
Here is my JMSI18nRouting config:
jms_i18n_routing:
default_locale: en
locales: [fr, en]
strategy: prefix_except_default
I want this type of behaviour:
http://mywebsite.com/my/page do an automatic language detection then a redirection to /xx/... (where xx is the user favorite language) because language is not specified in URL — Presently the default language is EN.
http://mywebsite.com/XX/my/page shows the page in XX language — Presently, works fine.
Any idea to do this ? Is the config OK ?
Oh, and, if anyone has a solution to do the same thing in pure Symfony (without JMSI18nRoutingBundle), my ears are widely open.
EDIT / Found a way to have intelligent redirections with JMSI18nRoutingBundle to respect user's favorite language or let user force the display of a language. See my answer.
Finally, I answer my question.
I developed a small "patch" that uses JMSI18nRoutingBundle and detects the user's preferred language, and also let the user force a language.
Create listener YourBundle/EventListener/LocaleListener.php
This listener will change the URL if the user's preferred locale is different to the locale defined by Symfony or JMSI18nRoutingBundle. In this way, you have two URL for two different contents in two different languages : it's SEO friendly.
You can also create a language selector composed of links hrefing to ?setlang=xx where xx is the language the user wants to display. The listener will detect the setlang query and will force the display of the xx lang, including in the next requests.
Note the $this->translatable = [... array. It let you define what parts of your site are translated/translatable. The granularity can be defined from the vendor to the action method.
You can also create a config node to define your translatable vendors/bundles/controllers, I don't made this because of performance considerations.
<?php
namespace YourVendor\YourBundle\EventListener;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class LocaleListener implements EventSubscriberInterface
{
private $defaultLocale;
private $acceptedLocales;
private $translatable;
public function __construct($router, $defaultLocale, $acceptedLocales)
{
$this->router = $router;
$this->defaultLocale = $defaultLocale;
$this->acceptedLocales = $acceptedLocales;
$this->translatable = [
'Vendor1',
'Vendor2\Bundle1',
'Vendor2\Bundle2\Controller1',
'Vendor2\Bundle2\Controller2::myPageAction',
];
}
public function onKernelRequest(GetResponseEvent $event)
{
$request = $event->getRequest();
$route = $request->get('_route');
if(!empty($newLocale = $request->query->get('setlang'))) {
if(in_array($newLocale, $this->acceptedLocales)) {
$cookie = new Cookie('force_lang', $newLocale, time() + 3600 * 24 * 7);
$url = $this->router->generate($route, ['_locale' => $newLocale] + $request->attributes->get('_route_params'));
$response = new RedirectResponse($url);
$response->headers->setCookie($cookie);
$event->setResponse($response);
}
} else if($this->translatable($request->attributes->get('_controller'))) {
$preferred = empty($force = $request->cookies->get('force_lang')) ? $request->getPreferredLanguage($this->acceptedLocales) : $force;
if($preferred && $request->attributes->get('_locale') != $preferred) {
$url = $this->router->generate($route, ['_locale' => $preferred] + $request->attributes->get('_route_params'));
$event->setResponse(new RedirectResponse($url));
}
}
}
private function translatable($str)
{
foreach($this->translatable as $t) {
if(strpos($str, $t) !== false) return true;
}
return false;
}
public static function getSubscribedEvents()
{
return [ KernelEvents::REQUEST => [['onKernelRequest', 200]] ];
}
}
Bind your listener on the HTTP kernel.
Edit your services.yml file.
services:
app.event_listener.locale_listener:
class: YourVendor\YourBundle\EventListener\LocaleListener
arguments: ["#router", "%kernel.default_locale%", "%jms_i18n_routing.locales%"]
tags:
- { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }
Configuration of JMSI18nRoutingBundle
You have nothing to change.
Example:
# JMS i18n Routing Configuration
jms_i18n_routing:
default_locale: "%locale%"
locales: [fr, en]
strategy: prefix_except_default
Here's a method to do it using straight Symfony. It might feel a tad hacky because it requires specifying 2 routes per each action, so if someone can think of a better way I'm all ears.
First, I would define some sort of config parameter for all of the acceptable locales, and list the first one as the default
parameters.yml.dist:
parameters:
accepted_locales: [en, es, fr]
Then make sure your Controller routes match for when _locale is both set and not set. Use the same route name for both, except suffix the one without a _locale with a delimiter like |:
/**
* #Route("/{_locale}/test/{var}", name="test")
* #Route( "/test/{var}", name="test|")
*/
public function testAction(Request $request, $var, $_locale = null)
{
// whatever your controller action does
}
Next define a service that will listen on the Controller event and pass your accepted locales to it:
<service id="kernel.listener.locale" class="My\Bundle\EventListener\LocaleListener">
<tag name="kernel.event_listener" event="kernel.controller" method="onKernelController" />
<argument>%accepted_locales%</argument>
</service>
Now use the service to detect if _locale is set in your route, and if not, determine the locale based on the HTTP_ACCEPT_LANGUAGE header and redirect to the route that contains it. Here's an example listener that will do this (I added comments to explain what I was doing):
namespace NAB\UtilityBundle\EventListener;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\Event\FilterControllerEvent;
class ControllerListener
{
private $acceptedLocales;
public function __construct(array $acceptedLocales)
{
$this->acceptedLocales = $acceptedLocales;
}
public function onKernelController(FilterControllerEvent $event)
{
if (HttpKernelInterface::MASTER_REQUEST != $event->getRequestType()) {
return;
}
$controller = $event->getController();
if (!is_array($controller)) {
return;
}
$request = $event->getRequest();
$params = $request->attributes->get('_route_params');
// return if _locale is already set on the route
if ($request->attributes->get('_locale')) {
return;
}
// if the user has accepted languages set, set the locale on the first match found
$languages = $request->server->get('HTTP_ACCEPT_LANGUAGE');
if (!empty($languages))
{
foreach (explode(',', $languages) as $language)
{
$splits = array();
$pattern = '/^(?P<primarytag>[a-zA-Z]{2,8})(?:-(?P<subtag>[a-zA-Z]{2,8}))?(?:(?:;q=)(?P<quantifier>\d\.\d))?$/';
// if the user's locale matches the accepted locales, set _locale in the route params
if (preg_match($pattern, $language, $splits) && in_array($splits['primarytag'], $this->acceptedLocales))
{
$params['_locale'] = $splits['primarytag'];
// stop checking once the first match is found
break;
}
}
}
// if no locale was found, default to the first accepted locale
if (!$params['_locale']) {
$params['_locale'] = $this->acceptedLocales[0];
}
// drop the '|' to get the appropriate route name
list($localeRoute) = explode('|', $request->attributes->get('_route'));
// attempt get the redirect URL but return if it could not be found
try {
$redirectUrl = $controller[0]->generateUrl($localeRoute, $params);
}
catch (\Exception $e) {
return;
}
// set the controller response to redirect to the route we just created
$event->setController(function() use ($redirectUrl) {
return new RedirectResponse($redirectUrl);
});
}
}
For further explanation on setting up a before filter on a Controller, check out the Symfony documentation here. If you use something like this, be very careful that every route name is defined properly.
Another more usable solution
Go to the vendor of I18nRoutingBundle and edit the listener
/www/vendor/jms/i18n-routing-bundle/JMS/I18nRoutingBundle/EventListener
Replace
$locale = $this->localeResolver->resolveLocale($request, $this->locales) ?: $this->defaultLocale;
by
$locale = $this->localeResolver->resolveLocale($request, $this->locales) ?: $request->getPreferredLanguage($this->locales);
(It is cleaner to overide the listener than to directly edit the vendors)

get users who have a specific role

I need to get the list of all my users having a specific role, is there any way to do it easily? The solution I figured out for now would be to retrive all users then apply a filter on each using is granted function (which is hardcore)
PS: I don't like using the db request that skims over data and if the user role equals the wanted role it returns it, else it doesn't. Which means that we don't take into account users with super roles.
Because of the role hierarchy, I don't see a way to avoid grabbing all the users and then filtering. You could make a user role table and add all possible user roles but that would get out of date if you changed the hierarchy.
However, once you have all the roles for a given user then you can test if a specific one is supported.
There is a role hierarchy object to help.
use Symfony\Component\Security\Core\Role\Role;
use Symfony\Component\Security\Core\Role\RoleHierarchy;
class RoleChecker
{
protected $roleHeirarchy;
public function __construct(RoleHierarchy $roleHierarchy)
{
$this->roleHierarchy = $roleHierarchy; // serviceId = security.role_hierarchy
}
protected function hasRole($roles,$targetRole)
{
$reachableRoles = $this->roleHierarchy->getReachableRoles($roles);
foreach($reachableRoles as $role)
{
if ($role->getRole() == $targetRole) return true;
}
return false;
}
}
# services.yml
# You need to alias the security.role_hierarchy service
cerad_core__role_hierarchy:
alias: security.role_hierarchy
You need to pass an array of role objects to hasRole. This is basically the same code that the security context object uses. I could not find another Symfony service just for this.
The is also a parameter value called '%security.role_hierarchy.roles%' that comes in handy at times as well.
Symfony 5 answer, it's a little bit easier:
namespace App\Controller;
...
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
use Symfony\Component\Security\Core\Role\RoleHierarchy;
class UserController extends AbstractController
{
private $roleHierarchy;
/**
* #Route("/users", name="users")
*/
public function usersIndex(RoleHierarchyInterface $roleHierarchy)
{
$this->roleHierarchy = $roleHierarchy;
// your user service or your Doctrine code here
$users = ...
foreach ($users as $user) {
$roles = $roleHierarchy->getReachableRoleNames($user->getRoles());
\dump($roles);
if ($this->isGranted($user, 'ROLE_SUPER_ADMIN')) {
...
}
}
...
}
private function isGranted(User $user, string $role): bool
{
$reachableRoles = $this->roleHierarchy->getReachableRoleNames($user->getRoles());
foreach ($reachableRoles as $reachableRole) {
if ($reachableRole === $role) {
return true;
}
}
return false;
}
}
Note: I put everything in the controller for the sake of simplicity here, but of course I'd recommend to move the Role Management code into a separate service.

Symfony 2: Two step (not two-factor) authentication

I need to implement a two step (not two-factor) authentication in Symfony 2.3. The first step is the usual user+password+csrf form. The second step is "Terms & Conditions", which the user should see when they first log in or when the terms are updated, and they should have to tick a box in order to proceed to the rest of the site.
The second step isn't really an authentication step, but a user shouldn't be able to access the rest of the site unless that second step is acted upon, so it makes sense to conceptually think of it as part of the authentication.
While writing this, the rubber duck tells me that I should think about authorisation instead, and the idea of starting the user on a "didn't accept terms yet" role, and updating the role to "fully authorised user" if the terms have been accepted. This sounds like the most sound solution so far, as I can let the firewall take care of the logic.
Stumbled upon these pieces of information so far:
http://blogsh.de/2011/11/15/change-user-roles-during-a-session-in-symfony/
http://php-and-symfony.matthiasnoback.nl/2012/07/symfony2-security-creating-dynamic-roles-using-roleinterface/
There's one behaviour that I expect to encounter as I dig deeper into this: the firewall will display an error instead of re-directing the user to the Terms page and then let them on their way once they accept them.
Has anybody done this before, so I have to invent as little of the wheel as possible?
I found somebody with a similar problem, and he received a solution I could use:
Symfony 2 : Redirect a user to a page if he has a specific role
The event listener class:
namespace Acme\DemoBundle\Lib;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\HttpKernel;
use Acme\DemoBundle\Entity\User;
class TermsAndConditionsRequestListener
{
private $security;
private $router;
public function __construct($security, $router)
{
$this->security = $security;
$this->router = $router;
}
public function onKernelRequest(GetResponseEvent $event)
{
/* http://symfony.com/doc/2.3/cookbook/service_container/event_listener.html */
if (HttpKernel::MASTER_REQUEST !== $event->getRequestType())
{
// don't do anything if it's not the master request
return;
}
$request = $event->getRequest();
$route = $request->attributes->get('_route');
if ($route === '_wdt' || substr_compare($route, '_profiler', 0, 9) === 0)
{
// ignore development routes
return;
}
if (in_array($route, array('terms_and_conditions_force', 'terms_and_conditions_accept')))
{
// don't redirect into an infinite loop
return;
}
$token = $this->security->getToken();
$user = $token ? $token->getUser() : null;
$user_role = ($user instanceof User) ? $user->getRole() : null;
if ($user_role === 'ROLE_USER' && (is_null($user->getTermsAcceptedDate()) || $terms_are_newer_than_acceptance_date))
{
$url = $this->router->generate('terms_and_conditions_force');
$event->setResponse(new RedirectResponse($url));
}
}
}
The event listener service:
acme.wvml.event_listener.request.terms_and_conditions:
class: Acme\DemoBundle\Lib\TermsAndConditionsRequestListener
arguments: [#security.context, #router]
tags:
- { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }
You will have to extend the symfony UserAuthenticationProvider. you'll probably want to add the check in the checkAuthentication function and if it fails return the error message regarding the terms and conditions.

How to define findOneBy($criteria) function?

I am very new to Symfony2, designing a simple login system. The userclass,
router, controlerclass everything is working fine. I am stuck to
userRepository class.
My controller part is:
public function loginProcessAction(Request $request){
if($request->getMethod() == "POST") {
$username = $request->get('username');
$password = $request->get('password');
$em= $this->getDoctrine()->getEntityManager();
$repository = $em->getRepository("LoginLoginBundle:Student");
$user = $repository->findOneBy(array('username'=>$username,
'password'=>$password));
if($user){
return $this->render('loginSuccess twig page') ;
}
else{
return $this->render('error twig page') ;
}
} else{
return $this->render("login error page");
}
}
How to define findOneBy(username, password) function in reopository class.
This is not the best way to handle authentication when using Symfony2. Take a look at the Security component integrated with Symfony2.
So check How Security Works: Authentication and Authorization part of the security documentation, all you need to implement/configure is Firewalls to handle Authentication and
Access Controls for Authorization.
But ...
Here's an answer to the common question: How to a define findOneBy(parameter1, parameter2) function for a given repository class?
First, map your entity to the appropriate repository as follow,
/*
* #ORM\Entity(repositoryClass="YourNamespace\YourBundle\Entity\yourRepository")
*/
class YourEntity
{
// ...
}
You should then add the mapped repository class and implement a findOneBy(parameter1, parameter2) method.
You can then access this class within your controller as follow,
$em= $this->getDoctrine()->getManager();
$yourEntityInstance = $em->getRepository("yourNamespaceYourBundle:YourEntity")
->findOneBy($parameter1, $parameter2);

Resources