Módulo 22: Spring Security

Objetivos del módulo

  • Distinguir autenticación (¿quién eres?) de autorización (¿qué puedes hacer?) y entender qué son el principal, los roles y las authorities
  • Comprender la arquitectura de Spring Security: DelegatingFilterProxy, FilterChainProxy, SecurityFilterChain, AuthenticationManager, UserDetailsService, PasswordEncoder y SecurityContextHolder
  • Saber qué ocurre al añadir spring-boot-starter-security a un proyecto y por qué todo queda protegido
  • Configurar la seguridad con un bean SecurityFilterChain: authorizeHttpRequests, requestMatchers, formLogin, logout, httpBasic
  • Guardar los usuarios primero en memoria y después en la base de datos EMPRESA (tablas usuarios y roles), con un UserDetailsService propio y contraseñas cifradas con BCrypt
  • Autorizar por URL y por método (@PreAuthorize con @EnableMethodSecurity) con los roles ADMIN y EMPLEADO
  • Integrar la seguridad en las vistas Thymeleaf (sec:authorize, sec:authentication), con un formulario de login propio y una página 403
  • Entender el ataque CSRF y proteger los formularios POST de empresa-app
  • Proteger la API REST sin estado con JWT usando el resource server de OAuth2 y un endpoint de login que emite los tokens
  • Configurar CORS dentro de la seguridad, probar la API autenticada desde Swagger UI y aplicar las buenas prácticas básicas

1. Autenticación y autorización

Hasta ahora, cualquiera que conociera la URL de empresa-app podía ver los salarios de todos los empleados, borrar departamentos o crear pedidos. En una aplicación real eso es inaceptable: hay que saber quién hace cada petición y decidir si puede hacerla.

Piensa en un congreso profesional. En la entrada, el personal comprueba tu DNI y tu inscripción y te entrega una acreditación con tu nombre y un color: azul para asistentes, rojo para ponentes. Después, en cada sala, un vigilante solo mira el color de la acreditación: la sala de ponentes solo deja pasar a los rojos. Spring Security funciona igual:

Concepto En el congreso En Spring Security
Autenticación Comprobar tu DNI en la entrada Comprobar usuario y contraseña (o un token)
Principal La persona identificada El usuario autenticado (UserDetails, un Jwt…)
Credenciales El DNI La contraseña (se borra de memoria tras autenticar)
Authority / rol El color de la acreditación ROLE_ADMIN, ROLE_EMPLEADO…
Autorización El vigilante de cada sala Las reglas por URL y por método
Contexto de seguridad Llevar la acreditación colgada SecurityContextHolder: el usuario actual de esta petición

📘 Concepto: Una authority (GrantedAuthority) es un permiso con nombre, un simple texto. Un rol es una authority con el prefijo ROLE_: cuando escribes hasRole("ADMIN"), Spring comprueba la authority ROLE_ADMIN; con hasAuthority("ROLE_ADMIN") escribes el nombre completo. Los roles suelen ser grupos de usuarios (ADMIN, EMPLEADO) y las demás authorities, permisos concretos (pedidos:crear, SCOPE_read).

Los dos errores que vas a ver mucho

Código Nombre oficial Significado real Ejemplo en empresa-app
401 Unauthorized No sé quién eres (falta autenticarse o las credenciales no valen) Pedir /api/pedidos/5001 sin token
403 Forbidden Sé quién eres, pero no puedes hacer esto Carmen (EMPLEADO) intenta borrar un producto

💡 Consejo: El nombre «Unauthorized» del 401 es un error histórico de HTTP: en realidad significa «no autenticado». Si te ayuda, recuerda: 401 → «¿quién eres?»; 403 → «tú no».

Los usuarios y roles de empresa-app

Al terminar el módulo, la aplicación tendrá estos usuarios, asociados a empleados de EMPRESA:

Usuario Contraseña Empleado Roles Puede…
ana ana2026 100 · Ana García López (directora general) ADMIN, EMPLEADO Todo
carmen carmen2026 110 · Carmen Ruiz Ortega (vendedora) EMPLEADO Consultar, crear pedidos
pablo pablo2026 120 · Pablo Moreno Herrera (analista) EMPLEADO Consultar, crear pedidos
tomas tomas2026 160 · Tomás Reyes Fuentes (becario) EMPLEADO Nada: su cuenta está desactivada

2. La arquitectura de Spring Security

Spring Security no está dentro de tus controladores: actúa antes, como una serie de filtros de servlet por los que pasa cada petición. Cada filtro tiene una única tarea (leer la sesión, comprobar el token CSRF, procesar el login, decidir el acceso…) y, si algo falla, corta la petición sin que llegue al DispatcherServlet.

