Symfony: tutorial 16: la seguridad

2019-08-03 - Categorías: PHP / Symfony

La seguridad no es una de las asignaturas difíciles en Symfony. Puede ser difícil en otras aplicaciones web hechas a medida, pero en Symfony, siguiendo unas pocas directrices, conseguiremos aplicaciones web muy seguras. Symfony nos provee de mecanismos fáciles de configurar, de métodos de programación para usar allí donde necesitemos estas capas de seguridad. Además de que hay componentes, hechos por expertos en seguridad, para nuestros proyectos.

Este es un post muy teórico, en donde dejo las pinceladas principales para luego ahondar más en cada tema. Y también he tratado de dejar un caso de uso a modo de codekata, como viene siendo costumbre en la serie de tutoriales de iniciación a Symfony. Usando en Symfony los componentes recomendados, y unas pocas directrices, conseguiremos aplicaciones web muy seguras sin ser necesariamente expertos en seguridad web.

¡Pero mucho ojo! Que la seguridad en la web, y de todo dispositivo conectado a Internet, depende de muchas más cosas, aparte de cómo esté programada la capa web.

Un poco de teoría sobre seguridad en la web

Para los no expertos en seguridad, nos es difícil averiguar cómo se pueden colar virus o hackers en nuestras webs. Para protegernos toca aprender cómo lo hacen, o por lo menos en parte. Los principales focos de problemas en las webs son:

  • La inyección SQL.
  • Los ataques cross site scripting XSS.
  • La autenticación y autorización.
  • Los problemas de rendimiento que desembocan en ataques de denegación de servicio DoS (Denial of Service).

Es decir, partimos de que tenemos una serie de elementos que pueden ser explotados. Ya sea por su configuración, su programación, o por su capacidad de rendimiento. Aparte de lo que nos toca como programadores, hay varios niveles a securizar aparte de la web en sí:

  • El Sistema Operativo puede tener vulnerabilidades conocidas.
  • Cada uno de los servicios a la escucha online puede ser explotado: servidor web, SSH, servidor de emails, DNS, bases de datos, etc.. cualquier servicio accesible remotamente, a la escucha.
  • Finalmente la web en sí, el software programado, directamente navegable, debe estar también protegido.
  • Y finalmente la parte humana de la aplicación, es lo que llaman el eslabón más débil.

El aspecto humano de la seguridad en las web es bastante incontrolable, y muchas veces es el origen de muchos ataques. Si una persona da su contraseña, o su ordenador personal no es seguro, la web puede estar ya comprometida. Como la persona atacada se trate de una persona con alto nivel de privilegios en la web, ya tendremos un agujero importante en la plataforma web.

Inyección SQL

La inyección SQL es un proceso por el cual, mediante peticiones HTTP, con SQL emebebido en dichas consultas, podemos acceder a datos sensibles de una página web. Aquí Symfony con Doctrine te ayuda mucho, pero en el momento en que se hagan búsquedas para mostrar datos, somos susceptibles de este ataque.

Si construimos consultas en DQL, o en SQL, mediante la concatenación de cadenas que el usuario escribe en la web.. ¡peligro!

El kit de la cuestión sobre esto, está en si usamos la entrada del usuario, la URL, o parámetros pasados por el navegador del usuario directamente para consultar la base de datos. Si hacemos consultas directamente con estos datos recibidos del usuario, corremos el peligro permitir la inyección SQL. Pueden llegar a construir consultas como listados de usuarios, de productos offline, ventas, etc.. Sacando información de la BD que no deberían de sacar.

Se recomienda usar Doctrine, mapeando los datos en entidades de Doctrine. Y hacer las consultas usando los llamados preparedStatement, parametrizando los datos de consulta que vienen del navegador visitante. Aquí también debemos de validar y sanitizar los datos de entrada pero Doctrine provee de algo de seguridad.

Cross Site Scripting XSS

