Symfony 2.3, el libro oficial

13.4. Autorización

La autenticación siempre es el primer paso en la seguridad, ya que se trata del proceso de verificar quién es el usuario. En Symfony, la autenticación se puede hacer de varias maneras: a través de un formulario de acceso, con la autenticación básica de HTTP, e incluso a través de Twitter o Facebook.

Una vez que el usuario se ha autenticado, comienza la autorización, que proporciona un mecanismo estándar y muy potente para decidir si un usuario puede acceder a algún recurso (una URL, un objeto, una llamada a un método, ...). Su funcionamiento se basa en asignar roles específicos a cada usuario y después hacer que las diferentes partes de la aplicación requieran de diferentes roles para poder acceder.

El proceso de autorización tiene por tanto dos componentes principales:

  1. El usuario dispones de uno o más roles.
  2. Un recurso requiere de un rol específico para poder acceder a él..

En esta sección, nos centraremos en cómo utilizar diferentes roles para proteger el acceso a los recursos. Más adelante, aprenderás más sobre cómo crear y asignar roles a los usuarios.

13.4.1.  Protegiendo URL específicas mediante patrones

La forma más sencilla de proteger parte de tu aplicación consiste en utilizar expresiones regulares para proteger URL específicas. En el primer ejemplo de este capítulo ya se configuró la autorización para que cualquier URL que coincida con la expresión regular ^/admin requiera el rol ROLE_ADMIN.

Advertencia Entender cómo trabaja access_control exactamente es muy importante para garantizar que tu aplicación esté completamente asegurada. Más adelante se explica su funcionamiento con detalle.

Puedes definir tantos patrones de URL como necesites, cada uno de ellos con una expresión regular:

# app/config/security.yml
security:
    # ...
    access_control:
        - { path: ^/admin/users, roles: ROLE_SUPER_ADMIN }
        - { path: ^/admin, roles: ROLE_ADMIN }
<!-- app/config/security.xml -->
<config>
    <!-- ... -->
    <rule path="^/admin/users" role="ROLE_SUPER_ADMIN" />
    <rule path="^/admin" role="ROLE_ADMIN" />
</config>
// app/config/security.php
$container->loadFromExtension('security', array(
    // ...
    'access_control' => array(
        array('path' => '^/admin/users', 'role' => 'ROLE_SUPER_ADMIN'),
        array('path' => '^/admin', 'role' => 'ROLE_ADMIN'),
    ),
));

Truco Al prefijar la ruta con ^ te aseguras que sólo coinciden las URL que comienzan con ese patrón. Si utilizaras por ejemplo la ruta /admin (sin el ^ por delante) se aplicaría tanto a rutas del tipo /admin/foo como a rutas de este otro tipo: /foo/admin.

13.4.2. Entendiendo cómo trabaja access_control

Symfony2 comprueba para cada petición entrante si existe algún access_control cuya expresión regular coincida con la URL de la petición. En cuanto encuentra un access_control que coincide, detiene la búsqueda, ya que siempre se utiliza el primer access_control coincidente (así que el orden en el que configures los diferentes access_control es muy importante).

Cada access_control tiene varias opciones de configuración que se encargan de dos tareas:

  • Comprobar si la petición coincide con los datos del access_control
  • En caso de que coincida, qué tipo de control de acceso debe realizarse.

Para comprobar si la petición coincide con el access_control, Symfony2 crea una instancia de la clase RequestMatcher por cada uno de los access_control. Esta clase determina si este access_control debe aplicarse a la petición. Para realizar esta comprobación, se utilizan las siguientes opciones:

  • path
  • ip o ips
  • host
  • methods

Considera las siguientes entradas de access_control como ejemplo:

# app/config/security.yml
security:
    # ...
    access_control:
        - { path: ^/admin, roles: ROLE_USER_IP, ip: 127.0.0.1 }
        - { path: ^/admin, roles: ROLE_USER_HOST, host: symfony.com }
        - { path: ^/admin, roles: ROLE_USER_METHOD, methods: [POST, PUT] }
        - { path: ^/admin, roles: ROLE_USER }
<access-control>
    <rule path="^/admin" role="ROLE_USER_IP" ip="127.0.0.1" />
    <rule path="^/admin" role="ROLE_USER_HOST" host="symfony.com" />
    <rule path="^/admin" role="ROLE_USER_METHOD" method="POST, PUT" />
    <rule path="^/admin" role="ROLE_USER" />
