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,PasswordEncoderySecurityContextHolder - Saber qué ocurre al añadir
spring-boot-starter-securitya 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
usuariosyroles), con unUserDetailsServicepropio y contraseñas cifradas con BCrypt - Autorizar por URL y por método (
@PreAuthorizecon@EnableMethodSecurity) con los rolesADMINyEMPLEADO - 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
POSTdeempresa-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 prefijoROLE_: cuando escribeshasRole("ADMIN"), Spring comprueba la authorityROLE_ADMIN; conhasAuthority("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.
 *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:
Figura 2. Autenticación con usuario y contraseña.
- El filtro (
UsernamePasswordAuthenticationFilterpara el formulario web, o nuestro controlador/api/auth/login) recibe usuario y contraseña. - El
AuthenticationManager(implementaciónProviderManager) prueba sus proveedores; el de usuario y contraseña esDaoAuthenticationProvider. - El proveedor pide el usuario al
UserDetailsService. - Compara la contraseña escrita con el hash guardado mediante el
PasswordEncoder, y comprueba que la cuenta esté activa. - Si todo va bien, devuelve un
Authenticationautenticado con el principal y sus authorities. - 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 principalo@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:
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:
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 ponesrequestMatchers("/empleados/**").authenticated()antes derequestMatchers("/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 antiguoand(),authorizeRequests()y los matchersAntPathRequestMatcheryMvcRequestMatcher: las rutas se comparan conPathPatternRequestMatcher, el mismo sistema de patrones que usa Spring MVC. Si encuentras en Internet ejemplos con.and()o conWebSecurityConfigurerAdapter, 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.
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 implementaisEnabled(). Fíjate en eltoString(): 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
UsernameNotFoundExceptionenBadCredentialsException.
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.
Figura 6. Formulario de login propio con Thymeleaf y Bootstrap. Captura real.
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>
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_PASSWORDcuando 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
empllega del JSON comoLongy el parámetroidesInteger. 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)esfalse: 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@Transactionalque 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
6del 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>
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:
Figura 10. El listado de empleados visto por Ana (ADMIN). Captura real.
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/editara mano o enviar elPOSTconcurl. La protección real son las reglas deSeguridadConfigy los@PreAuthorizede los servicios;sec:authorizesolo 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";
}
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
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 cookieXSRF-TOKENlegible 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.
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. Traespring-security-oauth2-resource-serveryspring-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 haywithKeyPair(rsaPublica, rsaPrivada)para RSA y para curvas elípticas). En versiones anteriores había que montar a mano unJWKSourcede Nimbus. Por defecto, el convertidor del resource server lee el claimscopey crea authoritiesSCOPE_...; como nosotros usamos roles, le indicamos el claimrolesy el prefijoROLE_.
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 deJwtEncoderParameters.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
securityMatchermás específico debe tener el@Ordermenor. Una cadena sinsecurityMatcheratiende 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_metadatade la cabeceraWWW-Authenticatees 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-resourcesu descripción, para que un cliente sepa dónde conseguir un token. Lo atiende el filtroOAuth2ProtectedResourceMetadataFilterque viste en la lista de la sección 2. Ojo: enempresa-appesa 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/**"alsecurityMatcherde 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(deio.swagger.v3.oas.models.security) describe cómo se autentica: HTTP con esquemabearer.addSecurityItem(...)lo exige en todas las operaciones (aparece un candado en cada una). La de login lo anula con@SecurityRequirementsvacía, como vimos enAuthApiController.- 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):
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):
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=falseenapplication-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 |