Esta vulnerabilidad consiste en dejar que un usuario guarde Javascripts en nuestra aplicación web. Estos Javascripts luego se visualizan a muchos otros usuarios al visualizarse en el frontend. De esta manera, estos Javascripts pueden ejecutar todo tipo de tareas maliciosas en muchos navegadores de los usuarios como por ejemplo: pedir datos confidenciales haciéndose pasar por la web, captura de contraseñas, ver el comportamiento de los usuarios en tu web, y un largo etcétera.

Cómo protegerse:

  • Validar la entrada de usuarios a zonas donde se guarden datos con formularios. Es crítica la entrada de usuarios a zonas donde se configuren contenidos para cabeceras, pies de página, cualquier elemento de diseño de la web.
  • Validar la entrada de datos, puedes hacer comprobaciones de que no venga contenido con scripts, para esto puedes usar funciones como preg_match para encontrarlos de entrada.
  • Puedes limpiar los datos de entrada con funciones como strip_tags o preg_replace.
  • Finalmente al visualizar contenidos en el frontend, es muy peligroso permitir mostrarse los datos tal cual se guardaron. Symfony aquí te ayuda, pero si usas filtros como raw corres peligro de permitir HTML, CSS y Javascript peligroso en tu web.

Este es el ataque más peligroso, y habitual, en las aplicaciones web. Symfony por defecto no te protege de éste, pero sabiendo que existe podemos protegernos mejor.

Autenticación y autorización en Symfony

El sistema de cortafuegos y proveedores de usuarios

Ahora viene lo mejor de Symfony para el tema de la autenticación y autorización de usuarios. Symfony nos provee de dos cosas en este aspecto bien diferenciadas. Si quieres seguir el codekata te invito a que no te descargues todo el código fuente o a que vayas en paralelo creando tu proyecto como en los tutoriales anteriores sobre Symfony.

De todas formas el código fuente lo dejaré en:
https://github.com/jaimenj/symfony-starter-tutorials/tree/master/symfony-tutorial-16

Entonces, tendremos por un lado un sistema por el que definimos zonas de la aplicación que van a necesitar ciertos privilegios o categorización de los usuarios que los visitan. Y por otro lado proporciona unos mecanismos con los que identificamos y llevamos un seguimiento seguro de las sesiones de cada usuario.

Creando un proyecto de pruebas

De nuevo, para evitar aburrirte, vamos a tratar de ir al grano con un codekata. Vamos a crear un proyecto en dondo hagamos una sección con autenticación de usuarios. Comenzamos con los paquetes que vamos a necesitar:

symfony new symfony-tutorial-16
cd symfony-tutorial-16
composer require --dev profiler maker
composer require doctrine twig form validator annotations

Primero que todo, para la última versión de Symfony tenemos que incluir el bundle de seguridad:

composer require security

Lo siguiente es hacer una entidad de Doctrine con todos los datos de usuario que queramos. Podemos usar el comando:

php bin/console make:entity

..y modificarlo. O mejor aún usamos el maker de seguridad de la forma:

php bin/console make:user

..siguiendo las instrucciones tendremos generados los ficheros necesarios.

Generando códigos fuentes para securizar una zona de la web..

Definiendo zonas con niveles de acceso

Hasta aquí ya tenemos entonces la entidad User que va a almacenar en la base de datos los datos de los usuario. Se nos ha configurado también el fichero de securidad config/packages/security.yaml. Vamos a hacer algo sencillo, una web con la home pública, y un directorio /protected/ cuyo contenido sólo sea accesible si somos un usuario.

Lo siguiente puede ser generar un controlador simple que nos muestre estas dos zonas:

php bin/console make:controller DefaultController

Y le ponemos al controlador lo siguiente:

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;

class DefaultController extends AbstractController
{
    /**
     * @Route("/", name="home")
     */
    public function index()
    {
        return $this->render('default/home.html.twig', [
            'controller_name' => 'DefaultController',
        ]);
    }