![Cadena de filtros: el cliente envía la petición a Tomcat, DelegatingFilterProxy la pasa a FilterChainProxy, que elige la SecurityFilterChain de la API (/api/*, con BearerTokenAuthenticationFilter) o la de la web (con CsrfFilter y UsernamePasswordAuthenticationFilter) antes de llegar al DispatcherServlet](images/22-cadena-filtros.svg) *Figura 1. La cadena de filtros de Spring Security en empresa-app (con las dos cadenas que tendremos al final del módulo).

Pieza Qué hace
DelegatingFilterProxy Filtro normal de servlet que registra Spring Boot. No sabe nada de seguridad: delega en un bean de Spring
FilterChainProxy El bean springSecurityFilterChain. Recorre las SecurityFilterChain y usa la primera cuya ruta coincide
SecurityFilterChain Una lista ordenada de filtros para un conjunto de URL. La declaras tú como bean
SecurityContextHolder Guarda el Authentication de la petición actual (en una variable ThreadLocal)
AuthenticationManager Recibe unas credenciales y devuelve un Authentication autenticado o lanza una excepción
UserDetailsService Carga un usuario por su nombre (de memoria, de la base de datos, de LDAP…)
PasswordEncoder Cifra contraseñas y comprueba si una contraseña coincide con su hash
ExceptionTranslationFilter Convierte las excepciones de seguridad en respuestas: redirigir al login, 401 o 403
AuthorizationFilter El último filtro: aplica las reglas de authorizeHttpRequests

Cuando arranques la versión final de este módulo con el nivel de log DEBUG en org.springframework.security.web.DefaultSecurityFilterChain, verás las dos cadenas y sus filtros en el orden real (salida real, partida en varias líneas):

DEBUG ... o.s.s.web.DefaultSecurityFilterChain : Will secure Or [PathPattern [/api/**]] with filters:
  DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter,
  CorsFilter, LogoutFilter, OAuth2ProtectedResourceMetadataFilter, BearerTokenAuthenticationFilter,
  RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter,
  SessionManagementFilter, ExceptionTranslationFilter, AuthorizationFilter
DEBUG ... o.s.s.web.DefaultSecurityFilterChain : Will secure any request with filters:
  DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter,
  CorsFilter, CsrfFilter, LogoutFilter, UsernamePasswordAuthenticationFilter, RequestCacheAwareFilter,
  SecurityContextHolderAwareRequestFilter, RememberMeAuthenticationFilter, AnonymousAuthenticationFilter,
  ExceptionTranslationFilter, AuthorizationFilter
# application-dev.properties (temporalmente, para estudiar la configuración)
logging.level.org.springframework.security.web.DefaultSecurityFilterChain=debug
# y para seguir paso a paso cada petición (¡muy verboso!):
# logging.level.org.springframework.security=trace

El proceso de autenticación

Cuando alguien envía usuario y contraseña, el filtro correspondiente construye un Authentication sin autenticar y se lo pasa al AuthenticationManager:

Proceso de autenticación: el filtro llama al AuthenticationManager, que delega en DaoAuthenticationProvider; este carga el usuario con UserDetailsService desde la base de datos y compara la contraseña con PasswordEncoder (BCrypt); el Authentication resultante se guarda en el SecurityContextHolder Figura 2. Autenticación con usuario y contraseña.

  1. El filtro (UsernamePasswordAuthenticationFilter para el formulario web, o nuestro controlador /api/auth/login) recibe usuario y contraseña.
  2. El AuthenticationManager (implementación ProviderManager) prueba sus proveedores; el de usuario y contraseña es DaoAuthenticationProvider.
  3. El proveedor pide el usuario al UserDetailsService.
  4. Compara la contraseña escrita con el hash guardado mediante el PasswordEncoder, y comprueba que la cuenta esté activa.
  5. Si todo va bien, devuelve un Authentication autenticado con el principal y sus authorities.
  6. Se guarda en el SecurityContextHolder (y, en la web, en la sesión HTTP para las siguientes peticiones).

📘 Concepto: En cualquier punto del código puedes saber quién es el usuario actual con SecurityContextHolder.getContext().getAuthentication(). En los controladores es más cómodo pedirlo como parámetro: Authentication auth, Principal principal o @AuthenticationPrincipal UsuarioAutenticado usuario (sección 8).


3. Añadir el starter: todo queda protegido

Partimos de empresa-app tal como quedó en el módulo 21 y añadimos una sola dependencia (la versión la gestiona Spring Boot 4.1.1, que trae Spring Security 7.1.1):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Sin escribir ni una línea de configuración, al arrancar aparece este aviso en el log (salida real):

WARN 1 --- [empresa-app] [main] .s.a.UserDetailsServiceAutoConfiguration :
Using generated security password: 63d95bce-f805-4462-aa7c-a54ca609ded2
This generated password is for development use only. Your security configuration must be updated before running your application in production.

La autoconfiguración de Spring Boot ha hecho todo esto:

Qué Detalle
Proteger todas las URL Cualquier petición necesita estar autenticada, incluso la página de inicio y la API
Crear un usuario en memoria Usuario user y una contraseña aleatoria nueva en cada arranque (la del log)
Formulario de login Página generada en /login y cierre de sesión en /logout
HTTP Basic Para clientes que no son navegadores (curl, Postman)
Protección CSRF Todos los POST, PUT, PATCH y DELETE exigen un token (sección 9)
Cabeceras de seguridad X-Content-Type-Options, X-Frame-Options: DENY, Cache-Control: no-cache…
Fijación de sesión Al iniciar sesión se cambia el identificador de sesión

Si el navegador pide /empleados, recibe una redirección al formulario generado:

Formulario Please sign in generado por Spring Security con los campos Username y Password y el botón Sign in Figura 3. Formulario de login por defecto de Spring Security. Captura real.

Si te equivocas de contraseña, vuelve al formulario con el mensaje Invalid credentials:

El mismo formulario con el aviso Invalid credentials en rojo Figura 4. Credenciales incorrectas en el formulario por defecto. Captura real.

Con curl (que no pide HTML) la respuesta es un 401 con la cabecera WWW-Authenticate: Basic, que invita a usar HTTP Basic (salida real):

curl -i http://localhost:8080/api/productos/9
HTTP/1.1 401 
Set-Cookie: JSESSIONID=BECC4DF25A36DE02FCEBA5D3889B0C1D; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY

Y con el usuario y la contraseña del log, en Basic (-u usuario:clave):

curl -i -u user:63d95bce-f805-4462-aa7c-a54ca609ded2 http://localhost:8080/api/productos/9
HTTP/1.1 200 
Content-Type: application/json

{"id":9,"nombre":"Monitor 24 FHD","descripcion":"Monitor de 24 pulgadas Full HD","precio":139.00,"stock":35,"descatalogado":false,"fechaAlta":"2023-01-05","categoriaId":6,"categoria":"Monitores"}

⚠️ Importante: HTTP Basic envía usuario y contraseña en cada petición, solo codificados en Base64 (dXNlcjo2M2Q5...), que no es cifrado: cualquiera que intercepte el tráfico los lee. Solo es aceptable sobre HTTPS.

Para fijar el usuario mientras desarrollas, existen estas propiedades (útiles solo para pruebas rápidas):

spring.security.user.name=admin
spring.security.user.password=cambiame
spring.security.user.roles=ADMIN

4. La primera configuración: un bean SecurityFilterChain

La configuración por defecto protege todo por igual. Para decidir qué está protegido y para quién, se declara un bean SecurityFilterChain. En cuanto existe, Spring Boot retira su cadena por defecto. Creamos el paquete es.curso.empresa.seguridad con una primera versión, con dos usuarios en memoria:

package es.curso.empresa.seguridad;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

/** Primera versión: usuarios en memoria, formulario de login y HTTP Basic. */
@Configuration
public class SeguridadConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/css/**", "/error").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/productos/**", "/api/categorias/**").permitAll()
                .requestMatchers("/empleados/nuevo", "/empleados/*/editar").hasRole("ADMIN")
                .requestMatchers(HttpMethod.POST, "/empleados/**", "/departamentos/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .formLogin(Customizer.withDefaults())
            .httpBasic(Customizer.withDefaults())
            .logout(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public UserDetailsService usuarios(PasswordEncoder encoder) {
        UserDetails ana = User.withUsername("ana")
                .password(encoder.encode("ana2026"))
                .roles("ADMIN", "EMPLEADO")
                .build();
        UserDetails carmen = User.withUsername("carmen")
                .password(encoder.encode("carmen2026"))
                .roles("EMPLEADO")
                .build();
        return new InMemoryUserDetailsManager(ana, carmen);
    }
}

Las reglas de autorización

authorizeHttpRequests recibe una lista de reglas que se evalúan en orden, de arriba abajo; se aplica la primera que coincide:

Método Significado
requestMatchers("/ruta", "/otra/**") Rutas a las que se aplica la regla (* = un segmento, ** = cualquier número de segmentos)
requestMatchers(HttpMethod.POST, "/ruta/**") Solo para ese verbo HTTP
.permitAll() Cualquiera, autenticado o no
.authenticated() Cualquier usuario autenticado
.hasRole("ADMIN") / .hasAnyRole("ADMIN", "EMPLEADO") Usuarios con ese rol (ROLE_ADMIN…)
.hasAuthority("pedidos:crear") / .hasAnyAuthority(...) Con esa authority exacta
.denyAll() Nadie
anyRequest() Todo lo que no haya coincidido antes: debe ir la última

⚠️ Importante: El orden importa. Si pones anyRequest().authenticated() al principio, Spring Security lanza un error al arrancar porque las reglas siguientes no se alcanzarían nunca. Y si pones requestMatchers("/empleados/**").authenticated() antes de requestMatchers("/empleados/nuevo").hasRole("ADMIN"), la segunda regla no sirve de nada: escribe siempre de lo más concreto a lo más general.

📘 Concepto: Desde Spring Security 6 la configuración usa solo el estilo con lambdas (auth -> auth...). En Spring Security 7 se han eliminado el antiguo and(), authorizeRequests() y los matchers AntPathRequestMatcher y MvcRequestMatcher: las rutas se comparan con PathPatternRequestMatcher, el mismo sistema de patrones que usa Spring MVC. Si encuentras en Internet ejemplos con .and() o con WebSecurityConfigurerAdapter, son de versiones antiguas y no compilan.

Probando la configuración en memoria

Salida real de unas cuantas peticiones con curl y HTTP Basic:

$ curl -i http://localhost:8080/api/productos/9
HTTP/1.1 200                                                    ← catálogo público (permitAll)

$ curl -i http://localhost:8080/api/pedidos/5001
HTTP/1.1 401                                                    ← hay que autenticarse
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"

$ curl -i -u carmen:carmen2026 http://localhost:8080/api/pedidos/5001
HTTP/1.1 200                                                    ← Carmen está autenticada

$ curl -i -u carmen:carmen2026 http://localhost:8080/empleados/120/editar
HTTP/1.1 403                                                    ← pero no es ADMIN

$ curl -i -u ana:ana2026 http://localhost:8080/empleados/120/editar
HTTP/1.1 200                                                    ← Ana sí

$ curl -i -u ana:ana2026 -X POST http://localhost:8080/empleados/160/borrar
HTTP/1.1 403                                                    ← ¿¡Ana tampoco puede borrar!?

La última respuesta sorprende: Ana es ADMIN y aun así recibe un 403. No es un error de las reglas: es la protección CSRF, que rechaza un POST sin su token. Lo explicamos en la sección 9.

Contraseñas: nunca en claro

El PasswordEncoder transforma la contraseña en un hash: una huella de la que no se puede recuperar el original. Al iniciar sesión no se «descifra» nada: se calcula de nuevo la huella de lo que escribe el usuario y se compara.

Codificador Uso
BCryptPasswordEncoder El más usado. Lento a propósito (coste configurable) y con sal aleatoria incluida en el hash
Argon2PasswordEncoder Ganador de la Password Hashing Competition; recomendado por OWASP (necesita Bouncy Castle)
Pbkdf2PasswordEncoder / SCryptPasswordEncoder Alternativas estándar
DelegatingPasswordEncoder Guarda el algoritmo como prefijo ({bcrypt}$2a$10$...) y permite migrar de uno a otro. Se crea con PasswordEncoderFactories.createDelegatingPasswordEncoder()
NoOpPasswordEncoder No cifra: obsoleto, solo para demostraciones

Un hash BCrypt tiene esta forma; el de ana2026 que guardaremos en la base de datos es:

$2a$10$Aqfiej43xRieWHsTvaJwQenAcBnALc.p5YWjtAmgvFQBzWpKyA3fW
 │   │  └──────── sal (22) ──────┘└──────── huella (31) ──────┘
 │   └─ coste: 2^10 = 1024 rondas
 └─ versión del algoritmo

💡 Consejo: Spring Security 7 añade además codificadores basados en la librería Password4j (Argon2Password4jPasswordEncoder, BcryptPassword4jPasswordEncoder…). Para un proyecto nuevo, BCrypt con coste 10-12 sigue siendo una elección segura y sencilla; el ejercicio 1 mide cuánto tarda cada coste.


5. Usuarios en la base de datos

Los usuarios en memoria sirven para aprender, pero cada cambio exige recompilar. Vamos a guardarlos en la base de datos EMPRESA, enlazados con la tabla empleados.

Tablas de seguridad: USUARIOS (usuario_id, username único, password con hash BCrypt, activo S/N y emp_id que referencia a EMPLEADOS), USUARIOS_ROLES como tabla intermedia y ROLES con ADMIN y EMPLEADO Figura 5. Las tres tablas nuevas y su relación con EMPLEADOS.

Las tablas

src/main/resources/db/seguridad/schema.sql (válido en MySQL 8.4 y en H2):

-- =====================================================================
--  empresa-app - Tablas de seguridad (usuarios y roles)
--  Se añaden a la base de datos EMPRESA. Válido en MySQL 8.4 y en H2.
-- =====================================================================
CREATE TABLE roles (
    rol_id   INT AUTO_INCREMENT PRIMARY KEY,
    nombre   VARCHAR(30) NOT NULL,
    CONSTRAINT uq_rol_nombre UNIQUE (nombre)
);

CREATE TABLE usuarios (
    usuario_id  INT AUTO_INCREMENT PRIMARY KEY,
    username    VARCHAR(50)  NOT NULL,
    password    VARCHAR(100) NOT NULL,              -- hash BCrypt (60 caracteres), nunca la contraseña
    activo      CHAR(1)      DEFAULT 'S' NOT NULL,
    emp_id      INT,                                -- empleado de EMPRESA asociado (opcional)
    CONSTRAINT uq_usu_username UNIQUE (username),
    CONSTRAINT uq_usu_emp      UNIQUE (emp_id),
    CONSTRAINT ck_usu_activo   CHECK (activo IN ('S', 'N')),
    CONSTRAINT fk_usu_emp      FOREIGN KEY (emp_id) REFERENCES empleados (emp_id)
);

CREATE TABLE usuarios_roles (
    usuario_id  INT,
    rol_id      INT,
    CONSTRAINT pk_usuarios_roles PRIMARY KEY (usuario_id, rol_id),
    CONSTRAINT fk_ur_usuario FOREIGN KEY (usuario_id) REFERENCES usuarios (usuario_id) ON DELETE CASCADE,
    CONSTRAINT fk_ur_rol     FOREIGN KEY (rol_id)     REFERENCES roles (rol_id)
);

Y db/seguridad/data.sql con los cuatro usuarios de la sección 1 (las contraseñas ya cifradas):

INSERT INTO roles (rol_id, nombre) VALUES (1, 'ADMIN');
INSERT INTO roles (rol_id, nombre) VALUES (2, 'EMPLEADO');

INSERT INTO usuarios (usuario_id, username, password, activo, emp_id) VALUES
  (1, 'ana',    '$2a$10$Aqfiej43xRieWHsTvaJwQenAcBnALc.p5YWjtAmgvFQBzWpKyA3fW', 'S', 100);
INSERT INTO usuarios (usuario_id, username, password, activo, emp_id) VALUES
  (2, 'carmen', '$2a$10$TmlpQHPk2VZ5qgcvREdnd.7OzJJA.nGaR/Wce8g162K/mBmI82TCe', 'S', 110);
INSERT INTO usuarios (usuario_id, username, password, activo, emp_id) VALUES
  (3, 'pablo',  '$2a$10$UCPhC..go09WqpZ8DDy0jOLEvzC/Dz8/75zfZ3jHw9AzWKiTV6m26', 'S', 120);
INSERT INTO usuarios (usuario_id, username, password, activo, emp_id) VALUES
  (4, 'tomas',  '$2a$10$UP1FRrp/RQce818GXeyEQ.2TtV31wN82JwmrlrAelbx2tRxOTLsM6', 'N', 160);

INSERT INTO usuarios_roles (usuario_id, rol_id) VALUES (1, 1);
INSERT INTO usuarios_roles (usuario_id, rol_id) VALUES (1, 2);
INSERT INTO usuarios_roles (usuario_id, rol_id) VALUES (2, 2);
INSERT INTO usuarios_roles (usuario_id, rol_id) VALUES (3, 2);
INSERT INTO usuarios_roles (usuario_id, rol_id) VALUES (4, 2);

Como empresa-app usa spring.jpa.hibernate.ddl-auto=validate, las tablas deben existir antes de arrancar. En tu MySQL de Docker (módulo 18) se cargan así:

docker exec -i mysql-curso mysql -ucurso -pcurso empresa < src/main/resources/db/seguridad/schema.sql
docker exec -i mysql-curso mysql -ucurso -pcurso empresa < src/main/resources/db/seguridad/data.sql
docker exec mysql-curso mysql -ucurso -pcurso empresa -e "select u.username, u.activo, u.emp_id, group_concat(r.nombre) roles from usuarios u join usuarios_roles ur using(usuario_id) join roles r using(rol_id) group by u.usuario_id"
username	activo	emp_id	roles
ana	S	100	ADMIN,EMPLEADO
carmen	S	110	EMPLEADO
pablo	S	120	EMPLEADO
tomas	N	160	EMPLEADO

Para el perfil h2 basta con añadir los scripts a las listas de application-h2.properties (se ejecutan en orden):

spring.sql.init.schema-locations=classpath:db/h2/schema.sql,classpath:db/seguridad/schema.sql
spring.sql.init.data-locations=classpath:db/h2/data.sql,classpath:db/seguridad/data.sql

💡 Consejo: En un proyecto profesional, estos cambios de esquema se gestionan con una herramienta de migraciones como Flyway o Liquibase (Spring Boot gestiona sus versiones): cada cambio es un fichero numerado (V2__usuarios.sql) que se aplica una sola vez y queda registrado. Aquí usamos scripts a mano para no mezclar conceptos.

Las entidades

/** Tabla ROLES: ADMIN, EMPLEADO... (sin el prefijo ROLE_). */
@Entity
@Table(name = "roles")
public class Rol {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "rol_id")
    private Integer id;

    @Column(nullable = false, length = 30, unique = true)
    private String nombre;

    protected Rol() { }

    public Rol(String nombre) { this.nombre = nombre; }

    public Integer getId() { return id; }
    public String getNombre() { return nombre; }

    @Override
    public String toString() { return nombre; }
}
/** Tabla USUARIOS: cuentas para entrar en la aplicación. La contraseña se guarda cifrada (BCrypt). */
@Entity
@Table(name = "usuarios")
public class Usuario {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "usuario_id")
    private Integer id;

    @Column(nullable = false, length = 50, unique = true)
    private String username;

    @Column(nullable = false, length = 100)
    private String password;                 // hash BCrypt: $2a$10$...

    @Convert(converter = SiNoConverter.class)     // el mismo conversor 'S'/'N' de productos
    @Column(nullable = false, length = 1, columnDefinition = "char(1)")
    private boolean activo = true;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "emp_id")
    private Empleado empleado;

    @ManyToMany
    @JoinTable(name = "usuarios_roles",
            joinColumns = @JoinColumn(name = "usuario_id"),
            inverseJoinColumns = @JoinColumn(name = "rol_id"))
    private Set<Rol> roles = new HashSet<>();

    protected Usuario() { }

    public Usuario(String username, String password, Empleado empleado) {
        this.username = username;
        this.password = password;
        this.empleado = empleado;
    }

    // getters (y setters de password y activo)

    @Override
    public String toString() { return "Usuario[" + username + ", " + roles + "]"; }   // ¡nunca la contraseña!
}

