Módulo 10: Seguridad

Objetivos del módulo

  • Distinguir autenticación de autorización y conocer las piezas del componente Security
  • Crear la entidad User con make:user y entender UserInterface y PasswordAuthenticatedUserInterface
  • Almacenar contraseñas de forma segura con los password hashers
  • Generar el registro de usuarios con make:registration-form
  • Crear el login con make:security:form-login y configurar el cierre de sesión
  • Entender security.yaml: proveedores, firewalls, access_control y jerarquía de roles
  • Comprobar roles en controladores y plantillas (#[IsGranted], is_granted(), #[CurrentUser])
  • Activar “recordarme” y limitar los intentos de login
  • Escribir un voter para que solo el autor o un administrador pueda editar un artículo
  • Conocer la autenticación por token para APIs

1. Autenticación y autorización

Hasta ahora, cualquiera podía entrar en /admin/articulos/nuevo. Es hora de cerrar la puerta.

📘 Concepto: La seguridad de una aplicación tiene dos preguntas distintas:

  • Autenticación: ¿quién eres? (iniciar sesión con email y contraseña, un token…)
  • Autorización: ¿qué puedes hacer? (¿puedes entrar en el panel?, ¿puedes editar este artículo?)

Piensa en un hotel: en recepción enseñas tu DNI (autenticación) y te dan una tarjeta. Esa tarjeta abre tu habitación y el gimnasio, pero no la habitación de al lado ni la cocina (autorización).

Pieza Responde a En Symfony
Usuario ¿Qué datos identifican a alguien? Clase User que implementa UserInterface
Proveedor de usuarios ¿Dónde están los usuarios? providers (por ejemplo, la entidad Doctrine)
Hasher de contraseñas ¿Cómo se guardan las contraseñas? password_hashers
Firewall ¿Cómo se autentica la gente en esta zona? firewalls (form_login, access_token…)
Control de acceso ¿Qué roles hacen falta por URL? access_control
Voters ¿Puede este usuario hacer esto con este objeto? Clases que extienden Voter

Instalación

En proyectos --webapp ya está instalado. Si no:

composer require symfony/security-bundle

2. La entidad User

2.1 make:user

$ php bin/console make:user

 The name of the security user class (e.g. User) [User]:
 > User

 Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
 > yes

 Enter a property name that will be the unique "display" name for the user (e.g. email, username, uuid) [email]:
 > email

 Will this app need to hash/check user passwords? Choose No if passwords are not needed or will be checked/hashed by some other system (e.g. a single sign-on server).

 Does this app need to hash/check user passwords? (yes/no) [yes]:
 > yes

 created: src/Entity/User.php
 created: src/Repository/UserRepository.php
 updated: src/Entity/User.php
 updated: config/packages/security.yaml

2.2 El código generado (ampliado para el blog)

<?php
// src/Entity/User.php
declare(strict_types=1);

namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')] // "user" es palabra reservada en PostgreSQL y otros motores
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])]
#[UniqueEntity(fields: ['email'], message: 'Ya existe una cuenta con este email.')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 180)]
    private ?string $email = null;

    /**
     * @var list<string> Los roles del usuario
     */
    #[ORM\Column]
    private array $roles = [];

    /**
     * @var string|null La contraseña cifrada (hash), nunca en texto plano
     */
    #[ORM\Column]
    private ?string $password = null;

    #[ORM\Column(length: 100)]
    private ?string $displayName = null;

    /**
     * @var Collection<int, Article>
     */
    #[ORM\OneToMany(targetEntity: Article::class, mappedBy: 'author')]
    private Collection $articles;

    public function __construct()
    {
        $this->articles = new ArrayCollection();
    }

    /**
     * Identificador visible del usuario (lo que escribe en el login).
     */
    public function getUserIdentifier(): string
    {
        return (string) $this->email;
    }

    /**
     * @return list<string>
     */
    public function getRoles(): array
    {
        $roles = $this->roles;
        // todo usuario tiene al menos ROLE_USER
        $roles[] = 'ROLE_USER';

        return array_values(array_unique($roles));
    }

    /**
     * @param list<string> $roles
     */
    public function setRoles(array $roles): static
    {
        $this->roles = $roles;

        return $this;
    }

    public function getPassword(): ?string
    {
        return $this->password;
    }

    public function setPassword(string $password): static
    {
        $this->password = $password;

        return $this;
    }

    // ... getId(), getEmail(), setEmail(), getDisplayName(), setDisplayName(), getArticles()
}
Interfaz / método Para qué sirve
UserInterface Contrato mínimo de cualquier usuario de Symfony
getUserIdentifier() Devuelve el identificador único (email, username…)
getRoles() Devuelve los roles (ROLE_USER, ROLE_ADMIN…)
PasswordAuthenticatedUserInterface Indica que el usuario tiene contraseña
getPassword() Devuelve el hash de la contraseña

