En este capítulo nos dispondremos a crear un ejemplo básico de seguridad que nos permita hacer el “login” de los usuarios y a su vez bloquear el acceso a determinados usuarios según su rol, para ello tendremos que adentrarnos en como funciona la librería de seguridad de Symfony2, luego crearemos las estructuras necesarias para definir un mini-backend donde crearemos unos CRUD’s para usuarios y roles utilizando Doctrine como proveedor de usuarios.

Autenticación vs. Autorización

Representan los 2 conceptos más fundamentales de seguridad en Symfony, el primero se encarga de verificar si el usuario en cuestión está Autenticado (logeado) y se le conoce como “Firewall”, el segundo verifica si el usuario tiene los permisos o “roles” necesarios y se le conoce como “access_control”.

El primer paso es verificar si el usuario está o no autenticado, en tal caso lo deja pasar y el segundo paso es verificar si el usuario tiene el rol necesario para dicha acción, para comprenderlo mejor veamos un ejemplo básico del archivo de configuración:

# proyecto/app/config/security.yml
security:
    firewalls:
        secured_area:
            pattern:    ^/
            anonymous: ~
            http_basic:
                realm: "Secured Demo Area"

    access_control:
        - { path: ^/admin, roles: ROLE_ADMIN }

    providers:
        in_memory:
            users:
                usuario:  { password: user, roles: 'ROLE_USER' }
                admin: { password: kitten, roles: 'ROLE_ADMIN' }

    encoders:
        Symfony\Component\Security\Core\User\User: plaintext

Vemos como primer elemento definido los conjuntos de firewall y en él una “secured_area” donde:

  1. pattern: es una expresión regular para hacer empatar la URL, toda ruta que empate con ello obligará al mecanismo de firewall que verifique si el usuario está autenticado, si no lo está procederá a re-dirigirlo al formulario de autenticación (en el caso anteterior mostrar el díalogo nativo de autenticación HTTP del navegador).
  2. anonymous: ~: indica que permite usuarios anónimos, no se debe aplicar en caso de backends.
  3. http_basic: indica que utilice la autenticación HTTP.

Por su parte el Mecanismo de Autorización “access_control” actúa de forma diferente, porque aunque dependa de que el usuario esté autenticado verifica si el mismo dispone de los permisos (roles) necesarios para determinada operación:

  • – { path: ^/admin, roles: ROLE_ADMIN }indica una regla básica para autorización, donde en toda ruta que coincida al principio con /admin, el usuario debe de tener dicho rol “ROLE_ADMIN” indicado.

[tipexperto titulo = “Nota”] Puedes añadir tantos access_control como necesites.[/tipexperto]

“providers” simplemente define el proveedor de usuario, que en este caso es en memoria, y “encoders” define el codificador de la contraseña, el cual debe ser de algún tipo de HASH como SHA512, en el ejemplo se usa texto plano. (más adelante se exponen ejemplos detallados).

Configuraciones del Control de Acceso

El control de acceso no sólo se limita a controlar que el usuario cumpla con un rol determinado para un patrón de ruta determinada, permite cierta flexibilidad con el que podrás adaptarte a las necesidades de seguridad de tu aplicación.

Protegiendo por IP
Tan simple como añadir el parámetro ip, con la misma puedes obligar a que una ruta solo se pueda acceder desde dicha ip (ideal para el backend interno):

# app/config/security.yml
security:
    # ...
    access_control:
        - { path: ^/_internal, roles: IS_AUTHENTICATED_ANONYMOUSLY, ip: 127.0.0.1 }

Protegiendo por Canal
Si dispones de un certificado SSL puedes obligar a que la ruta solo esté disponible desde https, especificando requires_channel:

# app/config/security.yml
security:
    # ...
    access_control:
        - { path: ^/cart/checkout, roles: IS_AUTHENTICATED_ANONYMOUSLY, requires_channel: https}

Protegiendo un Controlador
En ocasiones necesitamos que el controlador se encargue del control de acceso, de forma que podamos flexibilizarlo según nuestro modelo de negocios, para ello podremos acceder al contexto de seguridad desde nuestros controladores:

//dentro de un controlador
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
// ...
public function helloAction($name)
{
     if (false === $this->get(’security.context’)->isGranted(’ROLE_ADMIN’)) {
          throw new AccessDeniedException();
     }
     // ...
}

También puede optar por instalar y utilizar el Bundle opcional JMSSecurityExtraBundle, el cual puede asegurar su controlador usando anotaciones:

//dentro de un controlador
use JMS\SecurityExtraBundle\Annotation\Secure;
/**
  * @Secure(roles="ROLE_ADMIN")
  */
public function helloAction($name)
{
     // ...
}

Para más información, consulte la documentación de JMSSecurityExtraBundle. Si estás usando la distribución estándar de Symfony, este paquete está disponible de forma predeterminada. Si no es así, lo puedes descargar e instalar.

Controlando el acceso desde plantilla
Incluso puedes verificar si el usuario tiene acceso desde la misma plantilla, útil para ocultar segmentos a roles específicos:
Desde Twig

{% if is_granted(’ROLE_ADMIN’) %}
     <a href="...">Delete</a>
{% endif %}

Desde PHP

<?php if ($view[’security’]->isGranted(’ROLE_ADMIN’)): ?>
    <a href="...">Delete</a>
<?php endif; ?>

Recuperando del objeto usuario
Desde un controlador, puedes acceder fácilmente a la instancia del usuario actual utilizando el Mecanismo de inyección de dependencias:

//dentro de un controlador
public function indexAction()
{
  $user = $this->get(’security.context’)->getToken()->getUser();
}

Tutorial: mini-backend de usuarios con Doctrine

Realmente el ejemplo anterior es demasiado básico como para llevarlo a una aplicación real, y una de las opciones más tentadoras es utilizar Doctrine como proveedor de los Usuarios, con el cual podamos crear Roles y Usuarios desde CRUD’s elaborados por el mismo framework y crear nuestro propio esquema de seguridad, debo resaltar que existen muchos Bundles prefabricados como el FOSUserBundle que facilitan enormemente ésta tarea, pero si quieres profundizar puedes seguir el siguiente tutorial para conocer a fondo como se hace desde 0 con Doctrine ;-).

Paso 1: Crea las entidades básicas
Antes de empezar debemos de definir las entidades básicas para ser utilizadas como proveedor de usuarios y roles en Sf2, dichas entidades User y Role deben de implementar las interfaces Symfony\Component\Security\Core\User\UserInterface y Symfony\Component\Security\Core\Role\RoleInterface respectivamente, así que añade estas 2 entidades a tu directorio “proyecto/src/MDW/BlogBundle/Entity”:

User.php:

<?php
// proyecto/src/MDW/BlogBundle/Entity/User.php
namespace MDW\BlogBundle\Entity;

use Symfony\Component\Security\Core\User\UserInterface;
use Doctrine\ORM\Mapping as ORM;
/**
  * @ORM\Entity
  * @ORM\Table(name="admin_user")
  */
class User implements UserInterface
{
    /**
     * @var integer $id
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
    * @ORM\Column(type="string", length="255")
    */
    protected $username;

    /**
     * @ORM\Column(name="password", type="string", length="255")
     */
    protected $password;

    /**
     * @ORM\Column(name="salt", type="string", length="255")
     */
    protected $salt;

    /**
     * se utilizó user_roles para no hacer conflicto al aplicar ->toArray en getRoles()
     * @ORM\ManyToMany(targetEntity="Role")
     * @ORM\JoinTable(name="user_role",
     *     joinColumns={@ORM\JoinColumn(name="user_id", referencedColumnName="id")},
     *     inverseJoinColumns={@ORM\JoinColumn(name="role_id", referencedColumnName="id")}
     * )
     */
    protected $user_roles;

    public function __construct()
    {
        $this->user_roles = new \Doctrine\Common\Collections\ArrayCollection();
    }

    /**
     * Get id
     *
     * @return integer
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Set username
     *
     * @param string $username
     */
    public function setUsername($username)
    {
        $this->username = $username;
    }

    /**
     * Get username
     *
     * @return string
     */
    public function getUsername()
    {
        return $this->username;
    }

    /**
     * Set password
     *
     * @param string $password
     */
    public function setPassword($password)
    {
        $this->password = $password;
    }