Y su repositorio. Como al autenticar necesitamos los roles y el empleado, y open-in-view está desactivado (módulo 19), los traemos en la misma consulta con @EntityGraph:

public interface UsuarioRepository extends JpaRepository<Usuario, Integer> {

    // Los roles y el empleado se necesitan al autenticar: se traen en la misma consulta
    @EntityGraph(attributePaths = {"roles", "empleado"})
    Optional<Usuario> findByUsername(String username);

    boolean existsByUsername(String username);
}

El principal: nuestra clase UserDetails

Spring Security trabaja con la interfaz UserDetails (nombre, contraseña, authorities y estado de la cuenta). Podríamos usar la clase User de Spring, pero un record propio nos permite llevar también el nombre completo y el id del empleado, y mostrarlos en las vistas sin volver a consultar la base de datos:

package es.curso.empresa.seguridad;

/**
 * El "principal": lo que Spring Security guarda del usuario que ha iniciado sesión.
 * Además de lo que exige UserDetails, lleva el nombre completo y el id del empleado
 * para poder mostrarlos en las vistas sin volver a consultar la base de datos.
 */
public record UsuarioAutenticado(
        String username,
        String password,
        boolean activo,
        List<SimpleGrantedAuthority> authorities,
        String nombreCompleto,
        Integer empleadoId) implements UserDetails {

    public static UsuarioAutenticado desde(Usuario u) {
        List<SimpleGrantedAuthority> autoridades = u.getRoles().stream()
                .map(r -> new SimpleGrantedAuthority("ROLE_" + r.getNombre()))   // ADMIN -> ROLE_ADMIN
                .sorted((a, b) -> a.getAuthority().compareTo(b.getAuthority()))
                .toList();
        var e = u.getEmpleado();
        return new UsuarioAutenticado(u.getUsername(), u.getPassword(), u.isActivo(), autoridades,
                e == null ? u.getUsername() : e.getNombreCompleto(),
                e == null ? null : e.getId());
    }

    @Override public String getUsername() { return username; }
    @Override public String getPassword() { return password; }
    @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; }
    @Override public boolean isEnabled() { return activo; }

    /** Para Thymeleaf: ${#authentication.principal.nombreCompleto} */
    public String getNombreCompleto() { return nombreCompleto; }
    public Integer getEmpleadoId() { return empleadoId; }

    @Override
    public String toString() { return "UsuarioAutenticado[" + username + ", " + authorities + "]"; }
}
Método de UserDetails Por defecto Si devuelve false…
isEnabled() true DisabledException: «La cuenta está desactivada» (lo usamos con la columna activo)
isAccountNonLocked() true LockedException (cuenta bloqueada, por ejemplo tras varios intentos)
isAccountNonExpired() true AccountExpiredException
isCredentialsNonExpired() true CredentialsExpiredException (obligar a cambiar la contraseña)

💡 Consejo: En Spring Security 7 los cuatro métodos de estado tienen implementación por defecto en la interfaz (devuelven true), así que solo sobrescribes los que uses: nuestro record solo implementa isEnabled(). Fíjate en el toString(): nunca debe incluir la contraseña, ni siquiera su hash, porque los objetos acaban escritos en los logs.

El UserDetailsService propio

package es.curso.empresa.seguridad;

/** Carga los usuarios de la tabla USUARIOS para que Spring Security los autentique. */
@Service
public class UsuarioDetailsService implements UserDetailsService {

    private static final Logger log = LoggerFactory.getLogger(UsuarioDetailsService.class);

    private final UsuarioRepository usuarios;

    public UsuarioDetailsService(UsuarioRepository usuarios) {
        this.usuarios = usuarios;
    }

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.debug("Cargando el usuario {}", username);
        return usuarios.findByUsername(username)
                .map(UsuarioAutenticado::desde)
                .orElseThrow(() -> new UsernameNotFoundException("No existe el usuario " + username));
    }
}

Al ser un bean de tipo UserDetailsService, Spring Security lo usa automáticamente con el PasswordEncoder declarado: basta con borrar el bean InMemoryUserDetailsManager de la primera versión.

⚠️ Importante: El mensaje de error al usuario debe ser el mismo si el usuario no existe que si la contraseña es incorrecta («Usuario o contraseña incorrectos»). Si dijeras «ese usuario no existe», un atacante podría averiguar qué nombres de usuario son válidos. Spring Security ya lo hace así: convierte UsernameNotFoundException en BadCredentialsException.


6. La seguridad completa de la web

Esta es la cadena de la web en su versión final (en la sección 10 añadiremos otra para la API). Sustituye a la primera versión:

@Bean
@Order(2)
public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
            .requestMatchers("/", "/login", "/error", "/css/**").permitAll()
            .requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
            .requestMatchers("/actuator/health/**", "/actuator/info").permitAll()
            .requestMatchers("/actuator/**").hasRole("ADMIN")
            .requestMatchers("/h2-console/**").permitAll()
            .requestMatchers("/empleados/nuevo", "/empleados/*/editar",
                             "/departamentos/nuevo", "/departamentos/*/editar").hasRole("ADMIN")
            .requestMatchers(HttpMethod.POST, "/empleados/**", "/departamentos/**").hasRole("ADMIN")
            .anyRequest().authenticated())
        .formLogin(form -> form
            .loginPage("/login")                          // nuestra plantilla login.html
            .defaultSuccessUrl("/", false)                // tras entrar, a la página que se pidió (o a /)
            .permitAll())
        .logout(logout -> logout
            .logoutSuccessUrl("/login?salir")
            .permitAll())
        .rememberMe(rm -> rm
            .key("clave-recordarme-empresa")
            .tokenValiditySeconds((int) Duration.ofDays(14).toSeconds()))
        // La consola de H2 (perfil h2) usa marcos y formularios propios sin token CSRF
        .csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**"))
        .headers(h -> h.frameOptions(f -> f.sameOrigin()));
    return http.build();
}
Parte Explicación
PathRequest.toStaticResources().atCommonLocations() Matcher de Spring Boot (org.springframework.boot.security.autoconfigure.web.servlet.PathRequest) para /css/**, /js/**, /images/**, /webjars/** y favicon
"/error" Hay que permitirlo: si no, una página de error de un usuario anónimo acabaría redirigiendo al login
"/actuator/health/**" El health check lo consultan herramientas de monitorización sin usuario
loginPage("/login") Usa nuestra página; Spring deja de generar la suya
defaultSuccessUrl("/", false) Si el usuario intentaba abrir /empleados/120, tras el login vuelve allí (el RequestCache lo recordó); si entró directamente en /login, va a /
logoutSuccessUrl("/login?salir") Tras cerrar sesión
rememberMe Casilla «Recordarme»: una cookie firmada que mantiene la sesión 14 días
frameOptions(sameOrigin) La consola de H2 usa <frame>, que la cabecera X-Frame-Options: DENY bloquearía

La página de login propia

El controlador solo muestra la plantilla; el POST /login lo procesa el filtro UsernamePasswordAuthenticationFilter, no nosotros:

/** Página de login propia y perfil del usuario que ha iniciado sesión. */
@Controller
public class CuentaController {

    private final EmpleadoService empleados;

    public CuentaController(EmpleadoService empleados) {
        this.empleados = empleados;
    }

    /** Solo muestra el formulario: el POST /login lo procesa Spring Security. */
    @GetMapping("/login")
    public String login() {
        return "login";
    }

    // ... perfil (sección 8)
}

templates/login.html (con el layout del módulo 21):

<section class="row justify-content-center">
    <div class="col-sm-10 col-md-6 col-lg-4">
        <div class="card shadow-sm">
            <div class="card-body p-4">
                <h1 class="h4 mb-3 text-center">
                    <i class="bi bi-shield-lock text-primary"></i> <span th:text="#{login.titulo}">Iniciar sesión</span>
                </h1>

                <!-- /login?error: lo añade Spring Security cuando falla la autenticación -->
                <div th:if="${param.error}" class="alert alert-danger py-2 small" th:text="#{login.error}">
                    Usuario o contraseña incorrectos</div>
                <!-- /login?salir: logoutSuccessUrl de SeguridadConfig -->
                <div th:if="${param.salir}" class="alert alert-success py-2 small" th:text="#{login.salir}">
                    Has cerrado la sesión</div>

                <!-- POST /login lo procesa el filtro UsernamePasswordAuthenticationFilter.
                     th:action añade el campo oculto _csrf -->
                <form th:action="@{/login}" method="post">
                    <div class="mb-3">
                        <label for="username" class="form-label" th:text="#{login.usuario}">Usuario</label>
                        <input type="text" id="username" name="username" class="form-control" required autofocus
                               autocomplete="username">
                    </div>
                    <div class="mb-3">
                        <label for="password" class="form-label" th:text="#{login.password}">Contraseña</label>
                        <input type="password" id="password" name="password" class="form-control" required
                               autocomplete="current-password">
                    </div>
                    <div class="form-check mb-3">
                        <input type="checkbox" id="remember-me" name="remember-me" class="form-check-input">
                        <label for="remember-me" class="form-check-label" th:text="#{login.recordarme}">Recordarme</label>
                    </div>
                    <button class="btn btn-primary w-100">
                        <i class="bi bi-box-arrow-in-right"></i> <span th:text="#{login.entrar}">Entrar</span>
                    </button>
                </form>
            </div>
        </div>
    </div>
</section>

Los nombres de los campos importan: por defecto el filtro lee username, password y remember-me (se pueden cambiar con usernameParameter(...) y passwordParameter(...)). ${param.error} es el parámetro ?error de la URL.

Página de login propia de empresa-app con la barra de navegación índigo, la tarjeta Iniciar sesión, los campos Usuario y Contraseña, la casilla Recordarme y el botón Entrar Figura 6. Formulario de login propio con Thymeleaf y Bootstrap. Captura real.

El mismo formulario con el aviso rojo Usuario o contraseña incorrectos, o cuenta desactivada Figura 7. Tras un intento fallido, Spring Security redirige a /login?error. Captura real.

Lo que pasa por debajo, comprobado con curl y un fichero de cookies (salida real):

$ curl -i http://localhost:8080/empleados                  # anónimo
HTTP/1.1 302
Location: http://localhost:8080/login

$ curl -c jar.txt http://localhost:8080/login              # el formulario trae un token CSRF
csrf=ni5xmekmH3b9FnQzLE-EIxwhnqR26GVT3TPwf-E12Nm0ah11-xlGqt0XLBDQI0IDSGKwECgTs8VO31d-v1HDStNX6uiHCHtA

$ curl -i -b jar.txt -c jar.txt -X POST http://localhost:8080/login -d "username=ana&password=ana2026&_csrf=ni5x..."
HTTP/1.1 302
Set-Cookie: JSESSIONID=2CE4A3F75AF9737A83B167DF2789C49A; Path=/; HttpOnly
Location: http://localhost:8080/

Fíjate en el Set-Cookie tras el login: Spring Security cambia el identificador de sesión al autenticarse. Así se evita el ataque de fijación de sesión (session fixation), en el que el atacante conseguía que la víctima iniciara sesión con un identificador que él ya conocía.

Cerrar sesión

El logout se hace con un POST a /logout (con su token CSRF), nunca con un enlace: si fuera un GET, cualquier web podría cerrarte la sesión con una simple imagen <img src=".../logout">. En el layout lo ponemos dentro del menú de usuario (sección 8).

La página 403

Cuando un usuario autenticado intenta algo que no le está permitido, Spring Security responde 403 y Spring Boot busca la plantilla templates/error/403.html, igual que con el 404 del módulo 21:

<section class="text-center py-5">
    <div class="display-1 text-danger fw-bold">403</div>
    <h1 class="h3" th:text="#{error.403.titulo}">Acceso denegado</h1>
    <p class="text-secondary" th:text="#{error.403.texto}">No tienes permiso para acceder a esta página.</p>
    <!-- #authentication es el objeto Authentication del usuario actual -->
    <p class="small text-secondary" sec:authorize="isAuthenticated()">
        Has entrado como <strong sec:authentication="name">carmen</strong>
        con los roles <code th:text="${#authentication.authorities.?[authority.startsWith('ROLE_')].![authority]}">[ROLE_EMPLEADO]</code>.
    </p>
    <p class="small text-secondary"><code th:text="${path}">/empleados/120/editar</code></p>
    <a class="btn btn-primary" th:href="@{/}"><i class="bi bi-house"></i> <span th:text="#{error.volver}">Volver al inicio</span></a>
</section>

