Módulo 10: Seguridad
Objetivos del módulo
- Distinguir autenticación de autorización y conocer las piezas del componente Security
- Crear la entidad
Userconmake:usery entenderUserInterfaceyPasswordAuthenticatedUserInterface - 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-loginy configurar el cierre de sesión - Entender
security.yaml: proveedores, firewalls,access_controly 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,
UserInterfaceexigía el métodoeraseCredentials()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
UserRepositorygenerado implementaPasswordUpgraderInterface. Cuando un usuario inicia sesión con un hash antiguo, Symfony lo rehashea automáticamente con el algoritmo actual y llama aupgradePassword(). 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-bundley 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 autenticadorform_logindel 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. Condefault_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
patterncoincida (un firewall sinpattern, comomain, 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_controlgana 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 proponemake:voter) en lugar de usar nombres genéricos comoEDIT, 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
apiva antes quemain: comomainno tienepattern, coincidiría con todo. En el módulo 12 (APIs REST) ampliaremos este punto con la entidadApiToken, 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_controly 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 auditrevisa 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: ... } |