📘 Concepto: En versiones anteriores, UserInterface exigía el método eraseCredentials() para borrar datos sensibles temporales. En Symfony 8 ya no forma parte de la interfaz: la recomendación es no guardar nunca la contraseña en claro en la entidad (usa campos no mapeados del formulario, como verás en el registro).

2.3 El autor de los artículos

Relacionamos cada artículo con su autor (como viste en el módulo 06):

// src/Entity/Article.php (fragmento)
#[ORM\ManyToOne(inversedBy: 'articles')]
#[ORM\JoinColumn(nullable: false)]
private ?User $author = null;

public function getAuthor(): ?User
{
    return $this->author;
}

public function setAuthor(?User $author): static
{
    $this->author = $author;

    return $this;
}
php bin/console make:migration
php bin/console doctrine:migrations:migrate

3. Contraseñas: password hashers

⚠️ Importante: Nunca guardes contraseñas en texto plano ni con md5()/sha1(). Si te roban la base de datos, todas las cuentas quedan expuestas (y la gente reutiliza contraseñas). Se guarda un hash lento y con sal, que no se puede revertir.

make:user ya configuró el hasher:

# config/packages/security.yaml (fragmento)
security:
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'

auto elige el mejor algoritmo disponible (bcrypt o Argon2/sodium) y permite migrar de forma transparente si en el futuro cambia. Para hashear o comprobar contraseñas en tu código, inyecta UserPasswordHasherInterface:

use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

$hash = $passwordHasher->hashPassword($user, 'contraseña-en-claro');
$user->setPassword($hash);

$ok = $passwordHasher->isPasswordValid($user, 'lo-que-escribió'); // bool

Desde la terminal también puedes generar un hash (útil para crear un administrador a mano):

php bin/console security:hash-password

💡 Consejo: El UserRepository generado implementa PasswordUpgraderInterface. Cuando un usuario inicia sesión con un hash antiguo, Symfony lo rehashea automáticamente con el algoritmo actual y llama a upgradePassword(). No tienes que hacer nada.

Usuarios de prueba con fixtures

<?php
// src/DataFixtures/UserFixtures.php
declare(strict_types=1);

namespace App\DataFixtures;

use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

class UserFixtures extends Fixture
{
    public function __construct(private readonly UserPasswordHasherInterface $hasher)
    {
    }

    public function load(ObjectManager $manager): void
    {
        $admin = new User();
        $admin->setEmail('admin@blog.test');
        $admin->setDisplayName('Administradora');
        $admin->setRoles(['ROLE_ADMIN']);
        $admin->setPassword($this->hasher->hashPassword($admin, 'admin1234'));
        $manager->persist($admin);

        $author = new User();
        $author->setEmail('autor@blog.test');
        $author->setDisplayName('Autor de pruebas');
        $author->setRoles(['ROLE_AUTHOR']);
        $author->setPassword($this->hasher->hashPassword($author, 'autor1234'));
        $manager->persist($author);

        $manager->flush();
    }
}

4. Registro de usuarios

php bin/console make:registration-form

El asistente pregunta si quieres añadir #[UniqueEntity], si quieres verificar el email (con el bundle symfonycasts/verify-email-bundle, que envía un enlace firmado) y si quieres iniciar sesión automáticamente tras registrarse. Genera RegistrationController, RegistrationFormType y la plantilla.

4.1 El formulario

<?php
// src/Form/RegistrationFormType.php
declare(strict_types=1);

namespace App\Form;

use App\Entity\User;
use App\Validator\PasswordRequirements;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;

class RegistrationFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('email', EmailType::class, ['label' => 'Email'])
            ->add('displayName', TextType::class, ['label' => 'Nombre público'])
            ->add('plainPassword', RepeatedType::class, [
                'type' => PasswordType::class,
                // no mapeado: la entidad solo guarda el hash
                'mapped' => false,
                'first_options' => ['label' => 'Contraseña', 'attr' => ['autocomplete' => 'new-password']],
                'second_options' => ['label' => 'Repite la contraseña'],
                'invalid_message' => 'Las contraseñas no coinciden.',
                'constraints' => [new PasswordRequirements()], // restricción compuesta del módulo 08
            ])
            ->add('agreeTerms', CheckboxType::class, [
                'label' => 'Acepto las condiciones de uso',
                'mapped' => false,
                'constraints' => [new Assert\IsTrue(message: 'Debes aceptar las condiciones.')],
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['data_class' => User::class]);
    }
}