Página 403 Acceso denegado de empresa-app con el texto Has entrado como carmen con los roles ROLE_EMPLEADO y la ruta /empleados/120/editar Figura 8. Carmen intenta editar a otro empleado: página 403 personalizada. Captura real.

📘 Concepto: ¿Por qué el filtro .?[authority.startsWith('ROLE_')]? En la primera versión de esta página mostrábamos ${#authentication.authorities} sin más y apareció esto: [ROLE_EMPLEADO, FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=2026-10-04T14:26:05Z]]. Spring Security 7 añade al usuario una authority especial por cada factor de autenticación superado (FACTOR_PASSWORD cuando entra con contraseña). Es la base del nuevo soporte de autenticación multifactor (MFA): por ejemplo, exigir además un código de un solo uso para ciertas operaciones. Con la selección de SpEL .?[...] mostramos solo los roles.

Mapa de permisos de la web

Ruta Anónimo EMPLEADO ADMIN
/, /login, /css/**, /swagger-ui/** ✅ ✅ ✅
GET /empleados, /empleados/{id}, /departamentos/**, /productos ➡️ login ✅ ✅
/empleados/nuevo, /empleados/{id}/editar y los de departamentos ➡️ login ❌ 403 ✅
POST /empleados/**, POST /departamentos/** (guardar, borrar, foto) ➡️ login ❌ 403 ✅
/actuator/health, /actuator/info ✅ ✅ ✅
Resto de /actuator/** ➡️ login ❌ 403 ✅

7. Autorización por método

Las reglas por URL son la primera barrera, pero tienen dos debilidades: es fácil olvidar una ruta nueva y no protegen si el mismo servicio se llama desde otro sitio (la API, una tarea programada…). Por eso se añade una segunda barrera en la capa de servicio: la seguridad por método.

Se activa con @EnableMethodSecurity en una clase de configuración:

@Configuration
@EnableMethodSecurity          // activa @PreAuthorize / @PostAuthorize en los servicios
public class SeguridadConfig {
    ...
}

Y se anotan los métodos que modifican datos:

@Service
@Transactional(readOnly = true)
public class EmpleadoService {
    ...
    @Transactional
    @PreAuthorize("hasRole('ADMIN')")     // segunda barrera, además de la regla por URL
    public Empleado guardar(EmpleadoForm form) { ... }

    @Transactional
    @PreAuthorize("hasRole('ADMIN')")
    public void borrar(Integer id) { ... }
}

Lo mismo en DepartamentoService.guardar/borrar y en PedidoService.cambiarEstado. Spring crea un proxy del servicio (como con @Transactional, módulo 17) que evalúa la expresión antes de ejecutar el método; si no se cumple, lanza AccessDeniedException, que acaba en un 403.

Expresiones disponibles

Expresión (SpEL) Significado
hasRole('ADMIN'), hasAnyRole('ADMIN','EMPLEADO') Roles
hasAuthority('pedidos:crear') Authority exacta
isAuthenticated(), isAnonymous(), permitAll, denyAll Estado de autenticación
authentication.name Nombre del usuario actual
principal El principal (UsuarioAutenticado en la web, Jwt en la API)
#id, #req.empleadoId() Parámetros del método por su nombre
returnObject El valor devuelto (solo en @PostAuthorize)
@miBean.metodo(...) Llamar a un bean propio con lógica de autorización
Anotación Cuándo se evalúa Uso típico
@PreAuthorize("...") Antes del método La más usada
@PostAuthorize("...") Después, con acceso a returnObject «Solo puedes ver el pedido si es tuyo»
@PreFilter / @PostFilter Filtran colecciones de entrada o salida Quitar de una lista lo que no puedes ver
@Secured("ROLE_ADMIN") Antes; sin SpEL Código antiguo (@EnableMethodSecurity(securedEnabled = true))
@RolesAllowed("ADMIN") Antes; anotación estándar de Jakarta @EnableMethodSecurity(jsr250Enabled = true)

Una regla con parámetros: cada empleado, sus datos

En la API, un EMPLEADO solo debe poder consultar su propia ficha, y un ADMIN la de cualquiera. Nuestro token JWT (sección 10) lleva un claim emp con el id del empleado, así que la regla se expresa directamente:

@GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or #id == authentication.token.claims['emp']")
public EmpleadoDto obtener(@PathVariable Integer id) {
    return EmpleadoDto.desde(servicio.buscar(id));
}

authentication es aquí un JwtAuthenticationToken, cuyo getToken() devuelve el Jwt con sus claims. Salida real con el token de Carmen (empleada 110) y con el de Ana:

### carmen ve empleado 110
HTTP/1.1 200
{"id":110,"nombre":"Carmen","apellidos":"Ruiz Ortega","email":"carmen.ruiz@empresa.es","oficio":"VENDEDOR","salario":2200.00,"fechaAlta":"2016-04-12","departamento":"VENTAS"}
### carmen ve empleado 120
HTTP/1.1 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
### ana ve empleado 120
HTTP/1.1 200

💡 Consejo: El claim emp llega del JSON como Long y el parámetro id es Integer. Funciona porque el operador == de SpEL compara números de tipos distintos por su valor. Si comparas con .equals(...) en Java, Integer.valueOf(110).equals(110L) es false: cuidado.

⚠️ Importante: La seguridad por método solo actúa cuando la llamada pasa por el proxy: una llamada interna this.borrar(id) dentro del mismo servicio no se comprueba. Es la misma limitación de @Transactional que viste en el módulo 19.


8. Seguridad en las vistas Thymeleaf

Para que la interfaz se adapte al usuario (mostrar su nombre, ocultar el botón «Nuevo empleado» a quien no puede usarlo) se añade el dialecto de seguridad de Thymeleaf, cuya versión gestiona Spring Boot:

<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>

📘 Concepto: No te confunda el 6 del nombre: es el artefacto que Spring Boot 4.1 gestiona (versión 3.1.5) y funciona con Spring Security 7; es también el que añade Spring Initializr al elegir Thymeleaf y Spring Security. El número indica a partir de qué versión de Spring Security sirve.

En las plantillas se declara el espacio de nombres sec (como th, solo ayuda al IDE):

<html lang="es" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
Atributo / objeto Para qué Ejemplo
sec:authorize="expresión" Muestra el elemento solo si se cumple la expresión de seguridad sec:authorize="hasRole('ADMIN')"
sec:authentication="propiedad" Escribe una propiedad del Authentication sec:authentication="name", sec:authentication="principal.nombreCompleto"
${#authentication} El Authentication en expresiones normales ${#authentication.principal.empleadoId}
${#authorization.expression('...')} Evalúa una expresión de seguridad dentro de th:if th:if="${#authorization.expression('hasRole(''ADMIN'')')}"

El menú de usuario en el layout

En fragmentos/layout.html los enlaces del menú solo aparecen a usuarios autenticados, y a la derecha se muestra el botón de entrar o el menú del usuario:

<ul class="navbar-nav me-auto" sec:authorize="isAuthenticated()">
    <li class="nav-item">
        <a class="nav-link" th:classappend="${activo == 'empleados'} ? 'active'"
           th:href="@{/empleados}" th:text="#{menu.empleados}">Empleados</a>
    </li>
    ...
</ul>
<div class="navbar-nav me-auto" sec:authorize="!isAuthenticated()"></div>
<span class="navbar-text small me-3">
    <a class="link-light" th:href="@{''(lang='es')}">ES</a> |
    <a class="link-light" th:href="@{''(lang='en')}">EN</a>
</span>
<!-- Usuario anónimo: botón para entrar -->
<a sec:authorize="!isAuthenticated()" class="btn btn-sm btn-light" th:href="@{/login}">
    <i class="bi bi-box-arrow-in-right"></i> <span th:text="#{menu.entrar}">Iniciar sesión</span>
</a>
<!-- Usuario autenticado: nombre, rol y menú con perfil y salir -->
<div sec:authorize="isAuthenticated()" class="dropdown">
    <button class="btn btn-sm btn-outline-light dropdown-toggle" data-bs-toggle="dropdown">
        <i class="bi bi-person-circle"></i>
        <span sec:authentication="principal.nombreCompleto">Ana García López</span>
        <span sec:authorize="hasRole('ADMIN')" class="badge text-bg-warning ms-1">ADMIN</span>
    </button>
    <ul class="dropdown-menu dropdown-menu-end">
        <li><span class="dropdown-item-text small text-secondary">
            <span th:text="#{menu.usuario}">Usuario</span>: <strong sec:authentication="name">ana</strong></span></li>
        <li><a class="dropdown-item" th:href="@{/perfil}"><i class="bi bi-person-vcard"></i>
            <span th:text="#{menu.perfil}">Mi perfil</span></a></li>
        <li><hr class="dropdown-divider"></li>
        <li>
            <!-- El logout es un POST: th:action añade el token CSRF automáticamente -->
            <form th:action="@{/logout}" method="post">
                <button class="dropdown-item"><i class="bi bi-box-arrow-right"></i>
                    <span th:text="#{menu.salir}">Cerrar sesión</span></button>
            </form>
        </li>
    </ul>
</div>

Barra de navegación con el botón Ana García López y la insignia ADMIN; el menú desplegado muestra Usuario: ana, Mi perfil y Cerrar sesión Figura 9. Menú del usuario autenticado. Captura real.

Botones según el rol

En el listado de empleados, el botón de alta y los de edición solo existen para ADMIN:

<a sec:authorize="hasRole('ADMIN')" class="btn btn-success" th:href="@{/empleados/nuevo}">
    <i class="bi bi-person-plus"></i> <span th:text="#{empleados.nuevo}">Nuevo empleado</span>
</a>
...
<a sec:authorize="hasRole('ADMIN')" class="btn btn-sm btn-outline-secondary"
   th:href="@{/empleados/{id}/editar(id=${e.id})}" th:title="#{boton.editar}"><i class="bi bi-pencil"></i></a>

Y lo mismo en el detalle (botones Editar y Borrar dentro de un <th:block sec:authorize="hasRole('ADMIN')">, y el formulario de la foto) y en el listado de departamentos. El resultado para cada usuario:

Listado de empleados visto por Ana (ADMIN): botón verde Nuevo empleado y un lápiz de edición en cada fila Figura 10. El listado de empleados visto por Ana (ADMIN). Captura real.

El mismo listado visto por Carmen (EMPLEADO): sin botón de alta ni lápices de edición, y con Carmen Ruiz Ortega en la barra Figura 11. El mismo listado visto por Carmen (EMPLEADO). Captura real.

⚠️ Importante: Ocultar un botón no es seguridad: es comodidad para el usuario. Carmen podría escribir /empleados/120/editar a mano o enviar el POST con curl. La protección real son las reglas de SeguridadConfig y los @PreAuthorize de los servicios; sec:authorize solo evita mostrar opciones inútiles.

El usuario actual en un controlador: @AuthenticationPrincipal

La página «Mi perfil» recibe directamente nuestro UsuarioAutenticado:

/** @AuthenticationPrincipal inyecta el principal (nuestro UsuarioAutenticado). */
@GetMapping("/perfil")
public String perfil(@AuthenticationPrincipal UsuarioAutenticado usuario, Model model) {
    model.addAttribute("usuario", usuario);
    if (usuario.empleadoId() != null) {
        model.addAttribute("empleado", empleados.buscar(usuario.empleadoId()));
    }
    return "perfil";
}

Página Mi perfil de Carmen: tarjeta Cuenta con usuario carmen y autoridad ROLE_EMPLEADO, y tarjeta Empleado asociado con nombre, oficio VENDEDOR, departamento VENTAS y email Figura 12. Perfil del usuario con los datos de su empleado. Captura real.

Forma de obtener el usuario Devuelve
@AuthenticationPrincipal UsuarioAutenticado u El principal ya con nuestro tipo
Authentication auth Todo: principal, authorities, detalles
Principal principal Solo el nombre (principal.getName()); estándar de Java EE
SecurityContextHolder.getContext().getAuthentication() Desde cualquier clase (servicios, por ejemplo)

9. CSRF: falsificación de peticiones entre sitios

El ataque

Ataque CSRF: una web maliciosa envía un formulario oculto a empresa-app desde el navegador de Ana, que adjunta automáticamente su cookie de sesión; la defensa es un token secreto en cada formulario que CsrfFilter comprueba y que la web maliciosa no puede leer Figura 13. Ataque CSRF y la defensa con un token sincronizado.

Las cookies de sesión se envían automáticamente con cada petición al sitio que las creó, venga de donde venga la petición. Si Ana tiene abierta empresa-app y visita una web maliciosa con un formulario oculto que apunta a http://localhost:8080/empleados/160/borrar, su navegador enviará ese POST con su sesión de ADMIN. Eso es CSRF (Cross-Site Request Forgery).

La defensa de Spring Security es el patrón del token sincronizado: cada sesión tiene un token aleatorio que debe acompañar a todo POST, PUT, PATCH y DELETE. La web maliciosa no puede leerlo (la política del mismo origen le impide leer las páginas de empresa-app), así que su petición llega sin él y CsrfFilter la rechaza con 403. GET, HEAD, OPTIONS y TRACE no se comprueban: por eso es tan importante que los GET nunca modifiquen datos (módulo 21).

El token en los formularios de empresa-app

Buena noticia: en el módulo 21 todos los formularios POST (guardar y borrar empleados y departamentos, subir la foto) se escribieron con th:action. Cuando Spring Security está presente, Thymeleaf añade automáticamente el campo oculto con el token a todo formulario con th:action y method="post". No hay que tocarlos. Este es el HTML real que genera el formulario de borrado de la ventana modal del detalle de un empleado:

<form action="/empleados/101/borrar" method="post"><input type="hidden" name="_csrf" value="PfSXgrgM7RzKnJuBC8mQtyiY8nLrdoSV0Dl_3xoYjDMeBfycDsem4Itpiyrn_qLiOeSk1R7930uJFeC44wgcuSksuwJ7Zs7-"/>
    <button class="btn btn-danger"><i class="bi bi-trash"></i> Sí, borrar</button>
</form>

Si algún formulario usara action="..." normal (sin th:), habría que añadir el campo a mano:

<form action="/departamentos/70/borrar" method="post">
    <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
    <button class="btn btn-danger">Borrar</button>
</form>

Comprobación real con la sesión de Ana: el mismo POST sin token y con token (el empleado 101 es jefe de otros, así que el servicio no lo borra y redirige al detalle con un mensaje flash):

### POST sin token
$ curl -i -b jar.txt -X POST http://localhost:8080/empleados/101/borrar
HTTP/1.1 403
### POST con token
$ curl -i -b jar.txt -X POST http://localhost:8080/empleados/101/borrar -d "_csrf=PfSXgrgM7RzK..."
HTTP/1.1 302
Location: http://localhost:8080/empleados/101

Esto explica el 403 de Ana en la sección 4: su POST con curl no llevaba token.

CSRF y JavaScript

Si una página hace fetch con POST a la aplicación (con cookies de sesión), el token va en una cabecera. Se publica en <meta> y se lee desde JavaScript:

<meta name="_csrf" th:content="${_csrf.token}">
<meta name="_csrf_header" th:content="${_csrf.headerName}">   <!-- X-CSRF-TOKEN -->
const token = document.querySelector('meta[name="_csrf"]').content;
const cabecera = document.querySelector('meta[name="_csrf_header"]').content;
await fetch('/empleados/160/borrar', { method: 'POST', headers: { [cabecera]: token } });

💡 Consejo: Spring Security 7 incluye http.csrf(csrf -> csrf.spa()), una configuración preparada para aplicaciones de una sola página (React, Angular) que reciben el token en una cookie XSRF-TOKEN legible desde JavaScript.

¿Cuándo se puede desactivar?

Situación ¿CSRF?
Web con sesión y cookies (nuestra web Thymeleaf) Sí, siempre
API REST sin estado con token en la cabecera Authorization (nuestra API con JWT) No hace falta: el navegador no adjunta esa cabecera por su cuenta
API que se autentica con cookies Sí
Consola de H2 en desarrollo La excluimos (ignoringRequestMatchers("/h2-console/**")) porque no envía token

10. La API REST sin estado con JWT

La web usa sesiones: tras el login, el servidor recuerda al usuario y el navegador envía la cookie JSESSIONID. Para una API que usan aplicaciones móviles, frontends en React o Angular y otros servicios, eso tiene inconvenientes: el servidor guarda estado (memoria, y problemas al tener varias instancias), las cookies no encajan con clientes que no son navegadores y obligan a CSRF.

La alternativa es la autenticación sin estado con tokens: el cliente se autentica una vez, recibe un token firmado y lo envía en cada petición en la cabecera Authorization: Bearer <token>. El servidor no guarda nada: le basta con verificar la firma.

JSON Web Token

Un JWT (RFC 7519) es un texto con tres partes en Base64URL separadas por puntos: cabecera, contenido (claims) y firma. El token real que obtiene Carmen, decodificado:

cabecera:  {"kid":"jtPdfVvjTMtK4gZbMTD4oxgNtcAK27pNIj_1rAurpHU","alg":"HS256"}
contenido: {"iss":"empresa-app","sub":"carmen","emp":110,"exp":1791127496,"iat":1791123896,"roles":["EMPLEADO"]}
firma:     b_OTs9mwBlLpICPg2t929oL_id0B6BM0dQsjXizqJIk
Claim Significado
iss (issuer) Quién lo emitió
sub (subject) A quién representa: el nombre de usuario
iat / exp Emitido / caduca (segundos desde 1970): este dura una hora
roles, emp Claims propios: roles y id del empleado

⚠️ Importante: El contenido de un JWT no está cifrado, solo firmado: cualquiera puede leerlo pegándolo en jwt.io. Nunca metas datos confidenciales (contraseñas, salarios). La firma garantiza que nadie lo ha modificado: si cambias "EMPLEADO" por "ADMIN", la firma deja de coincidir.

Flujo JWT: el cliente envía usuario y contraseña a POST /api/auth/login y recibe un token; después envía GET /api/pedidos/5001 con la cabecera Authorization Bearer; BearerTokenAuthenticationFilter verifica firma y caducidad con JwtDecoder, AuthorizationFilter aplica las reglas y el controlador responde; sin token o con token inválido la respuesta es 401 y sin el rol necesario 403 Figura 14. Obtener un JWT y usarlo en las peticiones a la API.

¿Quién emite los tokens? Una decisión de diseño

Opción Cómo funciona Cuándo
A. Servidor de autorización externo (Keycloak, Spring Authorization Server, Auth0, Entra ID) Un servicio dedicado autentica y emite los tokens (OAuth 2.1 / OpenID Connect); empresa-app solo los valida como resource server con spring.security.oauth2.resourceserver.jwt.issuer-uri Varias aplicaciones, inicio de sesión único, login con Google, MFA… Es lo profesional a gran escala
B. La propia aplicación emite sus tokens Un endpoint POST /api/auth/login comprueba las credenciales y firma el JWT con JwtEncoder; el resource server de Spring los valida Una sola aplicación con sus propios usuarios, como empresa-app
C. Librería JWT a mano (jjwt…) y un filtro propio Escribes tú el filtro que lee la cabecera y valida Desaconsejado: reinventas lo que Spring Security ya hace bien

Elegimos la opción B, apoyada por completo en piezas oficiales de Spring Security: NimbusJwtEncoder para firmar, NimbusJwtDecoder y el filtro BearerTokenAuthenticationFilter del resource server para validar. El día que la empresa adopte Keycloak, bastará con borrar el endpoint de login y configurar el issuer-uri: el resto (reglas, @PreAuthorize) no cambia.

📘 Concepto: ¿Por qué no usar el grant «password» de OAuth2, en el que el cliente envía usuario y contraseña al servidor de autorización? Porque está desaconsejado por OAuth 2.1 y Spring Security 7 lo ha eliminado. Un endpoint de login propio dentro de la misma aplicación es otra cosa: la contraseña nunca sale hacia terceros.

La dependencia

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>

⚠️ Importante: En Spring Boot 4 el starter se llama spring-boot-starter-security-oauth2-resource-server. El nombre clásico, spring-boot-starter-oauth2-resource-server, sigue existiendo pero su descripción en Maven Central dice literalmente «deprecated in favor of spring-boot-starter-security-oauth2-resource-server». Spring Initializr ya genera el nuevo nombre. Trae spring-security-oauth2-resource-server y spring-security-oauth2-jose (con la librería Nimbus JOSE + JWT).

La clave y su configuración

Con HMAC-SHA256 (HS256) la misma clave secreta firma y verifica. Debe tener al menos 256 bits y no puede estar en el código:

/** empresa.jwt.*: clave secreta (Base64, al menos 256 bits), duración y emisor de los tokens. */
@Validated
@ConfigurationProperties(prefix = "empresa.jwt")
public record JwtProperties(
        @NotBlank String secreto,
        @DefaultValue("1h") Duration duracion,
        @DefaultValue("empresa-app") String emisor) { }
# application.properties: en dev hay una clave de ejemplo; en prod se exige JWT_SECRET
empresa.jwt.secreto=${JWT_SECRET:S13XIMlqvCnoMLmQNNiJquI5mt4C4ae0/oxLny4App4=}
empresa.jwt.duracion=1h

# application-prod.properties: sin valor por defecto (si falta, la aplicación no arranca)
empresa.jwt.secreto=${JWT_SECRET}

Una clave nueva se genera, por ejemplo, con openssl rand -base64 32.

/**
 * Tokens JWT firmados con HMAC-SHA256 (clave simétrica): la misma aplicación
 * los emite (JwtEncoder) y los comprueba (JwtDecoder, usado por el resource server).
 */
@Configuration
public class JwtConfig {

    private final SecretKey clave;

    public JwtConfig(JwtProperties props) {
        byte[] bytes = Base64.getDecoder().decode(props.secreto());
        if (bytes.length < 32) {
            throw new IllegalStateException("empresa.jwt.secreto debe tener al menos 256 bits (32 bytes)");
        }
        this.clave = new SecretKeySpec(bytes, "HmacSHA256");
    }

    @Bean
    public JwtEncoder jwtEncoder() {
        return NimbusJwtEncoder.withSecretKey(clave).build();
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withSecretKey(clave).macAlgorithm(MacAlgorithm.HS256).build();
    }

    /** Convierte el claim "roles" del token (["ADMIN","EMPLEADO"]) en ROLE_ADMIN, ROLE_EMPLEADO. */
    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
        roles.setAuthoritiesClaimName("roles");
        roles.setAuthorityPrefix("ROLE_");
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(roles);
        return converter;
    }
}