    /**
     * Get password
     *
     * @return string
     */
    public function getPassword()
    {
        return $this->password;
    }

    /**
     * Set salt
     *
     * @param string $salt
     */
    public function setSalt($salt)
    {
        $this->salt = $salt;
    }

    /**
     * Get salt
     *
     * @return string
     */
    public function getSalt()
    {
        return $this->salt;
    }

    /**
     * Add user_roles
     *
     * @param Maycol\BlogBundle\Entity\Role $userRoles
     */
    public function addRole(\Maycol\BlogBundle\Entity\Role $userRoles)
    {
        $this->user_roles[] = $userRoles;
    }

    public function setUserRoles($roles) {
        $this->user_roles = $roles;
    }

    /**
     * Get user_roles
     *
     * @return Doctrine\Common\Collections\Collection
     */
    public function getUserRoles()
    {
        return $this->user_roles;
    }

    /**
     * Get roles
     *
     * @return Doctrine\Common\Collections\Collection
     */
    public function getRoles()
    {
        return $this->user_roles->toArray(); //IMPORTANTE: el mecanismo de seguridad de Sf2 requiere ésto como un array
    }

    /**
     * Compares this user to another to determine if they are the same.
     *
     * @param UserInterface $user The user
     * @return boolean True if equal, false othwerwise.
     */
    public function equals(UserInterface $user) {
        return md5($this->getUsername()) == md5($user->getUsername());

    }

    /**
     * Erases the user credentials.
     */
    public function eraseCredentials() {

    }
}

Role.php:

<?php
// proyecto/src/MDW/BlogBundle/Entity/Role.php
namespace MDW\BlogBundle\Entity;

use Symfony\Component\Security\Core\Role\RoleInterface;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="admin_roles")
 */
class Role implements RoleInterface
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\Column(name="nombre", type="string", length="255")
     */
    protected $name;

    /**
     * Get id
     *
     * @return integer
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Set name
     *
     * @param string $name
     */
    public function setName($name)
    {
        $this->name = $name;
    }

    /**
     * Get name
     *
     * @return string
     */
    public function getName()
    {
        return $this->name;
    }

    public function getRole() {
        return $this->getName();
    }

    public function __toString() {
        return $this->getRole();
    }
}

Una vez creadas nuestras entidades, accedemos a la consola de Symfony2 y generamos las tablas en Base de Datos:

$ app/console doctrine:schema:update --force

Paso 2: Generando los CRUD’s

Una vez creadas las entidades en DB, procedemos a crear los CRUD’s desde la consola de symfony:

$ app/console doctrine:generate:crud

Seguimos los pasos colocando MDWBlogBundle:Role, luego nos solicita si deseamos crear las opciones de escritura, le decimos “y” (sí), formato del CRUD: annotation, y finalmente en el Routes prefix colocamos /admin/role, este paso es importante porque a la ruta le asignamos el prefijo /admin para que nos permita empatar luego con el access_control, confirmamos y aparecerá el mensaje “You can now start using the generated code!”

Procedemos a aplicar lo mismo pero en este caso con MDWBlogBundle:User y en Routes prefix colocamos /admin/user

Ahora añadiremos las rutas a nuestro archivo de rutas (proyecto/src/MDW/BlogBundle/Resources/Config/routing.yml), porque al crearlas como Anotaciones las mismas no se añaden automáticamente:

#proyecto/src/MDW/BlogBundle/Resources/Config/routing.yml
# final del archivo:
MDWAnnotations:
    resource: "@MDWBlogBundle/Controller/"
    prefix:   /
    type:     annotation

De ésta forma añadiremos todas las rutas definidas por anotaciones del directorio Controller, ésta técnica forma parte del SensioFrameworkExtraBundle y nos permite definir las rutas directamente en nuestros controladores. si utilizas la Versión estándar de Symfony2 este Bundle viene por defecto.

Ya con esto podemos acceder a nuestros crud’s desde localhost/proyecto/web/app_dev.php/admin/user, pero aún debemos modificar ciertos aspectos en el controlador User para codificar el hash de contraseña.

Primero añadiremos la siguiente función en el controlador de usuarios:

// proyecto/src/MDW/BlogBundle/Controller/UserController.php
// añadimos esta función
private function setSecurePassword(&$entity) {
	$entity->setSalt(md5(time()));
	$encoder = new \Symfony\Component\Security\Core\Encoder\MessageDigestPasswordEncoder('sha512', true, 10);
	$password = $encoder->encodePassword($entity->getPassword(), $entity->getSalt());
	$entity->setPassword($password);
}

Luego modificamos las funciones de las acciones correspondientes a create y update, añadiendo la llamada al la función anterior para establecer el hash de la contraseña con el algoritmo SHA512:

// proyecto/src/MDW/BlogBundle/Controller/UserController.php

//funcion createAction:
    public function createAction()
    {
        $entity  = new User();
        $request = $this->getRequest();
        $form    = $this->createForm(new UserType(), $entity);
        $form->bindRequest($request);

        if ($form->isValid()) {
            //establecemos la contraseña: --------------------------
            $this->setSecurePassword($entity);

            $em = $this->getDoctrine()->getEntityManager();
            $em->persist($entity);
            $em->flush();

            return $this->redirect($this->generateUrl('admin_user_show', array('id' => $entity->getId())));

        }

        return array(
            'entity' => $entity,
            'form'   => $form->createView()
        );
    }
//...
//funcion updateAction:
    public function updateAction($id)
    {
        $em = $this->getDoctrine()->getEntityManager();

        $entity = $em->getRepository('MDWBlogBundle:User')->find($id);

        if (!$entity) {
            throw $this->createNotFoundException('Unable to find User entity.');
        }

        $editForm   = $this->createForm(new UserType(), $entity);
        $deleteForm = $this->createDeleteForm($id);

        $request = $this->getRequest();

        //obtiene la contraseña actual -----------------------
        $current_pass = $entity->getPassword();

        $editForm->bindRequest($request);

        if ($editForm->isValid()) {
            //evalua si la contraseña fue modificada: ------------------------
            if ($current_pass != $entity->getPassword()) {
                $this->setSecurePassword($entity);
            }
            $em->persist($entity);
            $em->flush();

            return $this->redirect($this->generateUrl('admin_user_edit', array('id' => $id)));
        }

        return array(
            'entity'      => $entity,
            'edit_form'   => $editForm->createView(),
            'delete_form' => $deleteForm->createView(),
        );
    }

Por último sólo nos queda eliminar del formulario (src/MDW/BlogBundle/Form/UserType.php) el campo salt el cual no debe ser modificado por el usuario:

<?php
// proyecto/src/MDW/BlogBundle/Form/UserType.php
namespace MDW\BlogBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;

class UserType extends AbstractType
{
    public function buildForm(FormBuilder $builder, array $options)
    {
        $builder
            ->add('username')
            ->add('password')
            //->add('salt') //No necesitamos que salt sea mostrado ---------------
            ->add('user_roles')
        ;
    }

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

Ahora puedes proceder a registrar usuarios y roles, es muy importante que al menos crees los roles “ROLE_ADMIN” y “ROLE_USER” y dos usuarios (uno con un rol diferente) antes de que procedas en aplicar el esquema de seguridad, de lo contrario no tendrás usuario con que loguearte ;-).

Paso 3: Creando el esquema de seguridad
Ahora procedemos a sobreescribir nuestro esquema de seguridad (proyecto/app/config/security.yml), recomiendo que antes de hacerlo guardes una copia del security.yml.

# proyecto/app/config/security.yml
security:
    encoders:
        MDW\BlogBundle\Entity\User:
            algorithm: sha512
            encode-as-base64: true
            iterations: 10

    providers:
        user_db:
            entity: { class: MDW\BlogBundle\Entity\User, property: username }

    firewalls:
        dev:
            pattern:  ^/(_(profiler|wdt)|css|images|js)/
            security: false

        login:
            pattern:  ^/admin/login$
            security: false

        secured_area:
            pattern:    ^/admin/
#            http_basic:
#                realm: "Introduzca Usuario y Contraseña"
            form_login:
                login_path: /admin/login
                check_path: /admin/login_check
            logout:
                path: /admin/logout
                target: /

    access_control:
      - { path: ^/admin, roles: ROLE_ADMIN }