4.2 El controlador

<?php
// src/Controller/RegistrationController.php
declare(strict_types=1);

namespace App\Controller;

use App\Entity\User;
use App\Form\RegistrationFormType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;

final class RegistrationController extends AbstractController
{
    #[Route('/registro', name: 'app_register')]
    public function register(
        Request $request,
        UserPasswordHasherInterface $passwordHasher,
        Security $security,
        EntityManagerInterface $em,
    ): Response {
        $user = new User();
        $form = $this->createForm(RegistrationFormType::class, $user);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            /** @var string $plainPassword */
            $plainPassword = $form->get('plainPassword')->getData();
            $user->setPassword($passwordHasher->hashPassword($user, $plainPassword));

            $em->persist($user);
            $em->flush();

            // Inicia sesión automáticamente con el autenticador del firewall "main"
            return $security->login($user, 'form_login', 'main');
        }

        return $this->render('registration/register.html.twig', [
            'registrationForm' => $form,
        ]);
    }
}

💡 Consejo: En un blog público conviene activar la verificación de email: el usuario no puede publicar ni comentar hasta pulsar el enlace que recibe. El maker te lo deja casi hecho con symfonycasts/verify-email-bundle y el Mailer (módulo 11).


5. Login y logout

5.1 make:security:form-login

php bin/console make:security:form-login
 Choose a name for the controller class (e.g. SecurityController) [SecurityController]:
 >

 Do you want to generate a '/logout' URL? (yes/no) [yes]:
 >

 created: src/Controller/SecurityController.php
 created: templates/security/login.html.twig
 updated: config/packages/security.yaml

5.2 El controlador generado

<?php
// src/Controller/SecurityController.php
declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;