📘 Concepto: NimbusJwtEncoder.withSecretKey(...) es un constructor fluido nuevo de Spring Security 7 (también hay withKeyPair(rsaPublica, rsaPrivada) para RSA y para curvas elípticas). En versiones anteriores había que montar a mano un JWKSource de Nimbus. Por defecto, el convertidor del resource server lee el claim scope y crea authorities SCOPE_...; como nosotros usamos roles, le indicamos el claim roles y el prefijo ROLE_.

Emitir el token

/** Emite un JWT para un usuario ya autenticado. */
@Service
public class TokenService {

    private final JwtEncoder encoder;
    private final JwtProperties props;

    public TokenService(JwtEncoder encoder, JwtProperties props) {
        this.encoder = encoder;
        this.props = props;
    }

    public String generar(Authentication auth) {
        Instant ahora = Instant.now();
        List<String> roles = auth.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .filter(a -> a.startsWith("ROLE_"))
                .map(a -> a.substring(5))                 // ROLE_ADMIN -> ADMIN
                .toList();
        JwtClaimsSet.Builder claims = JwtClaimsSet.builder()
                .issuer(props.emisor())                   // iss
                .subject(auth.getName())                  // sub: el nombre de usuario
                .issuedAt(ahora)                          // iat
                .expiresAt(ahora.plus(props.duracion()))  // exp
                .claim("roles", roles);
        if (auth.getPrincipal() instanceof UsuarioAutenticado u && u.empleadoId() != null) {
            claims.claim("emp", u.empleadoId());          // claim propio: id del empleado
        }
        JwsHeader cabecera = JwsHeader.with(MacAlgorithm.HS256).build();
        return encoder.encode(JwtEncoderParameters.from(cabecera, claims.build())).getTokenValue();
    }

    public long segundosDeValidez() {
        return props.duracion().toSeconds();
    }
}

⚠️ Importante: Indicamos el algoritmo en la cabecera con JwsHeader.with(MacAlgorithm.HS256) y el decodificador solo acepta ese mismo algoritmo (macAlgorithm(MacAlgorithm.HS256)). Así no dependemos del algoritmo por defecto de JwtEncoderParameters.from(claims) (pensado para claves RSA) y nadie puede colarnos un token firmado con otro algoritmo.

El endpoint de login usa el AuthenticationManager para comprobar usuario y contraseña con el mismo UserDetailsService y PasswordEncoder que la web:

/** Inicio de sesión de la API: usuario y contraseña a cambio de un token JWT. */
@RestController
@RequestMapping("/api/auth")
@Tag(name = "Autenticación", description = "Obtener un token JWT")
public class AuthApiController {