    /**
     * @Route("/protected/", name="protected")
     */
    public function protected()
    {
        return $this->render('default/protected.html.twig', [
            'controller_name' => 'DefaultController',
        ]);
    }
}

Ahora el fichero templates/default/home.html.twig:

{% extends 'base.html.twig' %}

{% block title %}Hello DefaultController!{% endblock %}

{% block body %}
<style>
    .example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
    .example-wrapper code { background: #F5F5F5; padding: 2px 6px; }
</style>

<div class="example-wrapper">
    <h1>Hello {{ controller_name }}! ?</h1>

    <p>This is the HOME content.</p>
</div>
{% endblock %}

Ahora el fichero templates/default/protected.html.twig muy parecido:

{% extends 'base.html.twig' %}

{% block title %}Hello DefaultController!{% endblock %}

{% block body %}
<style>
    .example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
    .example-wrapper code { background: #F5F5F5; padding: 2px 6px; }
</style>

<div class="example-wrapper">
    <h1>Hello {{ controller_name }}! ?</h1>

    <p>This is the PROTECTED content.</p>
</div>
{% endblock %}

Ya con esto podremos arrancar el servidor de desarrollo local con ‘symfony server:start’ y visitar las páginas web:
http://localhost:8000/
http://localhost:8000/protected/
..si todo ha ido bien.

Añadiendo usuarios

Pero ¿dónde se guardan los usuarios? ¿Recuerdas antes que hemos creado la entidad User? Lo siguiente entonces para poder montar la autenticación de usuarios, es el poder añadir usuarios. Para esto necesitamos una interfaz, las interfaces CRUD, que significan Create Retrieve Update y Delete. De nuevo podemos volver a línea de comandos y ejecutamos el generación de códigos para crearlas así:

php bin/console make:crud User

Después de esto hay que configurar el fichero .env poniendo los datos de nuestra BD local, crearla y crear el esquema. Si todo ha ido bien entonces ya puedes accedera a: http://localhost:8000/user

Hay unos pequeños retoques que hacer porque así tal cual no va a funcionar. Primero podemos ponerlo bonito añadiendo las librerías de Bootstrap, jQuery y las fuentes asombrosas. Si editamos el fichero symfony-tutorial-16/templates/base.html.twig y lo dejamos algo así:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>{% block title %}Welcome!{% endblock %}</title>
        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css">
        {% block stylesheets %}{% endblock %}
    </head>
    <body class="container-fluid">
        {% block body %}{% endblock %}

        <script src="https://code.jquery.com/jquery-3.2.1.slim.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js"></script>
        <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.13.0/js/all.min.js"></script>
        {% block javascripts %}{% endblock %}
    </body>
</html>

Si también editamos un poco la tabla de usuarios usando Bootstrap podría quedar algo así:

{% extends 'base.html.twig' %}

{% block title %}User index{% endblock %}

{% block body %}
    <h1>User index</h1>

    <table class="table table-striped">
        <thead>
            <tr>
                <th>Id</th>
                <th>Email</th>
                <th>Roles</th>
                <th>Password</th>
                <th>actions</th>
            </tr>
        </thead>
        <tbody>
        {% for user in users %}
            <tr>
                <td>{{ user.id }}</td>
                <td>{{ user.email }}</td>
                <td>{{ user.roles ? user.roles|json_encode : '' }}</td>
                <td>{{ user.password }}</td>
                <td>
                    <a href="{{ path('user_show', {'id': user.id}) }}" class="btn btn-primary">show</a>
                    <a href="{{ path('user_edit', {'id': user.id}) }}" class="btn btn-primary">edit</a>
                </td>
            </tr>
        {% else %}
            <tr>
                <td colspan="5">no records found</td>
            </tr>
        {% endfor %}
        </tbody>
    </table>

    <a href="{{ path('user_new') }}" class="btn btn-primary">Create new</a>
{% endblock %}

Lo siguiente del codekata es que hay que arreglar un par de cosas en el formulario de usuarios y en el controlador por el tema de la contraseña y grupos de usuario. Esto se puede luego mejorar, pero dejo aquí los detalles. En el formulario hay que comentar la línea de los roles:

//->add('roles')

..y en el controlador hay que codificar la contraseña para que guarde cifrada en la base de datos y funcione así:

// Usaremos el encoder
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

// Inyectamos en el controlador la interfaz de cifrado
// añadiendo este parámetro
UserPasswordEncoderInterface $encoder

// Ciframos la contraseña
$passwordEncoded = $encoder->encodePassword($user, $user->getPassword());
$user->setPassword($passwordEncoded);

Lo dejo así y todo el código fuente en el repo de Github por si quieres ver el controlador completo:
https://github.com/jaimenj/symfony-starter-tutorials/blob/master/symfony-tutorial-16/src/Controller/UserController.php

Creando usuarios para autenticación y autorización en Symfony..

Si no codificamos la contraseña cuando creamos un usuario nos quedará guardada como el primer usuario de prueba. En el segundo usuario ya está guardando cifrando las contraseñas. Esto es lo que podemos hacer con un controlador como el siguiente:

    /**
     * @Route("/new", name="user_new", methods={"GET","POST"})
     */
    public function new(Request $request, UserPasswordEncoderInterface $encoder): Response
    {
        $user = new User();
        $form = $this->createForm(UserType::class, $user);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $entityManager = $this->getDoctrine()->getManager();
            $passwordEncoded = $encoder->encodePassword($user, $user->getPassword());
            $user->setPassword($passwordEncoded);
            $entityManager->persist($user);
            $entityManager->flush();

            return $this->redirectToRoute('user_index');
        }

        return $this->render('user/new.html.twig', [
            'user' => $user,
            'form' => $form->createView(),
        ]);
    }

Ahora sí, ya podemos ir a http://localhost:8000/user y crear los usuarios que queramos para probar. Vamos ahora con la configuración por zonas.

Securizando, configurando el cortafuegos

El siguiente paso es securizar la zona /protected/, que para esto podemos ir a configurarlo en el fichero de seguridad. Para el caso de este post, podemos abrir el fichero symfony-tutorial-16/config/packages/security.yaml, y podemos hacer algo tal que así:

security:
    encoders:
        App\Entity\User:
            algorithm: auto

    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    providers:
        # used to reload user from session & other features (e.g. switch_user)
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            anonymous: lazy
            provider: app_user_provider

            # activate different ways to authenticate
            # https://symfony.com/doc/current/security.html#firewalls-authentication

            # https://symfony.com/doc/current/security/impersonating_user.html
            # switch_user: true
            
            form_login:
                # submit the login form here
                check_path:                     login
                # the user is redirected here when they need to log in
                login_path:                     login
                # if true, forward the user to the login form instead of redirecting
                use_forward:                    false
                # login success redirecting options (read further below)
                always_use_default_target_path: true
                default_target_path:            protected
                target_path_parameter:          _target_path
                use_referer:                    false
                # login failure redirecting options (read further below)
                failure_path:                   login
                #failure_forward:               false
                #failure_path_parameter:        _failure_path
                #failure_handler:               some.service.id
                #success_handler:               some.service.id

                # field names for the username and password fields
                username_parameter:             _username
                password_parameter:             _password

                # csrf token options
                #csrf_parameter:                _csrf_token
                #csrf_token_id:                 authenticate
                #csrf_token_generator:          my.csrf_token_generator.id
                csrf_token_generator:           security.csrf.token_manager

                # by default, the login form *must* be a POST, not a GET
                post_only:                      true
                remember_me:                    true

                # by default, a session must exist before submitting an authentication request
                # if false, then Request::hasPreviousSession is not called during authentication
                require_previous_session:       true
            logout:
                path:                           logout
                target:                         default

    # Easy way to control access for large sections of your site
    # Note: Only the *first* access control that matches will be used
    access_control:
        # - { path: ^/admin, roles: ROLE_ADMIN }
        - { path: ^/protected/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/protected, roles: ROLE_USER }

Haciendo login y el controlador

Ya con esto nos va a pedir autenticarnos, pero nos falta el login y el controlador que va a implementar dicho login. Aquí el controlador:

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;

/**
 * @Route("/protected")
 */
class MySecurityController extends AbstractController
{
    private $tokenManager;

    public function __construct(CsrfTokenManagerInterface $tokenManager = null)
    {
        $this->tokenManager = $tokenManager;
    }

    /**
     * @Route("/login", name="login")
     *
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function login(Request $request, AuthenticationUtils $authenticationUtils)
    {
        // get the login error if there is one
        $error = $authenticationUtils->getLastAuthenticationError();

        // last username entered by the user
        $lastUsername = $authenticationUtils->getLastUsername();

        return $this->render('login.html.twig', [
            'last_username' => $lastUsername,
            'error' => $error,
        ]);
    }

    /**
     * @Route("/logout", name="logout")
     */
    public function logout()
    {
        throw new \RuntimeException('You must activate the logout in your security firewall configuration.');
    }
}

Y aquí el login en Twig:

<!DOCTYPE html>
<html lang="es">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css">
    </head>
    <body>
        {% if error %}
            <div class="alert alert-primary">{{ error.message }}</div>
        {% endif %}

        <div class="container p-3 text-center rounded">
            <i class="fas fa-key" style="font-size: 1500%; margin-top: 50px;"></i>

            <form action="{{ path('login') }}" method="post" role="form" class="form mt-5">
                <div class="form-group">
                    <label for="username">Usuario</label>
                    <input type="text" id="username" name="_username" value="{{ last_username }}" class="form-control" autofocus="autofocus"/>
                </div>

                <div class="form-group">
                    <label for="password">Contraseña</label>
                    <input type="password" id="password" name="_password" class="form-control"/>
                </div>

                <input type="checkbox" id="remember_me" name="_remember_me" checked />
                <label for="remember_me">Recuérdame</label>

                <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
                {#
                    If you want to control the URL the user
                    is redirected to on success (more details below)
                    <input type="hidden" name="_target_path" value="/account" />
                    #}

                <button type="submit" class="btn btn-primary form-control">Entrar</button>
            </form>
        </div>

        <script src="https://code.jquery.com/jquery-3.2.1.slim.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js"></script>
        <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.13.0/js/all.min.js"></script>
    </body>
</html>

Si todo está bien enlazado, se tiene que ver una pantalla como la siguiente cuando vayamos a http://localhost:8000/protected/

Un login en Symfony..

Es muy curioso que ya Symfony te genera un sistema con el que incluye tokens de seguridad, de un sólo uso, para cada formulario. Un sistema de roles con los que categorizar y dar seguridad a los usuarios. En fin, mucho trabajo ya disponible que nos ahorramos.

Detectando usuarios en plantillas Twig

Por ejemplo si queremos controlar en plantilla el acceso con cierto rol podemos hacer:

        {% if is_granted('ROLE_SUPER_ADMIN') %}
        <li class="nav-item" class="nav-item">
            <a href="{{ path('admin_list') }}">
            <i class="fas fa-users"></i> Administradores</a>
        </li>
        {% endif %}

..este código hará que sólo a los super-administradores se les muestre el enlace en navegación. Pero qué pasa si ya saben el link, entonces podemos comprobar esto en el controlador lanzando excepciones como la siguiente:

    $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN');

Dejo esto aquí, pero decir que hay para la seguridad hay mucho trabajo de programación. Seguir una estructura bien definida en Symfony te ayudará mucho. Muchas situaciones inseguras estarán contempladas por el bundle de seguridad de Symfony. Pero recapitulando, es importante ser conscientes de que aunque mucho nos ayuda Symfony, mucho queda en manos del programador..

Problemas en el rendimiento, ataques de denegación de servicio

En sí mismo no es un problema de seguridad, aunque seguramente la web se quede sin servicio, no debe de desembocar en fuga de datos, o corrupción de estos. El problema final a la seguridad de la web con este ataque DoS es que la web se nos puede quedar sin dar servicio, con las implicaciones que esto tenga.

Para protegernos de esto en nuestras webs podemos hacer:

  • Instalar el profiler de Symfony y revisar con lupa el proyecto.
  • Contruir despacio y con buena letra, refactorizando siempre que podamos, con el profiler de Symfony en mente, revisando número de consultas a la base de datos, tiempos de ejecución de plantillas, etc..
  • Aplicar test de extrés, o pruebas de carga, a la web. Es decir, llenar de usuarios ficticios la web concurrentemente. Por ejemplo, poner 1000, 10000 usuarios a la vez en la web haciendo peticiones de todo tipo y monitorizar los resultados.
  • Crawlear la web completa, inspeccionar a ver si hubiera algún problema que desemboque en errores 500, o secciones de la página excesivamente lentas.
  • Ten en mente que una vez montado el servidor, el 99% de los problemas de rendimiento son por software, la solución no estará en aumentar el hardware.

Finalmente queda tener en mente el revisar a nivel de servidores constantemente sus configuraciones: Apache, Nginx, PHP/PHP-FPM, GNULinux, Redis, Mariadb, etc..

Terminando

Con todo esto ya tenemos unas pinceladas para la seguridad. Espero que me disculpen los expertos, ya que no soy experto en seguridad y seguro que se escapa algún detalle importante. Cualquier aporte siempre será bien recibido. Como siempre, me remito a la documentación oficial para completar los ejemplos y explicaciones de arriba:
https://symfony.com/doc/current/security.html

De nuevo dejo todo el código fuente de este post en Github:
https://github.com/jaimenj/symfony-starter-tutorials

¡Un saludo!

5 respuestas a “Symfony: tutorial 16: la seguridad”

  1. Neolo dice:

    Buenas,
    Muchas gracias están siéndome de gran ayuda, un gran trabajo!
    En este caso, al comentar la línea del formulario «->add(‘roles’)» no podemos agregar roles a los usuarios que creamos y todos son idénticos, ¿Cómo se podría realizar una distinción por roles?
    Muchas gracias.

    • Jnj dice:

      Buenas tardes Neolo.
      Para permitir usar varios roles fíjate en la variable que almacena los roles en la entidad User:
      https://github.com/jaimenj/symfony-starter-tutorials/blob/master/symfony-tutorial-16/src/Entity/User.php
      Es un array, un tipo de dato json. Se puede construir una gestión de los roles por programación en el formulario. La idea es que se puede añadir en el formulario de edición de User los roles que quieras. También se podría crear una entidad de nombre Role y enlazarla con la entidad User. Hay mucho juego. Lo más sencillo sería en el formulario hacer un desplegable con los valores:
      ROLE_USER
      ROLE_ADMIN
      ROLE_SUPER_ADMIN
      etc..
      Y luego controlar las zonas con los roles que hayas definido en controladores, plantillas Twig o en el fichero de configuración security.yml. Hay mucho juego con esto de los roles.
      Un saludo y muchas gracias por comentar!

    • Jnj dice:

      Hola Neolo, hay un post relacionado que añade lo que comentas de la edición de roles de usuario..
      https://jnjsite.com/symfony-gestionando-los-roles-de-usuario/

      • Neolo dice:

        Muchísimas gracias, es justo lo que buscaba, están geniales estos posts, muy útiles, y perfectamente explicados! y encima respondes a las preguntas super rápido, no se puede pedir mas! no sabes cuanto me has ayudado…

Responder a Neolo Cancelar la respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

 

© 2024 JnjSite.com - MIT license

Sitio hecho con WordPress, diseño y programación del tema por Jnj.