</access-control>
'access_control' => array(
    array('path' => '^/admin', 'role' => 'ROLE_USER_IP',     'ip' => '127.0.0.1'),
    array('path' => '^/admin', 'role' => 'ROLE_USER_HOST',   'host' => 'symfony.com'),
    array('path' => '^/admin', 'role' => 'ROLE_USER_METHOD', 'method' => 'POST, PUT'),
    array('path' => '^/admin', 'role' => 'ROLE_USER'),
),

Para cada petición entrante, Symfony decide qué access_control utilizar basándose en la URI solicitada, la dirección IP del cliente, el nombre del servidor o host, y el método de la petición. Si para una entrada no se especifican las opciones ip, host o method, ese access_control se aplicará a cualquier dirección IP, host y método. Por último, recuerda que el orden es muy importante porque Symfony siempre utiliza la primera coincidencia:

Ejemplo #1:

  • Datos de la petición entrante:
    • URI: /admin/user
    • IP: 127.0.0.1
    • Host: example.com
    • Method: GET
  • access_control utilizado por Symfony:
    • Regla #1 (ROLE_USER_IP)
  • ¿Por qué?:
    • Porque la URI coincide con path y la IP con ip.

Ejemplo #2:

  • Datos de la petición entrante:
    • URI: /admin/user
    • IP: 127.0.0.1
    • Host: symfony.com
    • Method: GET
  • access_control utilizado por Symfony:
    • Regla #1 (ROLE_USER_IP)
  • ¿Por qué?:
    • Porque también coinciden los valores de path e ip. También se produce una coincidencia con la entrada del rol ROLE_USER_HOST, pero Symfony siempre utiliza la primera regla que coincida.

Ejemplo #3:

  • Datos de la petición entrante:
    • URI: /admin/user
    • IP: 168.0.0.1
    • Host: symfony.com
    • Method: GET
  • access_control utilizado por Symfony:
    • Regla #2 (ROLE_USER_HOST)
  • ¿Por qué?:
    • La dirección IP no concuerda con la de la primera regla, por tanto se utiliza la segunda regla (cuya configuración sí que coincide con la petición).

Ejemplo #4:

  • Datos de la petición entrante:
    • URI: /admin/user
    • IP: 168.0.0.1
    • Host: symfony.com
    • Method: POST
  • access_control utilizado por Symfony:
    • Regla #2 (ROLE_USER_HOST)
  • ¿Por qué?:
    • Los datos de la segunda regla siguen siendo válidos para esta petición. La tercera regla también se cumple, pero Symfony siempre utiliza dirección IP no concuerda con la de la primera regla, por tanto se utiliza la primera regla que coincida.

Ejemplo #5:

  • Datos de la petición entrante:
    • URI: /admin/user
    • IP: 168.0.0.1
    • Host: example.com
    • Method: POST
  • access_control utilizado por Symfony:
    • Regla #3 (ROLE_USER_METHOD)
  • ¿Por qué?:
    • Los datos de la petición no coinciden ni con la primera ni con la segunda regla. La tercera regla sí que coincide y por eso es la que se utiliza.

Ejemplo #6:

  • Datos de la petición entrante:
    • URI: /admin/user
    • IP: 168.0.0.1
    • Host: example.com
    • Method: GET
  • access_control utilizado por Symfony:
    • Regla #4 (ROLE_USER)
  • ¿Por qué?:
    • Los valores de ip, host y method impiden que alguna de las tres primeras reglas coincida.

Ejemplo #7:

  • Datos de la petición entrante:
    • URI: /foo
    • IP: 127.0.0.1
    • Host: symfony.com
    • Method: POST
  • access_control utilizado por Symfony:
    • Ninguna.
  • ¿Por qué?:
    • La URI solicitada no coincide con la expresión regular de ninguna regla.

Una vez que Symfony2 ha decidido que entrada access_control concuerda con la petición (si es que hay alguna), entonces aplica las restricciones de acceso basándose en las opciones roles y requires_channel:

  • role: si el usuario no tiene del rol indicado, se le deniega el acceso (internamente se lanza la excepción AccessDeniedException).
  • requires_channel: si el canal de la petición (por ejemplo http) no coincide con el valor de esta opción (por ejemplo https), el usuario será redirigido (en este caso se le redirige de http a https).