    public record LoginRequest(@NotBlank String username, @NotBlank String password) { }

    public record TokenResponse(String token, String tipo, long expiraEn) { }

    private final AuthenticationManager authManager;
    private final TokenService tokens;

    public AuthApiController(AuthenticationManager authManager, TokenService tokens) {
        this.authManager = authManager;
        this.tokens = tokens;
    }

    @PostMapping("/login")
    @SecurityRequirements      // esta operación no necesita token (para Swagger UI)
    @Operation(summary = "Inicia sesión y devuelve un token JWT")
    public TokenResponse login(@Valid @RequestBody LoginRequest req) {
        // Si las credenciales son incorrectas lanza BadCredentialsException (-> 401)
        Authentication auth = authManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(req.username(), req.password()));
        return new TokenResponse(tokens.generar(auth), "Bearer", tokens.segundosDeValidez());
    }
}

Spring Boot no publica un AuthenticationManager como bean, así que lo declaramos en SeguridadConfig. Al existir, Spring Security lo usa también para el formulario de la web (lo comprobamos en el ejercicio 6 con los eventos de inicio de sesión):

/** Lo usa AuthApiController para comprobar usuario y contraseña en /api/auth/login. */
@Bean
public AuthenticationManager authenticationManager(UserDetailsService uds, PasswordEncoder encoder) {
    DaoAuthenticationProvider proveedor = new DaoAuthenticationProvider(uds);
    proveedor.setPasswordEncoder(encoder);
    return new ProviderManager(proveedor);
}

Y en ApiExceptionHandler (módulo 20) convertimos los fallos de autenticación en un ProblemDetail 401:

/** Credenciales incorrectas o cuenta desactivada en /api/auth/login (módulo 22). */
@ExceptionHandler(AuthenticationException.class)
public ProblemDetail noAutenticado(AuthenticationException ex) {
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED,
            ex instanceof DisabledException ? "La cuenta está desactivada" : "Usuario o contraseña incorrectos");
    pd.setTitle("No autenticado");
    return pd;
}

Dos cadenas de filtros

La API necesita una configuración muy distinta de la web: sin sesión, sin CSRF, sin formulario y con tokens. Lo más limpio es una segunda SecurityFilterChain limitada a /api/** con securityMatcher, y con más prioridad (@Order(1)) que la de la web (@Order(2)):

@Bean
@Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http, JwtAuthenticationConverter jwtConverter)
        throws Exception {
    http
        .securityMatcher("/api/**")                       // esta cadena solo atiende la API
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
            .requestMatchers(HttpMethod.GET, "/api/productos/**", "/api/categorias/**").permitAll()
            .requestMatchers(HttpMethod.GET, "/api/**").hasAnyRole("ADMIN", "EMPLEADO")
            .requestMatchers(HttpMethod.POST, "/api/pedidos").hasAnyRole("ADMIN", "EMPLEADO")
            .anyRequest().hasRole("ADMIN"))
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .csrf(csrf -> csrf.disable())                     // sin cookies de sesión no hay CSRF
        .cors(Customizer.withDefaults())                  // usa el bean CorsConfigurationSource
        .oauth2ResourceServer(rs -> rs.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)));
    return http.build();
}
Línea Efecto
securityMatcher("/api/**") La cadena solo se aplica a esas rutas; el resto sigue hacia la cadena web
SessionCreationPolicy.STATELESS No se crea ni se usa sesión HTTP: cada petición se autentica con su token
csrf.disable() Correcto solo porque no hay cookies de sesión
oauth2ResourceServer(...jwt...) Añade BearerTokenAuthenticationFilter, que usa nuestro bean JwtDecoder
anyRequest().hasRole("ADMIN") Todo lo que modifica datos (salvo crear pedidos) es solo para ADMIN

💡 Consejo: Con dos cadenas, el orden es vital: la cadena con securityMatcher más específico debe tener el @Order menor. Una cadena sin securityMatcher atiende cualquier petición, así que si la de la web fuera la primera, la de la API no se usaría nunca.

Probando la API con curl

Login correcto, incorrecto y con la cuenta desactivada de Tomás (salida real):

curl -i -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{"username":"carmen","password":"carmen2026"}'
HTTP/1.1 200 
Content-Type: application/json

{"token":"eyJraWQiOiJqdFBkZlZ2alRNdEs0Z1piTVRENG94Z050Y0FLMjdwTklqXzFyQXVycEhVIiwiYWxnIjoiSFMyNTYifQ.eyJpc3MiOiJlbXByZXNhLWFwcCIsInN1YiI6ImNhcm1lbiIsImVtcCI6MTEwLCJleHAiOjE3OTExMjc0ODAsImlhdCI6MTc5MTEyMzg4MCwicm9sZXMiOlsiRU1QTEVBRE8iXX0.b_OTs9mwBlLpICPg2t929oL_id0B6BM0dQsjXizqJIk","tipo":"Bearer","expiraEn":3600}
$ curl -i -X POST .../api/auth/login -d '{"username":"carmen","password":"otra"}' ...
HTTP/1.1 401 
Content-Type: application/problem+json

{"detail":"Usuario o contraseña incorrectos","instance":"/api/auth/login","status":401,"title":"No autenticado"}

$ curl -X POST .../api/auth/login -d '{"username":"tomas","password":"tomas2026"}' ...
{"detail":"La cuenta está desactivada","instance":"/api/auth/login","status":401,"title":"No autenticado"}

Guardamos el token en una variable (en Bash) y lo usamos:

TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" \
        -d '{"username":"carmen","password":"carmen2026"}' | sed 's/.*"token":"\([^"]*\)".*/\1/')
curl -i http://localhost:8080/api/pedidos/5001 -H "Authorization: Bearer $TOKEN"

Los casos posibles, con su salida real (solo la línea de estado y las cabeceras relevantes):

### sin token
HTTP/1.1 401 
WWW-Authenticate: Bearer resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"
### con token carmen
HTTP/1.1 200 
Content-Type: application/json
### token manipulado (una letra más al final)
HTTP/1.1 401 
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"
### carmen borra producto (DELETE /api/productos/14)
HTTP/1.1 403 
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
### carmen cambia estado (PATCH /api/pedidos/5022/estado)
HTTP/1.1 403 
### carmen crea pedido (POST /api/pedidos)
HTTP/1.1 201 
Location: http://localhost:8080/api/pedidos/5025

📘 Concepto: El parámetro resource_metadata de la cabecera WWW-Authenticate es novedad de Spring Security 7: implementa el RFC 9728 (OAuth 2.0 Protected Resource Metadata), según el cual el recurso protegido publica en /.well-known/oauth-protected-resource su descripción, para que un cliente sepa dónde conseguir un token. Lo atiende el filtro OAuth2ProtectedResourceMetadataFilter que viste en la lista de la sección 2. Ojo: en empresa-app esa dirección no empieza por /api/, así que la atiende la cadena web y redirige al login (lo comprobamos: 302). Si quisieras publicarla, añadirías "/.well-known/**" al securityMatcher de la cadena de la API.

Caducidad, revocación y RSA

Tema Qué saber
Caducidad Un JWT es válido hasta su exp aunque el usuario cierre sesión: usa duraciones cortas (de 15 minutos a 1 hora)
Revocación No hay forma directa de «anular» un JWT ya emitido. Si hace falta, se guarda una lista negra (por ejemplo, en Redis) o se usan tokens opacos
Refresh tokens Un token de larga duración que solo sirve para pedir nuevos JWT; lo gestionan bien los servidores de autorización (opción A)
Clave RSA Con NimbusJwtEncoder.withKeyPair(publica, privada) se firma con la privada y cualquiera puede verificar con la pública (NimbusJwtDecoder.withPublicKey(...) o spring.security.oauth2.resourceserver.jwt.public-key-location). Útil si otros servicios deben validar tus tokens sin poder emitirlos
Dónde lo guarda el frontend En memoria es lo más seguro; localStorage es vulnerable si la web tiene un fallo XSS

11. CORS con Spring Security

En el módulo 20 configuramos CORS en WebConfig con addCorsMappings. Con Spring Security hay un problema: la petición de comprobación OPTIONS (preflight) del navegador no lleva el token, y la cadena de seguridad la rechazaría con 401 antes de llegar a Spring MVC. La solución es configurar CORS en la cadena de seguridad con .cors(...), que coloca el CorsFilter antes de la autenticación y responde a los preflight.

Movemos la configuración a un bean CorsConfigurationSource en SeguridadConfig (y la quitamos de WebConfig, para no tenerla duplicada):

/** CORS para un frontend en otro origen (Vite, React, Angular...). */
@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration cors = new CorsConfiguration();
    cors.setAllowedOrigins(List.of("http://localhost:5173", "http://localhost:3000"));
    cors.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
    cors.setAllowedHeaders(List.of("Authorization", "Content-Type"));
    cors.setExposedHeaders(List.of("Location"));
    cors.setMaxAge(3600L);
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", cors);
    return source;
}

.cors(Customizer.withDefaults()) en la cadena de la API busca automáticamente este bean. Fíjate en que ahora permitimos expresamente la cabecera Authorization. Salida real de los preflight:

$ curl -i -X OPTIONS http://localhost:8080/api/pedidos -H "Origin: http://localhost:5173" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: authorization,content-type"
HTTP/1.1 200 
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Expose-Headers: Location
Access-Control-Max-Age: 3600

$ curl -i -X OPTIONS http://localhost:8080/api/pedidos -H "Origin: http://malicioso.example" -H "Access-Control-Request-Method: POST"
HTTP/1.1 403 

Invalid CORS request

El preflight de un origen permitido responde 200 aunque /api/pedidos exija token: el CorsFilter lo atiende antes que BearerTokenAuthenticationFilter.


12. Swagger UI con autenticación Bearer

Con la API protegida, Swagger UI necesita poder enviar el token. Se declara un esquema de seguridad en el bean OpenAPI del módulo 20:

@Bean
public OpenAPI empresaOpenApi(EmpresaProperties props) {
    return new OpenAPI()
            // Esquema de seguridad "bearer-jwt": Swagger UI mostrará el botón Authorize
            .components(new Components().addSecuritySchemes("bearer-jwt", new SecurityScheme()
                    .type(SecurityScheme.Type.HTTP)
                    .scheme("bearer")
                    .bearerFormat("JWT")
                    .description("Token obtenido en POST /api/auth/login")))
            .addSecurityItem(new SecurityRequirement().addList("bearer-jwt"))   // se aplica a todas las operaciones
            .info(new Info()
                    .title("API de " + props.nombre())
                    ...);
}
  • SecurityScheme (de io.swagger.v3.oas.models.security) describe cómo se autentica: HTTP con esquema bearer.
  • addSecurityItem(...) lo exige en todas las operaciones (aparece un candado en cada una). La de login lo anula con @SecurityRequirements vacía, como vimos en AuthApiController.
  • Las rutas /swagger-ui/** y /v3/api-docs/** están permitidas en la cadena web (no empiezan por /api/).

El documento OpenAPI generado incluye ahora (extracto real de /v3/api-docs):

"securitySchemes":{"bearer-jwt":{"type":"http","description":"Token obtenido en POST /api/auth/login","scheme":"bearer","bearerFormat":"JWT"}}

En Swagger UI, ejecuta primero POST /api/auth/login, copia el token de la respuesta, pulsa Authorize y pégalo (sin la palabra Bearer, que añade Swagger UI):

Swagger UI de empresa-app con el diálogo Available authorizations abierto: esquema bearer-jwt (http, Bearer) con la descripción Token obtenido en POST /api/auth/login y el token pegado en el campo Value Figura 15. Botón Authorize de Swagger UI con el esquema bearer-jwt. Captura real.

Desde ese momento, cada petición lleva la cabecera. Con el token de Ana, GET /api/empleados/120 responde 200 (Carmen recibiría 403):

Operación GET /api/empleados/{id} ejecutada con id 120: el curl generado incluye la cabecera Authorization Bearer y la respuesta 200 muestra los datos de Pablo Moreno Herrera Figura 16. Petición autenticada desde Swagger UI: el curl muestra la cabecera Authorization. Captura real.

⚠️ Importante: En producción desactivamos Swagger UI (springdoc.swagger-ui.enabled=false en application-prod.properties, módulo 23). Si la necesitas en un entorno compartido, protégela al menos con el rol ADMIN.


13. Actuator y la seguridad

Los endpoints de Actuator pasan por la cadena web. Nuestras reglas dejan públicos health e info (los usan las herramientas de monitorización y Docker) y reservan el resto a ADMIN. Salida real:

$ curl -i http://localhost:8080/actuator/health
HTTP/1.1 200
{"components":{"db":{"details":{"database":"MySQL","validationQuery":"isValid()"...

$ curl -i http://localhost:8080/actuator/env
HTTP/1.1 302
Location: http://localhost:8080/login

En desarrollo, health muestra detalles (show-details=always, módulo 18): versión de la base de datos, espacio en disco… En producción no hay que regalar esa información: application-prod.properties ya tiene management.endpoint.health.show-details=never. Una alternativa intermedia es when-authorized, que solo muestra los detalles a usuarios autenticados (y, con management.endpoint.health.roles=ADMIN, solo a ADMIN).


14. Buenas prácticas de seguridad

Práctica Cómo en empresa-app
Nunca guardar contraseñas en claro BCrypt en la columna password; toString() sin contraseña
HTTPS siempre fuera de tu máquina Lo normal es terminar TLS en un proxy (Nginx, el balanceador de la nube); Spring Boot también puede servir HTTPS con server.ssl.* o los SSL bundles. Spring Security añade Strict-Transport-Security (HSTS) en las respuestas HTTPS
Secretos fuera del código y de Git JWT_SECRET, DB_PASSWORD por variables de entorno (perfil prod); .env en .gitignore (módulo 23)
Mínimo privilegio Por defecto todo exige autenticación (anyRequest()), y lo que modifica exige ADMIN
Defensa en profundidad Reglas por URL + @PreAuthorize en servicios + validación de datos
Mensajes de error genéricos «Usuario o contraseña incorrectos»; sin trazas al usuario
Proteger contra fuerza bruta Limitar intentos de login (ejercicio propuesto), CAPTCHA, bloqueo temporal
Contraseñas robustas Longitud mínima y comprobar que no estén filtradas: HaveIBeenPwnedRestApiPasswordChecker de Spring Security con setCompromisedPasswordChecker(...) del DaoAuthenticationProvider
Mantener las dependencias al día Spring Boot publica versiones con parches de seguridad; herramientas como Dependabot o OWASP Dependency-Check avisan de vulnerabilidades
No exponer de más Actuator mínimo en producción, Swagger UI desactivada, server.error.include-stacktrace=never (por defecto)

💡 Consejo: Revisa la lista OWASP Top 10 (fallos de control de acceso, fallos criptográficos, inyección, configuración insegura…). Casi todo lo que hemos hecho en este módulo responde a alguno de sus puntos, y las consultas parametrizadas de JDBC y JPA de los módulos 12-16 protegen contra la inyección SQL.


15. Ejercicios

Ejercicio 1: Hashes de contraseñas

Escribe un programa que cifre ana2026 dos veces con BCryptPasswordEncoder, compruebe si los dos hashes son iguales, verifique la contraseña correcta y una con una mayúscula cambiada, mida cuánto tarda cifrar con coste 10, 12 y 14, y muestre el resultado de un DelegatingPasswordEncoder.

Ver solución
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

/** Ejercicio 1 (módulo 22): generar y comprobar hashes de contraseñas. */
public class Hashes {

    public static void main(String[] args) {
        PasswordEncoder bcrypt = new BCryptPasswordEncoder();          // coste 10 por defecto

        String h1 = bcrypt.encode("ana2026");
        String h2 = bcrypt.encode("ana2026");
        System.out.println("Hash 1: " + h1);
        System.out.println("Hash 2: " + h2);
        System.out.println("¿Son iguales los hashes? " + h1.equals(h2));
        System.out.println("matches(ana2026, h1)  -> " + bcrypt.matches("ana2026", h1));
        System.out.println("matches(ana2026, h2)  -> " + bcrypt.matches("ana2026", h2));
        System.out.println("matches(Ana2026, h1)  -> " + bcrypt.matches("Ana2026", h1));

        // ¿Cuánto tarda cada coste? (cada +1 duplica el tiempo)
        for (int coste : new int[] {10, 12, 14}) {
            PasswordEncoder enc = new BCryptPasswordEncoder(coste);
            long t0 = System.nanoTime();
            enc.encode("ana2026");
            System.out.printf("Coste %d: %d ms%n", coste, (System.nanoTime() - t0) / 1_000_000);
        }

        // El codificador "delegante" añade el algoritmo como prefijo: permite migrar de algoritmo
        PasswordEncoder delegante = PasswordEncoderFactories.createDelegatingPasswordEncoder();
        String h3 = delegante.encode("ana2026");
        System.out.println("Delegante: " + h3);
        System.out.println("matches -> " + delegante.matches("ana2026", h3));
    }
}

Se ejecuta con el JAR spring-security-crypto (y spring-core) en el classpath, por ejemplo con java -cp ... Hashes.java. Salida real:

Hash 1: $2a$10$VZ8uMCYE8Dt127Y1eFWWrustuhEvNcVfwgTyVqkrzs.qZA3k2rShC
Hash 2: $2a$10$LQrUhOsa/uANRbeEkjrOxevLwSVJjI8MMxzUUMxNZ05ySx1HE48kC
¿Son iguales los hashes? false
matches(ana2026, h1)  -> true
matches(ana2026, h2)  -> true
matches(Ana2026, h1)  -> false
Coste 10: 137 ms
Coste 12: 478 ms
Coste 14: 2251 ms
Delegante: {bcrypt}$2a$10$RZQNLrac9KULgPWsKRfvYeGIr0.Gsa7o9QBqauoEHGfgoM5IaobDm
matches -> true

Los dos hashes son distintos porque cada uno lleva su propia sal aleatoria, y sin embargo ambos verifican: matches extrae la sal del hash guardado y repite el cálculo. Por eso dos usuarios con la misma contraseña no tienen el mismo hash, y las tablas precalculadas (rainbow tables) no sirven. El tiempo crece de forma exponencial con el coste: elige el mayor que tu servidor soporte sin que el login resulte lento (unos 250-500 ms es razonable). El primer cálculo incluye además el calentamiento de la JVM.

Ejercicio 2: Página de usuarios solo para ADMIN

Crea la página /admin/usuarios con una tabla de los usuarios (nombre de usuario, empleado, roles y si está activo), visible solo para ADMIN, y añade un enlace en el menú del usuario. Comprueba con una prueba que Ana la ve y Carmen recibe un 403.

Ver solución

Un método nuevo en el repositorio, con los roles y el empleado en la misma consulta:

// Ejercicio 2 (módulo 22): listado de usuarios con sus roles y empleado
@EntityGraph(attributePaths = {"roles", "empleado"})
List<Usuario> findAllByOrderByUsername();

El controlador:

/** Ejercicio 2 (módulo 22): listado de usuarios, solo para ADMIN (regla /admin/** en SeguridadConfig). */
@Controller
@RequestMapping("/admin")
public class AdminController {

    private final UsuarioRepository usuarios;

    public AdminController(UsuarioRepository usuarios) {
        this.usuarios = usuarios;
    }

    @GetMapping("/usuarios")
    @Transactional(readOnly = true)
    public String usuarios(Model model) {
        model.addAttribute("usuarios", usuarios.findAllByOrderByUsername());
        return "admin/usuarios";
    }
}

La regla, en la cadena web, antes de anyRequest():

.requestMatchers("/admin/**").hasRole("ADMIN")          // ejercicio 2

La plantilla templates/admin/usuarios.html (la parte central):

<tr th:each="u : ${usuarios}">
    <td><code th:text="${u.username}">ana</code></td>
    <td th:text="${u.empleado?.nombreCompleto} ?: '—'">Ana García López</td>
    <td><span class="badge text-bg-primary me-1" th:each="r : ${u.roles}" th:text="${r.nombre}">ADMIN</span></td>
    <td class="text-center">
        <i th:class="${u.activo} ? 'bi bi-check-circle-fill text-success' : 'bi bi-x-circle-fill text-danger'"></i>
    </td>
</tr>

Y en el menú desplegable del layout:

<li sec:authorize="hasRole('ADMIN')"><a class="dropdown-item" th:href="@{/admin/usuarios}">
    <i class="bi bi-shield-lock"></i> Usuarios</a></li>

La prueba (con las técnicas del módulo 23) usa como usuario nuestro propio UsuarioAutenticado:

@Test
void ej2AdminVeLaPaginaDeUsuarios() {
    when(usuarios.findAllByOrderByUsername()).thenReturn(List.of());
    assertThat(mvc.get().uri("/admin/usuarios").with(user(ANA)))
            .hasStatusOk().hasViewName("admin/usuarios");
}

@Test
void ej2EmpleadoRecibe403() {
    assertThat(mvc.get().uri("/admin/usuarios").with(user(CARMEN))).hasStatus(HttpStatus.FORBIDDEN);
}

Las dos pasan (Tests run: 5, Failures: 0 en la clase Ejercicios22Test, que comprueba también los ejercicios 3 y 5). La página se ve en el módulo 24, donde forma parte del proyecto final. El @Transactional(readOnly = true) del controlador no es imprescindible aquí (el @EntityGraph ya trae todo), pero deja claro que la consulta se hace en una transacción de solo lectura.

Ejercicio 3: Cada empleado registra sus propios pedidos

En la API, un EMPLEADO solo puede crear pedidos a su nombre (el empleadoId del cuerpo debe ser el suyo); un ADMIN puede registrarlos a nombre de cualquiera. Usa @PreAuthorize con el claim emp del token.

Ver solución

En PedidoApiController:

@PostMapping
// Ejercicio 3 (módulo 22): un EMPLEADO solo puede registrar pedidos a su nombre
@PreAuthorize("hasRole('ADMIN') or #req.empleadoId() == authentication.token.claims['emp']")
public ResponseEntity<PedidoDto> crear(@Valid @RequestBody NuevoPedidoRequest req) {
    ...
}

#req es el parámetro del método y, como NuevoPedidoRequest es un record, su componente se lee con empleadoId(). Pruebas con tokens simulados por jwt() de spring-security-test (Carmen es la empleada 110):

static final String PEDIDO = """
        {"clienteId": 1004, "empleadoId": %d, "lineas": [{"productoId": 9, "cantidad": 1}]}""";

@Test
void ej3EmpleadoCreaPedidoASuNombre() {
    when(pedidos.crear(any(NuevoPedidoRequest.class))).thenReturn(new Pedido(new Cliente("Academia Nova", "B1"), null));
    assertThat(mvc.post().uri("/api/pedidos")
            .with(jwt().jwt(j -> j.claim("emp", 110L)).authorities(new SimpleGrantedAuthority("ROLE_EMPLEADO")))
            .contentType(MediaType.APPLICATION_JSON).content(PEDIDO.formatted(110)))
            .hasStatus(HttpStatus.CREATED);
}

@Test
void ej3NoPuedeCrearloANombreDeOtroYRecibeProblemDetail() {
    var r = mvc.post().uri("/api/pedidos")
            .with(jwt().jwt(j -> j.claim("emp", 110L)).authorities(new SimpleGrantedAuthority("ROLE_EMPLEADO")))
            .contentType(MediaType.APPLICATION_JSON).content(PEDIDO.formatted(111));
    assertThat(r).hasStatus(HttpStatus.FORBIDDEN)
            .hasContentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)
            .bodyJson().extractingPath("$.title").isEqualTo("Acceso denegado");
    System.out.println("403 -> " + r.exchange().getResponse().getHeader("WWW-Authenticate"));
}

Salida real de la segunda prueba:

403 -> Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"

Fíjate en que la regla por URL (POST /api/pedidos permitido a EMPLEADO) deja pasar la petición y es @PreAuthorize quien la rechaza: la URL no conoce el contenido del cuerpo, el método sí. Que el 403 llegue con cuerpo ProblemDetail es mérito del ejercicio 5.

Ejercicio 4: Un claim más y una prueba unitaria del token

Añade al JWT el claim nombre con el nombre completo del empleado (útil para que un frontend lo muestre sin otra petición) y escribe una prueba unitaria, sin arrancar Spring, que genere un token de 30 minutos, lo decodifique y compruebe sus claims.

Ver solución

En TokenService.generar:

if (auth.getPrincipal() instanceof UsuarioAutenticado u && u.empleadoId() != null) {
    claims.claim("emp", u.empleadoId());          // claim propio: id del empleado
    claims.claim("nombre", u.nombreCompleto());   // ejercicio 4: nombre para mostrar en el frontend
}

La prueba crea a mano las mismas piezas que Spring crearía:

/** Ejercicio 4 (módulo 22): prueba unitaria del token, sin arrancar Spring. */
class TokenServiceTest {