final class SecurityController extends AbstractController
{
    #[Route('/login', name: 'app_login')]
    public function login(AuthenticationUtils $authenticationUtils): Response
    {
        // error del último intento (si lo hubo)
        $error = $authenticationUtils->getLastAuthenticationError();
        // último email introducido
        $lastUsername = $authenticationUtils->getLastUsername();

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

    #[Route('/logout', name: 'app_logout')]
    public function logout(): void
    {
        // Nunca se ejecuta: el firewall intercepta esta ruta
        throw new \LogicException('Este método lo intercepta la clave logout del firewall.');
    }
}

📘 Concepto: Fíjate en que login() no comprueba la contraseña. Solo muestra el formulario. Cuando el formulario se envía por POST a /login, el autenticador form_login del firewall intercepta la petición antes de llegar al controlador, verifica las credenciales y redirige.

5.3 La plantilla (adaptada a Bootstrap)

{# templates/security/login.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}Iniciar sesión{% endblock %}

{% block body %}
<div class="row justify-content-center">
    <div class="col-md-5">
        <h1 class="h3 mb-4">Iniciar sesión</h1>

        {% if error %}
            <div class="alert alert-danger">
                {{ error.messageKey|trans(error.messageData, 'security') }}
            </div>
        {% endif %}

        {% if app.user %}
            <div class="alert alert-info">
                Ya has iniciado sesión como {{ app.user.userIdentifier }}.
                <a href="{{ path('app_logout') }}">Cerrar sesión</a>
            </div>
        {% endif %}

        <form method="post">
            <div class="mb-3">
                <label for="username" class="form-label">Email</label>
                <input type="email" value="{{ last_username }}" name="_username" id="username"
                       class="form-control" autocomplete="email" required autofocus>
            </div>
            <div class="mb-3">
                <label for="password" class="form-label">Contraseña</label>
                <input type="password" name="_password" id="password"
                       class="form-control" autocomplete="current-password" required>
            </div>

            {# Protección CSRF del login #}
            <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">

            <div class="form-check mb-3">
                <input type="checkbox" name="_remember_me" id="remember_me" class="form-check-input">
                <label for="remember_me" class="form-check-label">Recordarme</label>
            </div>

            <button class="btn btn-primary w-100" type="submit">Entrar</button>
        </form>

        <p class="mt-3 text-center">
            ¿No tienes cuenta? <a href="{{ path('app_register') }}">Regístrate</a>
        </p>
    </div>
</div>
{% endblock %}
Campo Nombre esperado por form_login
Usuario _username
Contraseña _password
Token CSRF _csrf_token (id del token authenticate)
Recordarme _remember_me
Redirección tras login _target_path (opcional)

💡 Consejo: Los mensajes de error del login se traducen con el dominio security. Con default_locale: es (módulo 08) verás “Credenciales no válidas.” en lugar de “Invalid credentials.”. Por seguridad, el mensaje es el mismo si el email no existe o si la contraseña es incorrecta: así no se revela qué emails están registrados.


6. security.yaml explicado

Tras los makers y algunos ajustes, la configuración del blog queda así:

# config/packages/security.yaml
security:
    # 1. Cómo se hashean las contraseñas
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'

    # 2. De dónde salen los usuarios
    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email

    # 3. Cómo se autentican
    firewalls:
        dev:
            # rutas de desarrollo sin seguridad (profiler, assets)
            pattern: ^/(_profiler|_wdt|assets|build)/
            security: false
        main:
            lazy: true
            provider: app_user_provider
            form_login:
                login_path: app_login
                check_path: app_login
                enable_csrf: true
                default_target_path: app_home
            logout:
                path: app_logout
                target: app_home
            remember_me:
                secret: '%kernel.secret%'
                lifetime: 604800 # 1 semana en segundos
            login_throttling:
                max_attempts: 5
                interval: '15 minutes'

    # 4. Roles que incluyen a otros
    role_hierarchy:
        ROLE_AUTHOR: ROLE_USER
        ROLE_EDITOR: ROLE_AUTHOR
        ROLE_ADMIN: [ROLE_EDITOR, ROLE_ALLOWED_TO_SWITCH]

    # 5. Qué rol hace falta por URL (gana la PRIMERA regla que coincide)
    access_control:
        - { path: ^/login, roles: PUBLIC_ACCESS }
        - { path: ^/registro, roles: PUBLIC_ACCESS }
        - { path: ^/admin/usuarios, roles: ROLE_ADMIN }
        - { path: ^/admin, roles: ROLE_AUTHOR }
        - { path: ^/perfil, roles: ROLE_USER }

when@test:
    security:
        password_hashers:
            # hash rápido en tests (no usar en producción)
            Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
                algorithm: auto
                cost: 4
                time_cost: 3
                memory_cost: 10

6.1 Providers

El proveedor carga el usuario a partir del identificador (aquí, buscando en la entidad User por email) y lo refresca en cada petición desde la base de datos, para que un cambio de roles o un bloqueo surtan efecto sin esperar a que caduque la sesión.

6.2 Firewalls

📘 Concepto: Un firewall define cómo se autentica la gente en una zona de URLs. Solo se activa un firewall por petición: el primero cuyo pattern coincida (un firewall sin pattern, como main, coincide con todo). Dentro de él activas autenticadores: form_login, remember_me, access_token, json_login…

Opción Significado
pattern Expresión regular de las URLs que cubre
security: false Desactiva la seguridad (solo para herramientas de desarrollo)
lazy: true No carga el usuario de la sesión hasta que se necesita (mejora la caché HTTP)
provider Proveedor de usuarios a usar
form_login Autenticación con formulario HTML
logout Ruta de cierre de sesión
remember_me Cookie de larga duración
login_throttling Límite de intentos fallidos

6.3 access_control

Opción Ejemplo Significado
path ^/admin Expresión regular sobre la ruta
roles ROLE_AUTHOR Rol (o atributo especial) necesario
methods [POST, DELETE] Solo para esos métodos HTTP
ips [127.0.0.1] Solo para esas IP
requires_channel https Fuerza HTTPS

⚠️ Importante: En access_control gana la primera regla que coincide. Pon siempre las reglas más específicas (^/admin/usuarios) antes que las generales (^/admin). Si las inviertes, cualquier autor podría entrar en la gestión de usuarios.

6.4 Role hierarchy

Con la jerarquía anterior, un ROLE_ADMIN tiene implícitamente ROLE_EDITOR, ROLE_AUTHOR y ROLE_USER. Así no tienes que asignar varios roles a cada usuario.

flowchart BT
    U[ROLE_USER] --> A[ROLE_AUTHOR]
    A --> E[ROLE_EDITOR]
    E --> AD[ROLE_ADMIN]

6.5 Flujo completo de autenticación

sequenceDiagram
    participant N as Navegador
    participant FW as Firewall main
    participant AC as access_control
    participant FL as form_login
    participant UP as UserProvider
    participant PH as PasswordHasher
    participant C as Controlador

    N->>FW: GET /admin/articulos
    FW->>AC: ¿Requiere rol? ROLE_AUTHOR
    AC-->>N: No autenticado → 302 a /login
    N->>C: GET /login (muestra formulario)
    N->>FL: POST /login (_username, _password, _csrf_token)
    FL->>FL: Comprueba token CSRF y límite de intentos
    FL->>UP: Cargar usuario por email
    UP-->>FL: User
    FL->>PH: ¿Contraseña válida?
    PH-->>FL: Sí
    FL-->>N: Token en sesión → 302 a /admin/articulos
    N->>FW: GET /admin/articulos (con cookie de sesión)
    FW->>UP: Refrescar usuario
    FW->>AC: ROLE_AUTHOR ✓
    AC->>C: Ejecuta el controlador
    C-->>N: 200 OK

7. Comprobar roles y permisos

7.1 En controladores

<?php
// src/Controller/Admin/UserAdminController.php
declare(strict_types=1);

namespace App\Controller\Admin;

use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/admin/usuarios')]
#[IsGranted('ROLE_ADMIN')] // se aplica a todas las acciones
final class UserAdminController extends AbstractController
{
    #[Route('', name: 'app_admin_user_index')]
    public function index(UserRepository $users): Response
    {
        return $this->render('admin/user/index.html.twig', ['users' => $users->findAll()]);
    }

    #[Route('/{id}/borrar', name: 'app_admin_user_delete', methods: ['POST'])]
    #[IsGranted('ROLE_SUPER_ADMIN', message: 'Solo un superadministrador puede borrar usuarios.')]
    public function delete(): Response
    {
        // ...
        return $this->redirectToRoute('app_admin_user_index');
    }
}

Cuando necesitas comprobarlo dentro del código:

// Lanza una excepción (403, o redirige al login si no hay sesión)
$this->denyAccessUnlessGranted('ROLE_EDITOR');

// Devuelve un booleano
if ($this->isGranted('ROLE_ADMIN')) {
    // mostrar opciones extra
}

En servicios, inyecta Symfony\Bundle\SecurityBundle\Security:

use Symfony\Bundle\SecurityBundle\Security;

final readonly class DashboardStats
{
    public function __construct(private Security $security)
    {
    }

    public function forCurrentUser(): array
    {
        $user = $this->security->getUser();          // UserInterface|null
        $isAdmin = $this->security->isGranted('ROLE_ADMIN');
        // ...
        return [];
    }
}

7.2 Atributos especiales

Atributo Se concede si…
PUBLIC_ACCESS Siempre (útil en access_control)
IS_AUTHENTICATED Hay un usuario autenticado (por cualquier vía)
IS_AUTHENTICATED_REMEMBERED Autenticado, incluido por la cookie “recordarme”
IS_AUTHENTICATED_FULLY Autenticado en esta sesión (ha escrito la contraseña)
IS_REMEMBERED Autenticado solo por “recordarme”
IS_IMPERSONATOR Está suplantando a otro usuario (switch_user)

💡 Consejo: Para acciones sensibles (cambiar la contraseña o el email) exige IS_AUTHENTICATED_FULLY: si alguien entró gracias a la cookie de “recordarme” en un ordenador compartido, se le pedirá la contraseña de nuevo.

7.3 El usuario actual: #[CurrentUser] y app.user

use App\Entity\User;
use Symfony\Component\Security\Http\Attribute\CurrentUser;

#[Route('/perfil', name: 'app_profile')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function profile(#[CurrentUser] User $user): Response
{
    return $this->render('profile/index.html.twig', [
        'articles' => $user->getArticles(),
    ]);
}

Si la ruta es pública y el usuario puede no estar logueado, haz el argumento nullable: #[CurrentUser] ?User $user. En un controlador también tienes $this->getUser().

Al crear un artículo, asignamos el autor así:

#[Route('/admin/articulos/nuevo', name: 'app_admin_article_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $em, #[CurrentUser] User $user): Response
{
    $article = new Article();
    $article->setAuthor($user);
    // ... formulario como en el módulo 07
}

7.4 En Twig

{# templates/_navbar.html.twig #}
<ul class="navbar-nav ms-auto">
    {% if app.user %}
        <li class="nav-item">
            <span class="navbar-text me-3">Hola, {{ app.user.displayName }}</span>
        </li>
        {% if is_granted('ROLE_AUTHOR') %}
            <li class="nav-item"><a class="nav-link" href="{{ path('app_admin_article_index') }}">Mis artículos</a></li>
        {% endif %}
        {% if is_granted('ROLE_ADMIN') %}
            <li class="nav-item"><a class="nav-link" href="{{ path('app_admin_user_index') }}">Usuarios</a></li>
        {% endif %}
        <li class="nav-item"><a class="nav-link" href="{{ path('app_logout') }}">Salir</a></li>
    {% else %}
        <li class="nav-item"><a class="nav-link" href="{{ path('app_login') }}">Entrar</a></li>
        <li class="nav-item"><a class="btn btn-primary" href="{{ path('app_register') }}">Registrarse</a></li>
    {% endif %}
</ul>

⚠️ Importante: Ocultar un botón en Twig no es seguridad: es solo interfaz. La URL sigue existiendo. La protección real está siempre en el servidor (access_control, #[IsGranted], voters).


8. Remember me

La opción remember_me del firewall crea una cookie de larga duración. Cuando la sesión caduca, Symfony usa esa cookie para volver a autenticar al usuario automáticamente.

# config/packages/security.yaml (fragmento)
firewalls:
    main:
        remember_me:
            secret: '%kernel.secret%' # firma la cookie
            lifetime: 604800          # 7 días
            path: /
            # always_remember_me: true  # sin casilla, siempre activo

El usuario lo activa marcando la casilla _remember_me del formulario de login. Si cambia su contraseña, las cookies de “recordarme” anteriores dejan de ser válidas, porque la firma incluye datos del usuario.


9. Voters: permisos sobre objetos

Los roles responden a “¿eres autor?”, pero no a “¿eres el autor de este artículo?”. Para eso están los voters.

📘 Concepto: Un voter es una clase que, ante una pregunta del tipo isGranted('ARTICLE_EDIT', $article), decide si concede, deniega o se abstiene. Symfony consulta a todos los voters y combina sus votos según una estrategia; con la estrategia por defecto (affirmative), basta con que un voter conceda el acceso.

Las reglas del blog:

  • ARTICLE_VIEW: un artículo publicado lo ve cualquiera; un borrador, solo su autor o un admin.
  • ARTICLE_EDIT: solo el autor o un ROLE_ADMIN.
  • ARTICLE_DELETE: el autor solo si es un borrador; un admin siempre.
php bin/console make:voter ArticleVoter
<?php
// src/Security/Voter/ArticleVoter.php
declare(strict_types=1);

namespace App\Security\Voter;

use App\Entity\Article;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

/**
 * @extends Voter<string, Article>
 */
final class ArticleVoter extends Voter
{
    public const string VIEW = 'ARTICLE_VIEW';
    public const string EDIT = 'ARTICLE_EDIT';
    public const string DELETE = 'ARTICLE_DELETE';

    public function __construct(
        private readonly AccessDecisionManagerInterface $accessDecisionManager,
    ) {
    }

    protected function supports(string $attribute, mixed $subject): bool
    {
        // Este voter solo opina sobre estos atributos y sobre artículos
        return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE], true)
            && $subject instanceof Article;
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
    {
        /** @var Article $article */
        $article = $subject;

        // Un artículo publicado es visible para todos, incluso anónimos
        if ($attribute === self::VIEW && $article->getPublishedAt() !== null) {
            return true;
        }

        $user = $token->getUser();
        if (!$user instanceof User) {
            $vote?->addReason('Debes iniciar sesión.');

            return false;
        }

        // Los administradores pueden hacerlo todo (respeta role_hierarchy)
        if ($this->accessDecisionManager->decide($token, ['ROLE_ADMIN'])) {
            return true;
        }

        $isAuthor = $article->getAuthor() === $user;

        return match ($attribute) {
            self::VIEW, self::EDIT => $isAuthor,
            self::DELETE => $isAuthor && $article->getPublishedAt() === null,
            default => false,
        };
    }
}
Método Responsabilidad
supports() ¿Me interesa esta pregunta? Si devuelve false, el voter se abstiene
voteOnAttribute() Si me interesa, ¿concedo (true) o deniego (false)?
$vote?->addReason() Explica el motivo; aparece en los logs y en el Profiler (panel Security)

💡 Consejo: Prefijamos los atributos con el nombre de la entidad (ARTICLE_EDIT, que es lo que propone make:voter) en lugar de usar nombres genéricos como EDIT, para que no choquen con los de otros voters. Usa las constantes de la clase en lugar de escribir la cadena a mano.

9.1 Usar el voter en el controlador

use App\Security\Voter\ArticleVoter;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/admin/articulos/{id}/editar', name: 'app_admin_article_edit', methods: ['GET', 'POST'])]
// 'article' es el NOMBRE del argumento del controlador que se pasa al voter
#[IsGranted('ARTICLE_EDIT', 'article')]
public function edit(Article $article, Request $request, EntityManagerInterface $em): Response
{
    // Si llegamos aquí, el usuario es el autor o un administrador
    // ...
}

#[Route('/admin/articulos/{id}/borrar', name: 'app_admin_article_delete', methods: ['POST'])]
public function delete(Article $article, Request $request, EntityManagerInterface $em): Response
{
    // Alternativa imperativa
    $this->denyAccessUnlessGranted(ArticleVoter::DELETE, $article);
    // ...
}

9.2 Usar el voter en Twig

{# templates/article/show.html.twig (fragmento) #}
{% if is_granted('ARTICLE_EDIT', article) %}
    <a href="{{ path('app_admin_article_edit', {id: article.id}) }}" class="btn btn-sm btn-outline-secondary">
        Editar
    </a>
{% endif %}

{% if is_granted('ARTICLE_DELETE', article) %}
    {{ include('admin/article/_delete_form.html.twig') }}
{% endif %}

9.3 Listados filtrados

Un voter decide sobre un objeto. Para listados (“mis artículos”), filtra en la consulta en lugar de llamar al voter por cada fila:

// src/Repository/ArticleRepository.php (fragmento)
public function findForAdminList(User $user, bool $isAdmin): array
{
    $qb = $this->createQueryBuilder('a')
        ->innerJoin('a.category', 'c')->addSelect('c')
        ->orderBy('a.createdAt', 'DESC');

    if (!$isAdmin) {
        $qb->andWhere('a.author = :user')->setParameter('user', $user);
    }

    return $qb->getQuery()->getResult();
}

10. Limitación de intentos de login

Sin límite, un atacante puede probar miles de contraseñas por minuto (fuerza bruta). login_throttling lo impide usando el componente RateLimiter:

composer require symfony/rate-limiter
# config/packages/security.yaml (fragmento)
firewalls:
    main:
        # por defecto: 5 intentos por minuto
        login_throttling: null

        # o personalizado
        login_throttling:
            max_attempts: 5
            interval: '15 minutes'

Los intentos se cuentan por combinación de usuario + IP y también por IP. Superado el límite, el login se rechaza (aunque la contraseña sea correcta) y el usuario ve un mensaje indicando que ha hecho demasiados intentos y cuántos minutos debe esperar.


11. Autenticación de APIs con tokens

Las APIs no usan formularios ni cookies de sesión: el cliente envía un token en cada petición, normalmente en la cabecera Authorization: Bearer <token>. Symfony lo resuelve con el autenticador access_token:

# config/packages/security.yaml (fragmento)
firewalls:
    api:
        pattern: ^/api
        stateless: true          # sin sesión
        access_token:
            token_handler: App\Security\ApiTokenHandler
    main:
        # ...
<?php
// src/Security/ApiTokenHandler.php
declare(strict_types=1);

namespace App\Security;

use App\Repository\ApiTokenRepository;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;

final readonly class ApiTokenHandler implements AccessTokenHandlerInterface
{
    public function __construct(private ApiTokenRepository $tokens)
    {
    }

    public function getUserBadgeFrom(string $accessToken): UserBadge
    {
        $token = $this->tokens->findOneBy(['value' => $accessToken]);

        if ($token === null || $token->isExpired()) {
            throw new BadCredentialsException('Token no válido.');
        }

        // Identificador del usuario: el provider lo cargará
        return new UserBadge($token->getOwner()->getUserIdentifier());
    }
}

📘 Concepto: Fíjate en que el firewall api va antes que main: como main no tiene pattern, coincidiría con todo. En el módulo 12 (APIs REST) ampliaremos este punto con la entidad ApiToken, los tokens JWT/OIDC y las respuestas 401.


12. Errores comunes y buenas prácticas

Problema Causa Solución
“Invalid CSRF token” al hacer login Falta el campo _csrf_token o el id no es authenticate Revisa la plantilla y enable_csrf: true
Bucle de redirecciones a /login /login protegido por access_control Añade { path: ^/login, roles: PUBLIC_ACCESS } al principio
El rol no funciona Falta el prefijo ROLE_ Los roles deben empezar por ROLE_
Un autor entra en zona de admin Orden incorrecto en access_control Reglas específicas primero
#[IsGranted('ARTICLE_EDIT', 'article')] no encuentra el sujeto El argumento del controlador se llama distinto El segundo parámetro es el nombre del argumento
El voter nunca se ejecuta supports() devuelve false Revisa atributo y tipo del sujeto; mira el panel Security del Profiler
Los cambios de rol no se aplican El usuario sigue con la sesión anterior El provider refresca al usuario; asegúrate de que getRoles() lee de la BD
“user” da error de SQL Palabra reservada #[ORM\Table(name: '`user`')]

💡 Consejo: Buenas prácticas de seguridad:

  • Protege zonas con access_control y acciones con #[IsGranted]; usa voters para reglas sobre objetos.
  • Concede el mínimo privilegio: los usuarios nuevos solo tienen ROLE_USER.
  • No construyas tu propio sistema de login: los autenticadores de Symfony resuelven CSRF, sesiones, temporización de respuestas y rehash de contraseñas.
  • Mantén actualizadas las dependencias (composer audit revisa vulnerabilidades conocidas).

13. Ejercicios

Ejercicio 1: Usuarios del blog

Crea la entidad User con make:user y añade displayName. Relaciona Article con User mediante author (ManyToOne obligatorio). Crea UserFixtures con un administrador y dos autores, y asigna autores aleatorios a los artículos de las fixtures del módulo 06.

Ejercicio 2: Registro y login

Ejecuta make:registration-form y make:security:form-login. Adapta las plantillas a Bootstrap y en español. Usa la restricción PasswordRequirements del módulo 08 en el campo de contraseña. Comprueba que tras registrarte quedas autenticado automáticamente.

Ejercicio 3: Barra de navegación según el usuario

Modifica la barra de navegación para mostrar “Entrar/Registrarse” a los anónimos y “Hola, nombre / Mis artículos / Salir” a los usuarios autenticados, con un enlace “Usuarios” solo para ROLE_ADMIN.

Ejercicio 4: Proteger el panel

Configura access_control y role_hierarchy como en la sección 6. Prueba con cada usuario de las fixtures a entrar en /admin/articulos y /admin/usuarios y anota el código HTTP que obtienes en cada caso (302, 403, 200).

Ejercicio 5: ArticleVoter

Implementa ArticleVoter con ARTICLE_VIEW, ARTICLE_EDIT y ARTICLE_DELETE. Protege la edición con #[IsGranted('ARTICLE_EDIT', 'article')] y el borrado con denyAccessUnlessGranted(). Muestra los botones de editar y borrar en la página del artículo solo cuando corresponda. Comprueba que un autor no puede editar artículos ajenos escribiendo la URL a mano.

Ejercicio 6: Borradores privados

Haz que la página pública de un artículo (/articulo/{slug}) muestre también los borradores, pero solo a quien pase is_granted('ARTICLE_VIEW', article). Para el resto, devuelve un 404 (pista: busca el artículo sin filtrar por fecha y comprueba el permiso tú mismo).

Ejercicio 7: Recordarme y límite de intentos

Activa remember_me y login_throttling con 3 intentos cada 5 minutos. Inicia sesión con “Recordarme”, borra la cookie de sesión en las herramientas del navegador y recarga: deberías seguir autenticado. Después falla la contraseña 4 veces seguidas y observa el mensaje.

Ejercicio 8: Página de perfil

Crea /perfil protegida con IS_AUTHENTICATED_FULLY que use #[CurrentUser] para mostrar los datos del usuario y la lista de sus artículos (publicados y borradores), con un formulario para cambiar el displayName.


Resumen

Concepto Sintaxis / herramienta
Crear usuario php bin/console make:user
Interfaces UserInterface + PasswordAuthenticatedUserInterface
Hashear contraseñas UserPasswordHasherInterface::hashPassword() / security:hash-password
Registro php bin/console make:registration-form
Login php bin/console make:security:form-login → form_login
Logout logout: { path: app_logout } en el firewall
Proveedor providers: { app_user_provider: { entity: { class:, property: email } } }
Control por URL access_control: - { path: ^/admin, roles: ROLE_AUTHOR }
Jerarquía role_hierarchy: { ROLE_ADMIN: [ROLE_EDITOR] }
En controladores #[IsGranted('ROLE_X')], denyAccessUnlessGranted(), isGranted()
Usuario actual #[CurrentUser] User $user, $this->getUser(), app.user
En Twig is_granted('ROLE_ADMIN'), is_granted('ARTICLE_EDIT', article)
Recordarme remember_me: { secret: '%kernel.secret%' } + casilla _remember_me
Voter extends Voter con supports() y voteOnAttribute()
Permiso sobre objeto #[IsGranted('ARTICLE_EDIT', 'article')]
Fuerza bruta login_throttling: { max_attempts: 5 } + symfony/rate-limiter
APIs access_token: { token_handler: ... }