Truco Si se deniega el acceso al usuario, el sistema de seguridad intentará autenticar al usuario si aún no lo ha hecho (redirigiéndole por ejemplo a la página del formulario de login). Si el usuario ya había iniciado sesión, se le muestra la página del error 403 (Access denied).

13.4.3. Protegiendo por IP

En algunas situaciones puede ser útil restringir el acceso a una determinada ruta basándose en la IP desde la que se origina la petición. Esto es especialmente importante si utilizas ESI en tu aplicación (que es algo que está relacionado con la caché HTTP y se explicará más adelante en otro capítulo).

Cuándo ESI está habilitado, es recomendable proteger el acceso a las URL de ESI. De hecho, algunos fragmentos ESI pueden contener información tan sensible como todos los datos del usuario actualmente conectado en la aplicación. Para impedir cualquier posibilidad de acceder a estos fragmentos, la ruta ESI debe asegurarse de forma que solamente sea visible desde algún servidor privado.

A partir de la versión 2.3 de Symfony, puedes indicar más de una dirección IP en una misma regla utilizando la notación ips: [a, b]. En las versiones anteriores, es necesario crear una regla por cada dirección IP utilizando la opción ip en vez de ips.

Advertencia La opción ip no restringe el acceso a una única dirección IP. En realidad, cuando una regla del access_control utiliza la clave ip, significa que que esa regla solamente se aplica a esa dirección y al resto de usuarios que acceden desde otras direcciones IP se les aplicarán las otras reglas definidas por access_control.

El siguiente ejemplo muestra cómo proteger todas las rutas ESI que empiezan con un determinado prefijo (/esi) para que no se accedan desde el exterior:

# app/config/security.yml
security:
    # ...
    access_control:
        - { path: ^/esi, roles: IS_AUTHENTICATED_ANONYMOUSLY, ips: [127.0.0.1, ::1] }
        - { path: ^/esi, roles: ROLE_NO_ACCESS }
<access-control>
    <rule path="^/esi" role="IS_AUTHENTICATED_ANONYMOUSLY"
        ips="127.0.0.1, ::1" />
    <rule path="^/esi" role="ROLE_NO_ACCESS" />
</access-control>
'access_control' => array(
    array('path' => '^/esi', 'role' => 'IS_AUTHENTICATED_ANONYMOUSLY', 'ips' => '127.0.0.1, ::1'),
    array('path' => '^/esi', 'role' => 'ROLE_NO_ACCESS'),
),

Ahora, cuando alguien trate de acceder por ejemplo a /esi/algo desde la IP 10.0.0.1:

  • La primera regla del control de acceso se ignora porque path concuerda pero la ip no coincide con ninguna de las dos direcciones configuradas.
  • La segunda regla del control de acceso se activa porque la URL de la petición coincide con la expresión regular de su opción path. El usuario verá denegado su acceso porque la ruta requiere que tenga el rol ROLE_NO_ACCESS, pero este rol no existe en la aplicación (en eso consiste este truco, en exigir un rol que no existe).

Ahora, si la misma petición proviene de la IP 127.0.0.1 o ::1 (que es su equivalente en IPv6):

  • Se activa la primera regla del control de acceso porque tanto path como ip concuerdan: se permite el acceso al usuario porque todos los usuarios disponen del rol IS_AUTHENTICATED_ANONYMOUSLY (Symfony lo aplica automáticamente a todos los usuarios).
  • La segunda regla de acceso ni siquiera se tiene en cuenta porque la primera regla ha producido una coincidencia.

13.4.4. Protegiendo por canal

También puedes requerir que un usuario acceda a una URL vía SSL. Para ello, añade la opción requires_channel en cualquier entrada access_control. Si la petición del usuario cumple con el patrón asociado a la regla access_control y utiliza el canal http, se redirigirá al usuario a la misma URL pero utilizando el canal https:

# app/config/security.yml
security:
    # ...
    access_control:
        - { path: ^/cart/checkout, roles: IS_AUTHENTICATED_ANONYMOUSLY, requires_channel: https }
<access-control>
    <rule path="^/cart/checkout" role="IS_AUTHENTICATED_ANONYMOUSLY"
        requires_channel="https" />
</access-control>
'access_control' => array(
    array(
        'path' => '^/cart/checkout',
        'role' => 'IS_AUTHENTICATED_ANONYMOUSLY',
        'requires_channel' => 'https'
    ),
),