Cambiar idioma/locale de la aplicación según las preferencias del usuario

Preferencias de idioma

Este post es la segunda parte del anterior que escribí relacionado con la internacionalización de nuestra aplicacion web y tratará de como traducir nuestra aplicación según el idioma que el usuario haya seleccionado en su configuración personal.

Para ello asumiremos que tenemos una entidad UserPreferences relacionada con nuestra entidad User con una relacion OneToOne, y otra entidad Language relacionada con UserPreferences con una relación ManyToOne, donde guardaremos los posibles “locales” de la aplicación, de la siguiente forma:

User:

/**
 * @ORM\OneToOne(targetEntity="efor\AppBundle\Entity\UserPreference", inversedBy="user", cascade={"persist"})
 */
protected $userPreference;

UserPreference:

/**
 * @ORM\Id
 * @ORM\Column(type="integer", nullable=false)
 * @ORM\GeneratedValue()
 */
private $id;

/**
 * @ORM\ManyToOne(targetEntity="efor\AppBundle\Entity\Language", inversedBy="userPreference", fetch="EAGER")
 */
private $language;

/**
 * @ORM\OneToOne(targetEntity="efor\UsuarioBundle\Entity\User", mappedBy="userPreference")
 */
private $user;

Language:

/**
 * @ORM\Id
 * @ORM\Column(type="integer", nullable=false)
 * @ORM\GeneratedValue()
 */
private $id;

/**
 * @ORM\Column(name="idioma_nombre", type="string", length=50, nullable=false)
 */
private $name;

/**
 * @ORM\Column(name="idioma_abreviatura", type="string", length=4, nullable=true)
 */
private $slug;

/**
 * @ORM\Column(name="idioma_codigo", type="integer", nullable=true)
 */
private $code;

/**
 * @ORM\Column(name="idioma_activo", type="boolean", nullable=false)
 */
private $active = true;

/**
 * @ORM\Column(name="idioma_borrado", type="boolean", nullable=false)
 */
private $deleted = false;

/**
 * @ORM\OneToMany(targetEntity="efor\AppBundle\Entity\UserPreference", mappedBy="language")
 */
private $userPreference;

Todas estas entidades van con sus getters/setters correspondientes.

Montamos el formulario para mostrar los idiomas disponibles que en nuestro caso los tendremos guardados mediante fixtures o migración de datos en la entidad Language. Quedaría así:

class UserPreference extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('language', EntityType::class,array(
                'label' => 'Idioma'
            ));
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'efor\AppBundle\Entity\UserPreference'
        ));
    }

    public function getName()
    {
        return 'usuario_bundle_user_preference';
    }
}

Y este formulario lo añadiremos a nuestro formulario de configuración del perfil del usuario, tal que así:

class ProfileType extends AbstractType
{
    /** @var ObjectManager $manager */
    private $manager;

    /** @var Language $lang */
    private $lang;

    public function __construct(ObjectManager $manager, Language $lang)
    {
        $this->manager = $manager;
        $this->lang = $lang;
    }

    /**
     * @param FormBuilderInterface $builder
     * @param array                $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name', TextType::class, array('label' => 'name'))
            ->add('surnameFirst', TextType::class, array('label' => 'first_name'))
            ->add('surnameSecond', TextType::class, array('label' => 'last_name'))
            ->add('email', EmailType::class, array('label' => 'Email'))
            ->add('plainPassword', 'password', array(
                'label' => 'Nuevo Password',
                'required' => false,
            ))
            ->add('salt', HiddenType::class)
            ->add('photo', new UploadFileType(), array(
                'label' => 'Foto',
                'required' => false,
            ))
            ->add('city', TextType::class, array(
                'label' => 'city',
            ))
            ->add('postalCode', TextType::class, array(
                'label' => 'postal_code',
            ))
            ->add('country', TextType::class, array(
                'label' => 'country',
            ))
            ->add('birthdate', DateType::class, array(
                'label' => 'birthdate',
                'widget' => 'single_text',
                'format' => 'dd/MM/yyyy',
                'attr' => array(
                    'class' => 'form-control input-inline datepicker',
                    'data-provide' => 'datepicker',
                    'data-date-format' => 'dd/mm/yyyy',
                    ),
                )
            )
->add('language', EntityType::class, array(
                'class' => 'AppBundle:Language',
                'data' => $this->lang,
                'label' => 'Idioma',
                'mapped' => false,
                'required' => true,
                'multiple' => false,
                'expanded' => true
            ))
        ;
    }

    /**
     * @param OptionsResolver $resolver
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'efor\UsuarioBundle\Entity\User'
        ));
    }

    /**
     * @return string
     */
    public function getName()
    {
        return 'efor_usuariobundle_User';
    }
}