    private final JwtProperties props = new JwtProperties(
            "S13XIMlqvCnoMLmQNNiJquI5mt4C4ae0/oxLny4App4=", Duration.ofMinutes(30), "empresa-app");
    private final JwtConfig config = new JwtConfig(props);
    private final TokenService tokens = new TokenService(config.jwtEncoder(), props);
    private final JwtDecoder decoder = config.jwtDecoder();

    @Test
    void elTokenLlevaRolesEmpleadoNombreYCaduca() {
        var carmen = new UsuarioAutenticado("carmen", "x", true,
                List.of(new SimpleGrantedAuthority("ROLE_EMPLEADO")), "Carmen Ruiz Ortega", 110);
        var auth = UsernamePasswordAuthenticationToken.authenticated(carmen, null, carmen.getAuthorities());

        Jwt jwt = decoder.decode(tokens.generar(auth));       // verifica la firma y la caducidad

        System.out.println("Claims: " + jwt.getClaims());
        assertThat(jwt.getSubject()).isEqualTo("carmen");
        assertThat(jwt.getClaimAsStringList("roles")).containsExactly("EMPLEADO");
        assertThat(jwt.getClaimAsString("nombre")).isEqualTo("Carmen Ruiz Ortega");
        assertThat(Duration.between(jwt.getIssuedAt(), jwt.getExpiresAt())).isEqualTo(Duration.ofMinutes(30));
    }
}

Salida real (la prueba tarda 27 ms):

Claims: {sub=carmen, roles=[EMPLEADO], iss=empresa-app, emp=110, exp=2026-10-04T15:36:59Z, iat=2026-10-04T15:06:59Z, nombre=Carmen Ruiz Ortega}

Como JwtConfig y TokenService reciben todo por constructor, se pueden probar como clases normales. Es una de las grandes ventajas de la inyección por constructor (módulo 17).

Ejercicio 5: Errores 401 y 403 de la API en formato ProblemDetail

Por defecto, los 401 y 403 de la API llegan con el cuerpo vacío. Haz que tengan un cuerpo application/problem+json como el resto de errores de la API (módulo 20), sin perder la cabecera WWW-Authenticate.

Ver solución

Una clase que implementa las dos interfaces de Spring Security: AuthenticationEntryPoint (se llama cuando falta autenticación → 401) y AccessDeniedHandler (autenticado pero sin permiso → 403). Primero delega en los manejadores estándar del resource server, que ponen el código y la cabecera, y después escribe el JSON con el JsonMapper de Jackson 3:

/**
 * Ejercicio 5 (módulo 22): respuestas 401 y 403 de la API en formato ProblemDetail (RFC 9457).
 * Primero delega en los manejadores estándar (que ponen el código y la cabecera WWW-Authenticate)
 * y después escribe el cuerpo JSON.
 */
@Component
public class ProblemDetailSeguridad implements AuthenticationEntryPoint, AccessDeniedHandler {

    private final BearerTokenAuthenticationEntryPoint entryPoint = new BearerTokenAuthenticationEntryPoint();
    private final BearerTokenAccessDeniedHandler deniedHandler = new BearerTokenAccessDeniedHandler();
    private final JsonMapper json;

    public ProblemDetailSeguridad(JsonMapper json) {
        this.json = json;
    }

    @Override
    public void commence(HttpServletRequest req, HttpServletResponse res, AuthenticationException ex)
            throws IOException {
        entryPoint.commence(req, res, ex);
        escribir(req, res, HttpStatus.UNAUTHORIZED, "No autenticado",
                "Falta el token de acceso o no es válido");
    }

    @Override
    public void handle(HttpServletRequest req, HttpServletResponse res, AccessDeniedException ex)
            throws IOException {
        deniedHandler.handle(req, res, ex);
        escribir(req, res, HttpStatus.FORBIDDEN, "Acceso denegado",
                "No tienes permiso para realizar esta operación");
    }

    private void escribir(HttpServletRequest req, HttpServletResponse res, HttpStatus estado,
                          String titulo, String detalle) throws IOException {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(estado, detalle);
        pd.setTitle(titulo);
        pd.setInstance(URI.create(req.getRequestURI()));
        res.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
        res.setCharacterEncoding("UTF-8");
        json.writeValue(res.getOutputStream(), pd);
    }
}

Se registra en la cadena de la API:

.oauth2ResourceServer(rs -> rs
    .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter))
    .authenticationEntryPoint(problemas))         // ejercicio 5: 401 con ProblemDetail
.exceptionHandling(e -> e.accessDeniedHandler(problemas));   // ejercicio 5: 403 con ProblemDetail

Prueba y salida real:

@Test
void ej5SinTokenDevuelve401ConProblemDetail() {
    var r = mvc.get().uri("/api/pedidos/5001").exchange();
    assertThat(r).hasStatus(HttpStatus.UNAUTHORIZED)
            .hasContentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)
            .bodyJson().extractingPath("$.detail").isEqualTo("Falta el token de acceso o no es válido");
    System.out.println("401 -> " + r.getResponse().getHeader("WWW-Authenticate"));
}
401 -> Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource"

En la primera versión de la prueba comprobábamos .hasContentType(MediaType.APPLICATION_PROBLEM_JSON) y falló: expected: application/problem+json but was: application/problem+json;charset=UTF-8. Al fijar la codificación, el tipo lleva el parámetro charset; hasContentTypeCompatibleWith compara el tipo sin parámetros. Como esta clase es un @Component del paquete seguridad, las pruebas @WebMvcTest no la cargan solas: hay que añadirla al @Import de la configuración de pruebas (módulo 23).

Ejercicio 6: Auditoría de inicios de sesión

Escribe en el log cada inicio de sesión correcto y cada intento fallido, con el nombre de usuario y el motivo, tanto en el formulario web como en /api/auth/login.

Ver solución

Spring Security publica eventos de aplicación (módulo 17) al autenticar: AuthenticationSuccessEvent y subclases de AbstractAuthenticationFailureEvent (AuthenticationFailureBadCredentialsEvent, AuthenticationFailureDisabledEvent…). Basta con escucharlos:

/** Ejercicio 6 (módulo 22): deja en el log cada inicio de sesión, correcto o fallido. */
@Component
public class AuditoriaSeguridad {

    private static final Logger log = LoggerFactory.getLogger(AuditoriaSeguridad.class);

    @EventListener
    public void correcto(AuthenticationSuccessEvent e) {
        log.info("Inicio de sesión correcto: {}", e.getAuthentication().getName());
    }

    @EventListener
    public void fallido(AbstractAuthenticationFailureEvent e) {
        log.warn("Inicio de sesión FALLIDO: {} ({})", e.getAuthentication().getName(),
                e.getException().getClass().getSimpleName());
    }
}

Hay un detalle: los eventos los publica el ProviderManager a través de un AuthenticationEventPublisher, y el nuestro lo creamos a mano en SeguridadConfig. Hay que conectarle el publisher que Spring Boot ya define como bean:

@Bean
public AuthenticationManager authenticationManager(UserDetailsService uds, PasswordEncoder encoder,
                                                   AuthenticationEventPublisher eventos) {
    DaoAuthenticationProvider proveedor = new DaoAuthenticationProvider(uds);
    proveedor.setPasswordEncoder(encoder);
    ProviderManager manager = new ProviderManager(proveedor);
    manager.setAuthenticationEventPublisher(eventos);   // ejercicio 6: publica éxitos y fallos
    return manager;
}

Salida real durante la prueba de integración del módulo 23, que usa /api/auth/login (un intento con contraseña errónea y varios correctos):

WARN  ... e.c.e.seguridad.AuditoriaSeguridad : Inicio de sesión FALLIDO: carmen (BadCredentialsException)
INFO  ... e.c.e.seguridad.AuditoriaSeguridad : Inicio de sesión correcto: carmen
INFO  ... e.c.e.seguridad.AuditoriaSeguridad : Inicio de sesión correcto: carmen

Y con el formulario web (Pablo se equivoca una vez y después entra; salida real):

WARN  ... [nio-8080-exec-3] e.c.e.seguridad.AuditoriaSeguridad : Inicio de sesión FALLIDO: pablo (BadCredentialsException)
INFO  ... [nio-8080-exec-7] e.c.e.seguridad.AuditoriaSeguridad : Inicio de sesión correcto: pablo

Sin la línea setAuthenticationEventPublisher, el ProviderManager usa un publicador vacío y no se registra nada: es un error frecuente al declarar tu propio AuthenticationManager. En producción, estos mensajes alimentan alertas (muchos fallos seguidos del mismo usuario o IP = posible ataque de fuerza bruta).

Ejercicio 7 (propuesto): Cambiar la contraseña

Añade a «Mi perfil» un formulario para cambiar la contraseña (actual, nueva y repetición). Comprueba la actual con PasswordEncoder.matches, exige al menos 10 caracteres, guarda el nuevo hash y muestra un mensaje flash. ¿Qué pasa con la sesión abierta en otros navegadores?

Ejercicio 8 (propuesto): Bloqueo tras intentos fallidos

Añade a la tabla usuarios una columna intentos_fallidos y, apoyándote en los eventos del ejercicio 6, bloquea la cuenta (isAccountNonLocked() == false) tras cinco fallos seguidos; desbloquéala al cabo de 15 minutos o cuando un ADMIN lo haga desde /admin/usuarios.

Ejercicio 9 (propuesto): «Recordarme» persistente

Sustituye el remember-me basado en cookie firmada por la versión persistente con JdbcTokenRepositoryImpl (tabla persistent_logins). Explica qué ventaja tiene cuando un usuario cambia su contraseña o quieres cerrar sus sesiones.

Ejercicio 10 (propuesto): Tokens firmados con RSA

Cambia la firma HS256 por RS256: genera un par de claves con openssl (o KeyPairGenerator en las pruebas), firma con NimbusJwtEncoder.withKeyPair(...) y valida con la clave pública (spring.security.oauth2.resourceserver.jwt.public-key-location). Publica la clave pública en /api/auth/jwks como JWK Set.

Ejercicio 11 (propuesto): Contraseñas comprometidas

Configura HaveIBeenPwnedRestApiPasswordChecker en el DaoAuthenticationProvider (setCompromisedPasswordChecker) y en el cambio de contraseña del ejercicio 7, para impedir contraseñas que aparecen en filtraciones conocidas. ¿Qué ocurre si la aplicación no tiene acceso a Internet?

Ejercicio 12 (propuesto): Iniciar sesión con GitHub

Añade spring-boot-starter-security-oauth2-client y configura oauth2Login() en la cadena web para entrar con una cuenta de GitHub (registra una OAuth App en GitHub). Asocia el correo de GitHub con un empleado de EMPRESA para asignarle su rol.


Resumen

Concepto Sintaxis / ejemplo
Dependencia spring-boot-starter-security (Spring Security 7.1); spring-boot-starter-security-test para pruebas
Autenticación / autorización ¿Quién eres? (401) / ¿Puedes hacerlo? (403)
Cadena de filtros DelegatingFilterProxy → FilterChainProxy → SecurityFilterChain → DispatcherServlet
Configuración @Bean SecurityFilterChain filterChain(HttpSecurity http) con lambdas
Reglas por URL authorizeHttpRequests(a -> a.requestMatchers("/x/**").hasRole("ADMIN").anyRequest().authenticated())
Login y logout formLogin(f -> f.loginPage("/login")), logout(l -> l.logoutSuccessUrl("/login?salir")), httpBasic(...)
Contraseñas BCryptPasswordEncoder, PasswordEncoderFactories.createDelegatingPasswordEncoder()
Usuarios InMemoryUserDetailsManager o un UserDetailsService propio con loadUserByUsername
Principal UserDetails propio (UsuarioAutenticado), @AuthenticationPrincipal
Por método @EnableMethodSecurity + @PreAuthorize("hasRole('ADMIN') or #id == ...")
Thymeleaf thymeleaf-extras-springsecurity6: sec:authorize, sec:authentication, ${#authentication}
CSRF Token automático con th:action; ${_csrf.token}; desactivar solo en APIs sin estado
JWT spring-boot-starter-security-oauth2-resource-server, NimbusJwtEncoder/NimbusJwtDecoder, oauth2ResourceServer(rs -> rs.jwt(...))
Dos cadenas @Order(1) + securityMatcher("/api/**") (sin estado) y @Order(2) para la web
CORS @Bean CorsConfigurationSource + .cors(Customizer.withDefaults())
Swagger UI SecurityScheme HTTP bearer + SecurityRequirement; @SecurityRequirements vacío en el login
Buenas prácticas HTTPS, secretos en variables de entorno, mensajes genéricos, mínimo privilegio, dependencias al día