Como puedes apreciar en “encoders” se ha definido un codificador especifico para la entidad User, utilizando el algoritmo SHA512, además codificandolo en Base64 con 10 iteracciones, tal cual se apreció en la función setSecurePassword del controlador.

En “providers” se estableció nuestra entidad User de Doctrine, especificando el campo correspondiente al username, es cual es el mismo username en nuestra entidad.

En “firewalls” se ha añadido la nueva regla (o firewall) login desde la cual se aplica el parametro security: false lo que permite acceder a la misma sin autenticarse, de lo contrario el formulario de login nunca lo podremos visializar.

Además en “secured_area” se ha eliminado anonymous, se ha establecido “form_login” donde definimos la ruta para el login del sistema, y se definió una ruta personalizada para el “log_out“, donde en “target” podemos definir el path hacia donde redirigir cuando los usuarios cierren sesión.

Para culminar sólo necesitamos crear el controlador y vista para nuestro login, por lo que debes de crear el archivo SecurityController.php en el directorio (proyecto/src/MDW/BlogBundle/Controller):

SecurityController.php

<?php
// proyecto/src/MDW/BlogBundle/Controller/SecurityController.php
namespace MDW\BlogBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Component\Security\Core\SecurityContext;

/**
 * Security controller.
 *
 * @Route("/admin")
 */
class SecurityController extends Controller
{
    /**
     * Definimos las rutas para el login:
     * @Route("/login", name="login")
     * @Route("/login_check", name="login_check")
     */
    public function loginAction()
    {
        $request = $this->getRequest();
        $session = $request->getSession();
        // obtiene el error de inicio de sesión si lo hay
        if ($request->attributes->has(SecurityContext::AUTHENTICATION_ERROR)) {
            $error = $request->attributes->get(SecurityContext::AUTHENTICATION_ERROR);
        } else {
            $error = $session->get(SecurityContext::AUTHENTICATION_ERROR);
        }
        return $this->render('MDWBlogBundle:Security:login.html.twig', array(
            // el último nombre de usuario ingresado por el usuario
            'last_username' => $session->get(SecurityContext::LAST_USERNAME),
            'error'         => $error,
        ));
    }
}
?>

Ahora crea el directorio “Security” dentro de (proyecto/src/MDW/BlogBundle/Resources/views) y procede a crear el archivo de vista:

login.html.twig

{# proyecto/src/MDW/BlogBundle/Resources/views/Security/login.html.twig #}
{% if error %}
<div>{{ error.message }}</div>

{% endif %}
<form action="{{ path('login_check') }}" method="post"><label for="username">Username:</label>
 <input id="username" type="text" name="_username" value="{{ last_username }}" />
 <label for="password">Password:</label>
 <input id="password" type="password" name="_password" />

 <input type="submit" name="login" />
</form>

Y con ello ya puedes intentar acceder a localhost/proyecto/web/admin/user y probar el sistema de seguridad de Symfony2 (vaciar la caché en el caso de entrar al entorno de producción), si creaste previamente 2 usuarios, intenta acceder con el usuario que no tiene el rol “ROLE_ADMIN” y verás como te niega el acceso, en cambio si pruebas con un usuario con dicho rol, puedes entrar perfectamente.

Resumen Final

En esta ocasión apreciamos el complejo sistema de seguridad de Symfony2, en donde un firewall verifica si el usuario está o no logueado, y un access control vigila que dicho usuario no pueda acceder a contenido del cual no se le ha dado acceso, también conocimos que se pueden definir providers diferentes para contener a nuestros usuarios y encoders para personalizar el HASH de la contraseña.

Además interactuamos con dicho sistema a través de un “rápido” tutorial que nos permitió resolver las inquietudes más directas en cuanto a creación de un básico RBAC (Role-based Access Control), reitero que no es la única forma de hacerlo y que existen muchos Bundles Prefabricados como el FOSUserBundle que nos facilita enormemente ésta tarea, pero si no se conoce debidamente la base puede resultar una verdadera caja negra el usar un Bundle sin el previo conocimiento de como Symfony2 implementa tales mecanismos.