En nuestro caso, este formulario recibe un objeto Language que se encargara de hidratar el campo “no mapeado” de los idiomas que lo pasaremos desde nuestro Controller al formulario de la siguiente forma:

public function editProfileAction(Request $request)
{
    /** @var User $user */
    $user = $this->getUser();
    $em = $this->getDoctrine()->getManager();
    $langUser = $user->getUserPreference()->getLanguage();
    $editProfileForm = $this->createForm(new ProfileType($em, $langUser), $user, array(
        'action' => $this->generateUrl('profile_edit'),
        'method' => 'POST',
    ));

    $editProfileForm
        ->add('update', 'submit', array(
            'attr' => array(
                'class' => 'boton pull-right'
            ),
            'label' => 'update'
        ));

    $editProfileForm->handleRequest($request);
    if ($editProfileForm->isValid()) {
        try {
            // Si el usuario no ha cambiado el password, su valor es null después
            // de hacer el ->bindRequest(), por lo que hay que recuperar el valor original
            if (null == $user->getPlainPassword()) {
                $user->setPassword($passwordOriginalEncripted);
            }

            /** @var Language $language */
            $language = $editProfileForm->get('language')->getData();
            $userPreference = new UserPreference();
            $userPreference->setLanguage($language);
            $userPreference->setUser($user);
            $user->setUserPreference($userPreference);

            $userManager = $this->getUserManager();
            $userManager->updateUser($user);

            $this->addFlashMessage(CoreController::FLASH_TYPE_SUCCES

            return $this->redirect($this->generateUrl('profile_show', array(
                '_locale' => $language->getSlug()
            )));


        } catch (\Exception $e) {
            $editProfileForm->addError(new FormError($e->getMessage()));

            return $this->render('UsuarioBundle:Profile:editProfile.html.twig', array(
                'form' => $editProfileForm->createView(),
            ));
        }
    }

    return $this->render('UsuarioBundle:Profile:editProfile.html.twig', array(
        'form' => $editProfileForm->createView(),
        'form_errors_validation_document' => $editProfileForm->getErrorsAsString(),
    ));
}

Para que funcione el cambio del “locale” de la aplicación, tendremos que pasarle el parámetro “_locale” en la URL.

Siguiendo la receta del libro de Symfony, tendremos que crear un LocaleListener que se encargara de atender cada petición en el evento ‘onKernelRequest’ y comprobar si existe un parámetro que sea ‘_locale’ para cambiar en la session el locale y que la aplicacion se traduzca. Para ello, creamos una carpeta EventListener dentro de nuestro bundle, y dentro creamos la clase LocaleListener, tal que asi:

<?php
namespace efor\CoreBundle\EventListener;

use Stof\DoctrineExtensionsBundle\DependencyInjection\Compiler\SecurityContextPass;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Security\Core\Authorization\AuthorizationChecker;

class LocaleListener implements EventSubscriberInterface
{
    private $defaultLocale;

    /**
     * LocaleListener constructor.
     *
     * @param string $defaultLocale
     */
    public function __construct($defaultLocale = 'es')
    {
        $this->defaultLocale = $defaultLocale;
    }

    /**
     * @param GetResponseEvent $event
     */
    public function onKernelRequest(GetResponseEvent $event)
    {
        $request = $event->getRequest();
        if (!$request->hasPreviousSession()) {
            return;
        }

        // try to see if the locale has been set as a _locale routing parameter
        if ($locale = $request->attributes->get('_locale')) {
            $request->getSession()->set('_locale', $locale);
        } else {
            // if no explicit locale has been set on this request, use one from the session
            $request->setLocale($request->getSession()->get('_locale', $this->defaultLocale));
        }
    }

    public static function getSubscribedEvents()
    {
        return array(
            // must be registered after the default Locale listener
            KernelEvents::REQUEST => array(array('onKernelRequest', 150)),
        );
    }
}

Después añadimos el listener como un servicio dentro del archivo app/config/services.yml :

services:

    app.locale_listener:
        class: efor\CoreBundle\Listener\LocaleListener
        arguments: ['%kernel.default_locale%']
        tags:
            - { name: kernel.event_subscriber }

Ademas, si queremos que cuando el usuario haga login se cambie el idioma de la aplicación, según el idioma que tenga guardado en sus preferencias, debemos hacerlo en otro listener. En mi aplicación, dispongo de un listener que se ejecuta después del login satisfactorio del usuario y es allí donde busco el locale en las preferencias y lo envió.  La documentación para este listener la tenéis aquí.

<?php

namespace efor\UsuarioBundle\Redirection;

use efor\AppBundle\Entity\Role;
use efor\UsuarioBundle\Entity\User;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;

class AfterLoginRedirection implements AuthenticationSuccessHandlerInterface
{
    const DEFAULT_LOCALE = 'es';

    /** @var RouterInterface */
    private $router;

    /**
     * AfterLoginRedirection constructor.
     *
     * @param RouterInterface $router
     */
    public function __construct(RouterInterface $router)
    {
        $this->router = $router;
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token)
    {
        //Default redirect
        $response = new RedirectResponse($this->router->generate('fos_user_security_login'));

        if ($token->isAuthenticated()) {
                //Go home with user locale preferences
                /** @var User $user */
                $user = $token->getUser();
                $userPreference = $user->getUserPreference();
                $lang = $userPreference ? $userPreference->getLanguage()->getSlug() : self::DEFAULT_LOCALE;

                $response = new RedirectResponse($this->router->generate('dashboard_homepage', array(
                    '_locale' => $lang
                )));
        }
        // Go login
        return $response;
    }
}

Y tenemos este listener definido como servicio dentro de nuestro bundle, tal que así:

<service id="redirect.after.login" class="efor\UsuarioBundle\Redirection\AfterLoginRedirection">
    <argument type="service" id="router" />
</service>

En resumen, en mi caso solo cambiando el locale en la session, request y translator, no me funcionaba, por lo que he tenido que recurrir a la alternativa de pasar el locale por GET en la url y que el listener fuera el que hiciera el trabajo sucio.

No se si sera mejor o peor forma de hacerlo, el caso es que funciona y muy bien.

Si alguien tiene dudas, o quiere comentar algo, no dudéis en escribirme y lo comentamos.

Por favor, comparte si te a sido útil.

Hasta la vista SymfonyDevs ¡

 

Anuncios

Uso de Voters para el control del acceso de usuarios en zonas

Acceso denegado - 403
Acceso denegado – 403

Los voters son un elemento de Symfony para darle una vuelta de tuerca al las listas de acceso – ACL (Access Control List) de Symfony donde podemos validar con mas profundidad si un recurso esta disponible para un usuario.

Aqui la documentacion oficial.

A continuacion explicare un ejemplo mas concreto donde lo he usado.

La problematica era la siguiente:

-Un usuario puede realizar ciertas acciones en las zonas, segun el tipo de rol y los permisos definidos para cada rol y zona. Es decir que para mi zona de “Gestion de Usuarios” existen las opciones de listar, crear, editar, visualizar y eliminar usuarios. Por ejemplo para un usuario con “rol admin” podra hacerlas todas, pero un usuario con “rol guest” solo podra listar y visualizar información de los usuarios.

Todo esto va gestionado a traves de relaciones entre enitdades que representan tablas en la BD. Esto lo explicare en otro post con mas detalle para que veais como he resuelto la gestion del acceso de usuarios con roles, permisos y zonas en una aplicacion con FOSUserBundle de por medio.

Empezamos:

1- He definido en el firewall que el rol minimo para poder acceder a esa ruta debe ser algo parecido a esto, de esa forma otros usuarios que no tengan ese rol “minimo” no podran acceder a esa/s ruta/s:

(security.yml)
access_control:
    - { path: ^/login$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
    - ... //seguridad de acceso en otras rutas
    - { path: ^/users, roles: ROLE_GUEST }

2 – En mi bundle he creado una carpeta llamada “Security” donde metere los Voters para revisar la seguridad de las acciones sobre las entidades.

3 – Como en la gran mayoria de mis Voters van a hacerse las mismas (o parecidas) comprobaciones respecto a si puede acceder a ese recurso o no, me he hecho un “GenericVoter” abstracto donde se incluye la logica de la seguridad, el cual herederan el resto de Voters de mis zonas:

<?php
namespace efor\AppBundle\Security;

use Doctrine\Common\Collections\Criteria;
use efor\AppBundle\Entity\Role;
use efor\AppBundle\Entity\RoleZone;
use efor\UsuarioBundle\Entity\User;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authorization\Voter\AbstractVoter;
use Symfony\Component\Security\Core\User\UserInterface;

// La clase AbstractVoter está disponible a partir de Symfony 2.6
abstract class GenericVoter extends AbstractVoter
{
    const READ = 'read';
    const CREATE = 'create';
    const EDIT = 'edit';
    const DELETE = 'delete';
    
    protected function getSupportedAttributes()
    {
        return array(self::CREATE, self::EDIT, self::DELETE, self::READ);
    }

    abstract protected function getSecureZone();

    /**
     * @param string $action
     * @param object $object
     * @param null $user
     *
     * @return bool
     */
    protected function isGranted($action, $object, $user = null)
    {
        if(!$user instanceof User){
            return false;
        }

        //Buscamos todos los roles del usuario(>= 1 role)
        $roleUser = $user->getRoleUser();

        //recorremos los posibles roles que tenga el usuario
        /** @var Role $role */
        foreach($roleUser as $role){ 
            //buscamos las zonas que tenga disponibles ese role
            $securedZones = $role->getRoleZone(); 

            /** @var RoleZone $securedZone */
            foreach($securedZones as $securedZone)
            {
                if($securedZone->getZone()->getName() === $this->getSecureZone()){
                    switch($action){
                        case self::READ:
                                return $securedZone->getAllowedToRead();
                            break;
                        case self::CREATE:
                                return $securedZone->getAllowedToAdd();
                            break;
                        case self::EDIT:
                                return $securedZone->getAllowedToEdit();
                            break;
                        case self::DELETE:
                                return $securedZone->getAllowedToDelete();
                            break;
                        default: return false;
                            break;
                    }
                }
            }
        }

        return true; //dejamos pasar siempre que se pueda
    }
}

En esta clase hay que destacar que hereda de AbstractVoter que definde una serie de metodos publicos implementados, y otros abstractos. De esa forma, asi nosotros añadimos las posibles acciones sobre un “objeto (entidad)” y una funcion abstracta getSecureZone() donde cada Voter especifico definira la “zona” donde se revisara la seguridad. Dentro de la funcion isGranted() comprobamos que esa zona este definida en alguno de los roles del usuario que la intenta acceder para ver si puede leer, crear, editar o eliminar un objeto de ese tipo.

4 – Definimos nuestro Voter especifico para comprobar la seguridad cuando se accede a los usuarios tal que así:

class UserVoter extends GenericVoter
{
    protected function getSupportedClasses()
    {
        return array('efor\UsuarioBundle\Entity\User');
    }

    protected function getSecureZone()
    {
        //en mi caso apunta a una clase donde tengo definidas 
        //mis rutas seguras - user
        return ZoneMapping::USERS_ROUTE_NAME;
    }
}

Este Voter comprobara para la entidad User (es la clase donde se podran hacer las 4 operaciones posibles – CRUD) cuando acceda a la zona ‘user’ si es posible conceder el acceso.

5 – Definimos un servicio en nuestro ‘services.yml’ que atendera para garantizar la seguridad:

services:
    ...
    user_voter:
        class: efor\AppBundle\Security\UserVoter
        public: false
        tags:
            - { name: security.voter }

6 – Ahora tenemos que usar las funciones que proporciona Symfony de chequeo del acceso para que este Voter se ejecute como responsable. Para ello en nuestro UserController deberemos añadir la/s siguiente/s comprobaciones a traves de anotaciones(cuando se pueda) o condicionales:

class UserController
 {
/**
 * @Route("/create", name="user_create")
 */
public function createUserAction(Request $request)
{
     $newUser = new User();

     if(!$this->isGranted(GenericVoter::CREATE, $newUser)){
         throw $this->createAccessDeniedException();
     }
     $form = ...
}
/**
 * @Route("/list", name="user_list")
 */
public function listUserAction()
{
    if(!$this->isGranted(GenericVoter::READ, new User())){
        throw $this->createAccessDeniedException();
    }
    ...
}

/**
* @Route("/show/{id}", name="user_show")
* @ParamConverter("user", class="efor\UsuarioBundle\Entity\User")
* @Security("is_granted('read', user)")
*/
public function showUserAction(User $user)
{
    if($user === $this->getUser()){
        return $this->redirectToRoute('profile_show');
    }

    return $this->render('UsuarioBundle:User:showUser.html.twig', array(
        'user' => $user
    ));
}

//IDEM con el resto de acciones del CRUD o Actions 
//que queramos proteger

Por una parte destacamos la anotacion @Security donde le decimos que ejecute la funcion isGranted y le pasamos la accion y un objeto(que se obtiene con el @ParamConverter previo).

De la misma forma pero sin usar anotacion, es llamando dentro de nuestro Action a $this->isGranted() pasandole la misma informacion ( a veces necesitamos instanciar un nuevo objeto para que se pueda comprobar) y lanzando una excepcion en caso negativo.

7 – Si queremos revisar la seguridad en las vistas para mostrar u ocultar botones podemos hacer uso de la funcion is_granted() en TWIG de la siguiente forma:

{% set editAction = constant('efor\\AppBundle\\Security\\GenericVoter::EDIT') %}
{% if is_granted(editAction, user) %}
    <a href={{ url('user_edit', { 'id': user.id } }}>Editar</a>
{% endif %}

De esta forma mostrar un link a la ruta de editar usuario solo si mi Voter lo permite validando los permisos sobre ese objeto.


 

Y eso es todo, podeis encontrar mas informacion sobre los Voters en el link que os deje al principio del post para que se ajuste a vuestras necesidades.

Si te ha gustado el articulo, no dudes en compartirlo en las redes sociales para que llegue a toda la comunidad Symfony Hispana. Si tienes otras formas de usarlo, o crees que harias algo distinto y que aportara valor a los demas, no dudes en dejar tu comentario al respecto, y lo revisare lo antes que pueda.

Saludos ¡

 

 

 

FOSUserBundle para la gestion de usuarios en Symfony 2

Voy a explicar paso a paso como voy a integrar el bundle mas famoso de gestion de usuarios y acceso en un proyecto Symfony en el que estoy trabajando y justamente lo necesito, asi que voy a poner paso a paso el proceso que he seguido. He seguido la guia oficial de Symfony 2 para este bundle aqui.

Empezamos ¡¡

1- Instalacion del bundle

Abrir el composer.json y añadir la siguiente linea:

“friendsofsymfony/user-bundle”: “dev-master”

2- Ejecutar el comando para instalar los vendors:

>>composer update -o

3-Activar el bundle en la aplicación, abrimos /app/AppKernel.php y añadimos:

new FOS\UserBundle\FOSUserBundle()

4- Crea clase User que herede de la clase base del FOS e añadir el atributo id y el constructor que herede la logica del padre:

use FOS\UserBundle\Model\User as BaseUser;
/**
 * @ORM\Table(name="usuario")
 * @ORM\Entity
 */
class User extends BaseUser
{
    /**
 * @ORM\Id
 * @ORM\Column(type="integer")
 * @ORM\GeneratedValue(strategy="AUTO")
 */
protected $id;

public function __construct()
{
    parent::__construct();
     //do domething
}
}

5- Configurar la seguridad en security.yml

# app/config/security.yml
security:
    encoders:
        FOS\UserBundle\Model\UserInterface: bcrypt

    role_hierarchy:
        ROLE_ADMIN:       ROLE_USER
        ROLE_SUPER_ADMIN: ROLE_ADMIN

    providers:
        fos_userbundle:
            id: fos_user.user_provider.username

    firewalls:
        main:
            pattern: ^/
            form_login:
                provider: fos_userbundle
                csrf_token_generator: security.csrf.token_manager
                # if you are using Symfony < 2.8, use the following config instead:
                # csrf_provider: form.csrf_provider

            logout:       true
            anonymous:    true

    access_control:
        - { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/admin/, role: ROLE_ADMIN }

6- Configurar el FOSUserBundle:

Yo suelo crear un fichero YML con cada configuracion para separarla del fichero config.yml y que no sea enorme en el tiempo. Para ello creo un nuevo fichero “/app/config/fos.yml” y añaimos lo siguiente y despues lo importamos en el config.yml:

# app/config/fos.yml
fos_user:
    db_driver: orm # other valid values are 'mongodb', 'couchdb' and 'propel'
    firewall_name: main
    user_class: AppBundle\Entity\User

# app/config/config.yml
imports:
    - ...
    - { resource: fos.yml }

7- Importar las rutas de FOSUserBundle a nuestro routing.yml

# app/config/routing.yml
fos_user:
    resource: "@FOSUserBundle/Resources/config/routing/all.xml"

8- Actualizar nuestro schema para añadir las nuevas columnas que usara el FOSUserBundle:

# Para ver los cambios en la base de datos
>> php bin/console doctrine:schema:update --dump-sql
# Para aplicar los cambios en la BD
>> php bin/console doctrine:schema:update --force

 Renombrando los campos que trae FOSUSerBundle por defecto

Debido a una serie de limitaciones respecto a la nomenclatura de la BD, algunos campos que ya existen coinciden con los que FOS trae por defecto como “email”, “password”, etc.

En mi caso, necesito forzar a que se coja el nombre y algun pequeño detalle mas de mi aplicacion, y que sobreescriba los del FOS.

En este caso, he tirado de Google y he descubierto que Doctrine2 trae un tipo  de anotaciones que permiten justamente esto que necesito, asi que seria algo como esto:

/**
 * @ORM\Table(name="fos_user")
 * @ORM\Entity(repositoryClass="UserBundle\Entity\Repositories\UserRepository")
 * @ORM\AttributeOverrides({
 *     @ORM\AttributeOverride(
 *          name="email",
 *          column=@ORM\Column(
 *              name        = "usuario_email",
 *              unique      = true,
 *              nullable    = false,
 *          )
 *     ),
 *     @ORM\AttributeOverride(
 *          name="password",
 *          column=@ORM\Column(
 *              name        = "usuario_password"
 *          )
 *     )
 * })
 */
class User extends BaseUser
{
    ...
}

De esta forma, cambiamos el nombre de las columnas de la tabla para que coja las nuestras y añadimos una restriccion de email unico y no nulo.

Y bueno, solo tenemos que aplicar los cambios del mapeo en la BD con el comando y listo.

>> php bin/console doctrine:schema:update --force

Si os surigio algun problema, o teneis alguna duda, no dudeis en preguntar via comentario y te contestare lo antes posible.

Saludos ¡¡