En 05-01 añadimos una dependencia y la API de CicloUrbana quedó cerrada a cal y canto: un usuario llamado user, una contraseña que cambia en cada arranque, un formulario HTML que a una aplicación móvil no le sirve de nada y un 403 por CSRF cada vez que intentamos crear una estación. Es seguro, pero es inútil. Esta lección convierte ese andamio en una configuración deliberada.
Vamos a escribir la primera clase real del paquete com.ciclourbana.seguridad: ConfiguracionSeguridad, con su bean SecurityFilterChain. Aprenderemos la DSL de lambdas de Spring Security 6 apartado por apartado, definiremos el mapa completo de accesos de la red de Ribalta, entenderemos por qué el orden de las reglas es lo que más se equivoca todo el mundo, y tomaremos con criterio —no por costumbre ni por copiar de internet— las cuatro decisiones que definen el carácter de la API: contraseñas, CSRF, sesión y cabeceras. Al terminar, CicloUrbana tendrá cerraduras de verdad, aunque las llaves sigan siendo provisionales hasta 05-03.
Advertencia. Todos los usuarios, contraseñas y orígenes de esta lección son ficticios y sirven para el ejemplo. Las contraseñas en claro que aparecen aquí solo son admisibles en un ejemplo didáctico ejecutado en local; nunca se escriben en un fichero versionado. Toda configuración de seguridad debe ser revisada por un profesional de seguridad antes de exponerse a Internet.
Contenido
- El modelo de componentes de Spring Security 6
ConfiguracionSeguridad: la primera clase de.seguridad- La DSL de lambdas, apartado por apartado
authorizeHttpRequestsyrequestMatchers- La regla de oro del orden de las reglas
- El mapa de accesos de CicloUrbana
- Usuarios en memoria con
InMemoryUserDetailsManager - Codificación de contraseñas
- HTTP Basic y form login
- CSRF: qué es y cuándo se puede desactivar
- Gestión de sesión:
STATELESS - Integrar la configuración CORS de 03-02
- Cabeceras de seguridad de la respuesta
- Varias cadenas con
@OrderysecurityMatcher - Depurar la seguridad
- Errores Comunes y Consejos
- Ejercicios
- El modelo de componentes de Spring Security 6
Si buscas ejemplos de Spring Security en internet, la mitad de lo que encontrarás no compila. El motivo es un cambio de modelo:
// Spring Security 5 y anteriores — ELIMINADO en la versión 6. No lo uses.
@Configuration
public class ConfiguracionSeguridad extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception { ... }
}WebSecurityConfigurerAdapter quedó obsoleto en Spring Security 5.7 y se eliminó en la 6.0. Si intentas extenderlo con Spring Boot 3.x, el código ni siquiera compila. La sustitución no es cosmética: es un cambio de filosofía, de herencia a composición.
| Aspecto | Modelo antiguo (herencia) | Modelo actual (beans) |
|---|---|---|
| Punto de extensión | Extender una clase y sobrescribir métodos | Declarar beans |
| Varias cadenas | Varias clases internas, orden confuso | Varios beans SecurityFilterChain con @Order |
Personalizar el AuthenticationManager |
Sobrescribir un método protegido | Declarar un bean o exponerlo desde AuthenticationConfiguration |
| Comprobar qué hay configurado | Difícil: estado heredado | Fácil: los beans están a la vista |
| Encaja con el resto de Spring Boot | Regular | Igual que cualquier otra configuración |
Tres razones del cambio: la herencia obligaba a un objeto con estado cuyo comportamiento dependía de qué métodos se hubieran sobrescrito; no componía bien, porque dos configuraciones exigían clases internas con reglas de ordenación poco evidentes; y era incoherente con el resto de Spring Boot, donde todo se configura declarando beans (02-01).
La consecuencia práctica es que toda la configuración de seguridad de CicloUrbana serán beans en una clase @Configuration normal, exactamente igual que ConfiguracionCors (03-02) o ConfiguracionOpenApi (03-07).
ConfiguracionSeguridad: la primera clase de .seguridad
ConfiguracionSeguridad: la primera clase de .seguridadpackage com.ciclourbana.seguridad;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class ConfiguracionSeguridad {
@Bean
SecurityFilterChain cadenaFiltros(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/estaciones/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());
return http.build();
}
}Cuatro elementos que conviene entender uno a uno:
@Configuration es una clase de configuración normal (02-01); nada especial.
@EnableWebSecurity. Importa la configuración de la seguridad web. Con Spring Boot es opcional, porque la autoconfiguración ya la aplica, pero se pone por dos motivos: hace explícito que esa clase gobierna la seguridad, y es imprescindible cuando se quiere activar el modo de depuración (@EnableWebSecurity(debug = true), apartado 15).
HttpSecurity inyectado como parámetro es un constructor de cadenas con ámbito prototipo: Spring entrega una instancia nueva por cada bean SecurityFilterChain, ya preconfigurada. Nunca lo guardes en un campo ni lo compartas entre métodos.
http.build() construye la SecurityFilterChain con los filtros correspondientes a lo configurado.
El efecto de declarar este bean es total: sustituye por completo la configuración por defecto de Spring Boot. El usuario user con contraseña generada desaparece del log, el formulario de login desaparece y solo queda lo que escribas. Es un interruptor de todo o nada: no se «añaden» reglas a las de por defecto, se reemplazan.
- La DSL de lambdas, apartado por apartado
HttpSecurity ofrece un método por cada aspecto configurable, y cada uno recibe una lambda que lo personaliza. Este es el esqueleto completo con el que trabajaremos:
http
.securityMatcher("/api/**") // a qué peticiones aplica esta cadena
.authorizeHttpRequests(auth -> { ... }) // quién puede acceder a qué
.csrf(csrf -> { ... }) // protección anti-CSRF
.cors(Customizer.withDefaults()) // política CORS
.sessionManagement(sesion -> { ... }) // política de sesión
.httpBasic(Customizer.withDefaults()) // autenticación HTTP Basic
.formLogin(form -> { ... }) // formulario de login
.logout(salida -> { ... }) // cierre de sesión
.headers(cabeceras -> { ... }) // cabeceras de seguridad
.exceptionHandling(ex -> { ... }) // qué responder ante 401 y 403
.addFilterBefore(filtroPropio, OtroFiltro.class);// insertar filtros propiosTres reglas de uso. Customizer.withDefaults() activa el aspecto con sus valores por defecto. Para desactivar se usa la lambda con disable(): .csrf(csrf -> csrf.disable()), o su forma abreviada .csrf(AbstractHttpConfigurer::disable). Y no llamar a un método no significa desactivarlo: si httpBasic no aparece, se aplica el valor por defecto de esa cadena. En Spring Security 6.1 y posteriores, además, los métodos encadenados sin lambda (.and(), .antMatchers()) están obsoletos o eliminados: la DSL de lambdas es la única forma soportada, y su indentación muestra a simple vista dónde empieza y acaba cada bloque.
authorizeHttpRequests y requestMatchers
authorizeHttpRequests y requestMatchersEs el bloque más importante: define quién accede a qué. Su estructura es siempre una lista de parejas criterio → regla.
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/v1/estaciones/**").permitAll()
.requestMatchers("/api/v1/bicicletas/**").hasRole("OPERARIO")
.anyRequest().authenticated())Formas de requestMatchers
| Forma | Ejemplo | Qué selecciona |
|---|---|---|
| Por patrón | requestMatchers("/api/v1/estaciones/**") |
Cualquier método sobre esas rutas |
| Por método y patrón | requestMatchers(HttpMethod.POST, "/api/v1/alquileres") |
Solo ese verbo |
| Solo por método | requestMatchers(HttpMethod.OPTIONS) |
Cualquier ruta con ese verbo |
| Varios patrones | requestMatchers("/login", "/registro") |
Cualquiera de ellos |
| Matcher propio | requestMatchers(new RegexRequestMatcher(...)) |
Casos que el patrón no cubre |
Los patrones son de tipo PathPattern (el mismo motor de @RequestMapping, 03-02):
| Comodín | Significado | /api/v1/estaciones/1/bicicletas |
|---|---|---|
? |
Un carácter | /api/v1/estacione? no coincide |
* |
Cualquier texto dentro de un segmento | /api/v1/* no coincide |
** |
Cualquier número de segmentos | /api/v1/** sí coincide |
{var} |
Variable de ruta | /api/v1/estaciones/{id}/bicicletas coincide |
La distinción entre * y ** causa muchos agujeros. Escribir requestMatchers("/api/v1/usuarios/*").hasRole("ADMIN") protege /api/v1/usuarios/7, pero no /api/v1/usuarios/7/alquileres, que queda gobernado por la regla siguiente. Ante la duda, **.
Un detalle de Spring Security 6: los antiguos antMatchers y mvcMatchers se unificaron en requestMatchers, que elige la implementación adecuada según haya o no Spring MVC en el classpath.
Reglas de acceso disponibles
| Regla | Significado | Uso típico en CicloUrbana |
|---|---|---|
permitAll() |
Acceso libre, sin autenticación | Consulta pública de estaciones |
authenticated() |
Cualquier usuario autenticado | Alquileres |
hasRole("ADMIN") |
Tiene la autoridad ROLE_ADMIN |
Gestión de estaciones |
hasAnyRole("OPERARIO", "ADMIN") |
Cualquiera de esos roles | Gestión de bicicletas |
hasAuthority("estaciones:escribir") |
Tiene esa autoridad exacta, sin prefijo | Modelo de permisos granulares (05-03) |
hasAnyAuthority(...) |
Cualquiera de esas autoridades | |
denyAll() |
Nadie, nunca | Cerrar rutas peligrosas explícitamente |
anonymous() |
Solo usuarios no autenticados | Un registro que no debe usarse ya conectado |
access(manager) |
Un AuthorizationManager propio |
Reglas complejas |
hasRole("ADMIN") y hasAuthority("ROLE_ADMIN") son equivalentes: el primero añade el prefijo ROLE_ automáticamente. La confusión que genera esto es constante y la desmenuzaremos en 05-03; por ahora basta la regla mecánica: con hasRole nunca escribas el prefijo, con hasAuthority escríbelo siempre si el rol lo lleva.
- La regla de oro del orden de las reglas
Las reglas se evalúan en el orden en que se declaran, y gana la primera que coincide. No la más específica: la primera. Este es el error más frecuente de todo el módulo, y produce agujeros silenciosos.
// ❌ CONFIGURACIÓN ROTA: el listado de usuarios queda abierto a cualquiera
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/**").permitAll() // ← coincide con TODO
.requestMatchers("/api/v1/usuarios/**").hasRole("ADMIN") // ← inalcanzable
.anyRequest().authenticated())Una petición a GET /api/v1/usuarios coincide con la primera regla, que la deja pasar. La segunda no se consulta nunca. Y lo peor es que la aplicación arranca sin ninguna advertencia: los datos personales de todos los ciudadanos de Ribalta quedan públicos y nada lo indica.
// ✅ CORRECTO: de lo más específico a lo más general
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/usuarios/**").hasRole("ADMIN")
.requestMatchers("/api/v1/**").permitAll()
.anyRequest().authenticated())Tres consecuencias prácticas:
- Ordena de específico a general, siempre. Rutas concretas primero, comodines amplios después.
anyRequest()va al final y no puede repetirse. Si aparece antes de otra regla, Spring Security lanza un error al arrancar (Can't configure requestMatchers after anyRequest). Es la única protección que el framework ofrece contra este problema, y solo cubre ese caso.- Termina siempre con
anyRequest().denyAll()oanyRequest().authenticated(). Es la política de denegación por defecto: cualquier endpoint nuevo que alguien añada mañana nace protegido en lugar de nacer abierto. Es la diferencia entre olvidarse de proteger algo (peligroso, silencioso) y olvidarse de abrir algo (molesto, evidente en el acto).
- El mapa de accesos de CicloUrbana
Antes de escribir código, la decisión de negocio. La red de Ribalta tiene tres perfiles: CIUDADANO (alquila bicicletas), OPERARIO (mantiene la flota) y ADMIN (gestiona la red y los usuarios).
| Endpoint | Método | Quién | Justificación |
|---|---|---|---|
/api/v1/estaciones, /api/v1/estaciones/{id} |
GET |
Público | El mapa de estaciones es dato abierto del Ayuntamiento |
/api/v1/estaciones/{id}/bicicletas |
GET |
Público | Saber si hay bicicletas libres antes de registrarse |
/api/v1/estaciones/** |
POST, PUT, PATCH, DELETE |
ADMIN |
Crear o cerrar una estación es una decisión municipal |
/api/v1/bicicletas/** |
GET |
Autenticado | Detalle de flota: batería, incidencias |
/api/v1/bicicletas/** |
Escritura | OPERARIO |
Altas, bajas y cambios de estado los hace mantenimiento |
/api/v1/incidencias/** |
Todos | OPERARIO |
Gestión interna de averías |
/api/v1/alquileres/** |
Todos | Autenticado | Quién puede tocar cuál se decide en 05-05 |
/api/v1/usuarios/** |
Todos | ADMIN |
Datos personales de ciudadanos |
/api/v1/auth/** |
POST |
Público | Registro y login (05-03 y 05-04) |
/swagger-ui/**, /v3/api-docs/** |
GET |
Solo en desarrollo | Se cierra en producción (05-05, 07-02) |
/actuator/health |
GET |
Público | Lo consulta el balanceador (07-01) |
/actuator/** |
GET |
ADMIN |
Métricas y detalles internos |
Y la traducción a código, con las reglas ordenadas de específico a general:
@Bean
SecurityFilterChain cadenaApi(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable()) // ver apartado 10
.cors(Customizer.withDefaults()) // ver apartado 12
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// --- Público: autenticación y consulta abierta de la red ---
.requestMatchers(HttpMethod.POST, "/api/v1/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/estaciones", "/api/v1/estaciones/*",
"/api/v1/estaciones/*/bicicletas").permitAll()
// --- Administración de la red ---
.requestMatchers("/api/v1/usuarios/**").hasRole("ADMIN")
.requestMatchers("/api/v1/estaciones/**").hasRole("ADMIN") // el resto de verbos
// --- Mantenimiento de la flota ---
.requestMatchers("/api/v1/incidencias/**").hasAnyRole("OPERARIO", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/bicicletas/**").authenticated()
.requestMatchers("/api/v1/bicicletas/**").hasAnyRole("OPERARIO", "ADMIN")
// --- Uso del servicio ---
.requestMatchers("/api/v1/alquileres/**").authenticated()
// --- Denegación por defecto ---
.anyRequest().denyAll())
.httpBasic(Customizer.withDefaults());
return http.build();
}Tres detalles del diseño. Las estaciones públicas usan /api/v1/estaciones/* y no /**, para que el comodín de un solo segmento no abra subrecursos futuros por accidente. El GET de bicicletas va antes que la regla general, porque la primera coincidencia gana. Y anyRequest().denyAll() cierra cualquier ruta bajo /api/** que nadie haya clasificado.
hasAnyRole("OPERARIO", "ADMIN") repetido dos veces es un síntoma de que falta una jerarquía de roles: un ADMIN debería poder hacer todo lo de un OPERARIO sin enumerarlo. Lo resolveremos en 05-03 con RoleHierarchy.
- Usuarios en memoria con
InMemoryUserDetailsManager
InMemoryUserDetailsManagerPara probar las reglas necesitamos usuarios con roles, y hasta 05-03 no tendremos base de datos de credenciales. InMemoryUserDetailsManager es un UserDetailsService (05-01) que guarda los usuarios en un mapa:
@Bean
UserDetailsService usuariosEnMemoria(PasswordEncoder codificador) {
UserDetails marta = User.withUsername("[email protected]")
.password(codificador.encode("clave-de-ejemplo-1"))
.roles("CIUDADANO") // → autoridad ROLE_CIUDADANO
.build();
UserDetails luis = User.withUsername("[email protected]")
.password(codificador.encode("clave-de-ejemplo-2")).roles("OPERARIO").build();
UserDetails ana = User.withUsername("[email protected]")
.password(codificador.encode("clave-de-ejemplo-3")).roles("ADMIN").build();
return new InMemoryUserDetailsManager(marta, luis, ana);
}Tres puntos importantes. .roles("CIUDADANO") añade el prefijo ROLE_ automáticamente: escribir .roles("ROLE_CIUDADANO") produce ROLE_ROLE_CIUDADANO y lanza una excepción al arrancar; para autoridades sin prefijo existe .authorities("estaciones:escribir"). User.withDefaultPasswordEncoder() está obsoleto y no debe usarse ni siquiera en ejemplos: anima a dejar contraseñas en el fuente. Y estas contraseñas son ficticias y solo valen en local: en un proyecto real se leerían de variables de entorno. Es andamiaje que desaparecerá en 05-03.
Con esto ya se pueden probar las reglas del apartado 6:
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/api/v1/estaciones
# 200 → público
curl -s -o /dev/null -w '%{http_code}\n' -u [email protected]:clave-de-ejemplo-1 \
http://localhost:8080/api/v1/usuarios # 403 → autenticada, pero no es ADMIN
curl -s -o /dev/null -w '%{http_code}\n' -u [email protected]:clave-de-ejemplo-3 \
http://localhost:8080/api/v1/usuarios # 200 → ADMIN
curl -s -o /dev/null -w '%{http_code}\n' \
http://localhost:8080/api/v1/alquileres/7 # 401 → sin credencialesEsos cuatro códigos son la demostración de que la configuración funciona: 200 público, 401 sin identidad, 403 con identidad insuficiente y 200 con el rol correcto.
- Codificación de contraseñas
La regla es absoluta: una contraseña jamás se almacena de forma que pueda recuperarse. Ni en claro, ni cifrada con una clave que esté en el mismo sistema. Se almacena un hash, y solo se comparan hashes.
Y no vale cualquier hash. MD5 y SHA-256 se diseñaron para ser rápidos, que es justo lo contrario de lo que hace falta aquí: una GPU actual calcula del orden de miles de millones de SHA-256 por segundo, así que un diccionario de contraseñas frecuentes se prueba entero en minutos. Las funciones adecuadas son deliberadamente lentas y llevan sal (un valor aleatorio por contraseña, que impide precalcular tablas y hace que dos usuarios con la misma contraseña tengan hashes distintos).
| Algoritmo | Apto | Notas |
|---|---|---|
| Texto plano | Nunca | Una fuga de base de datos regala todas las cuentas |
| MD5, SHA-1, SHA-256 «a secas» | No | Rápidos por diseño; sin sal por defecto |
| PBKDF2 | Sí | Estándar, aprobado por NIST; el menos resistente a GPU de los tres |
| BCrypt | Sí — elección de CicloUrbana | Maduro, con sal integrada y coste ajustable |
| SCrypt | Sí | Además exige memoria |
| Argon2id | Sí, el más recomendado hoy | Requiere una librería adicional |
CicloUrbana elige BCrypt: es el valor por defecto de Spring Security, no necesita dependencias extra, tiene veinticinco años de escrutinio público y su coste es ajustable.
@Bean
PasswordEncoder codificadorContrasenas() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}Esa fábrica devuelve un DelegatingPasswordEncoder, y merece la pena entender por qué no devolvemos directamente un BCryptPasswordEncoder. Un hash producido por el delegador tiene este aspecto:
{bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
^^^^^^^^ ^^^ ^^ ^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
prefijo ver coste sal (22) hash (31)El prefijo entre llaves identifica el algoritmo con el que se codificó esa contraseña. Gracias a él, el sistema puede verificar contraseñas antiguas con {pbkdf2} mientras codifica las nuevas con {bcrypt}, y migrar de algoritmo sin obligar a nadie a cambiar su contraseña. Es la razón de que este sea el codificador recomendado incluso en proyectos nuevos: hoy no necesitas migrar, pero dentro de cinco años sí.
El error clásico. Si en la base de datos hay un hash sin prefijo —migrado de un sistema anterior—, el delegador no sabe qué algoritmo aplicar y lanza IllegalArgumentException: There is no PasswordEncoder mapped for the id "null". Hay dos salidas: la correcta a largo plazo es prefijar los hashes existentes con una migración Flyway (UPDATE usuarios SET contrasena_hash = '{bcrypt}' || contrasena_hash); la alternativa es indicar al delegador qué hacer cuando falta el prefijo:
@Bean
PasswordEncoder codificadorContrasenas() {
var delegador = (DelegatingPasswordEncoder)
PasswordEncoderFactories.createDelegatingPasswordEncoder();
// Solo durante una migración: los hashes sin prefijo se tratan como BCrypt
delegador.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder());
return delegador;
}El factor de coste. new BCryptPasswordEncoder(12) indica el exponente del número de iteraciones: cada unidad duplica el trabajo. El valor por defecto es 10; la recomendación actual está entre 10 y 12, y el criterio práctico es elegir el mayor coste cuya verificación siga por debajo de unos 250 ms en tu hardware de producción. Es un equilibrio explícito: subirlo encarece el ataque por fuerza bruta, pero también encarece cada inicio de sesión legítimo y puede convertirse en un vector de denegación de servicio si alguien lanza miles de logins.
Dos consejos finales: codificar es lento a propósito, así que hazlo solo al registrar y al validar, nunca en un bucle; y nunca registres una contraseña en claro en un log, ni siquiera depurando (05-05).
- HTTP Basic y form login
.httpBasic(Customizer.withDefaults()) // Authorization: Basic base64(u:p)
.formLogin(Customizer.withDefaults()) // formulario HTML en /login| HTTP Basic | Form login | |
|---|---|---|
| Cómo viaja la credencial | Cabecera Authorization, en cada petición |
POST /login una vez |
| Estado | Sin estado (pero Spring crea sesión igualmente si no se lo impides) | Con sesión y cookie |
| Cliente natural | curl, herramientas, pruebas |
Navegador |
| Fallo | 401 + WWW-Authenticate |
Redirección a /login |
| CSRF | No aplica a la cabecera | Sí aplica |
formLogin admite personalización completa —loginPage("/entrar"), successHandler(...)— y es la opción correcta para una aplicación con vistas en el servidor. CicloUrbana desactivará ambos en 05-04, y conviene entender por qué. Form login devuelve una redirección HTTP 302 a una página HTML: una aplicación móvil que espera JSON no sabe qué hacer con eso. HTTP Basic obliga a que el cliente guarde la contraseña del usuario para enviarla en cada petición, lo cual es exactamente lo que un token evita: con JWT, la contraseña se envía una sola vez y lo que se almacena después es un token caducable y revocable. Hasta entonces, mantenemos httpBasic porque hace muy cómodo probar con curl.
- CSRF: qué es y cuándo se puede desactivar
CSRF (Cross-Site Request Forgery) es un ataque que abusa de una propiedad del navegador: las cookies se envían automáticamente a su dominio, venga la petición de donde venga.
sequenceDiagram
participant U as Navegador de Marta
participant M as sitio-malicioso.example
participant C as CicloUrbana
U->>C: Login → cookie de sesión JSESSIONID
U->>M: Visita una página cualquiera
M-->>U: HTML con un formulario oculto que se autoenvía
U->>C: POST /api/v1/alquileres/7/finalizar<br/>¡con la cookie de Marta!
Note over C: Sin CSRF: la petición parece legítima<br/>Con CSRF: falta el token → 403
La defensa es un token impredecible que el servidor entrega y que el cliente debe reenviar en cada petición que modifique estado. El sitio malicioso no puede leerlo, porque la política del mismo origen del navegador se lo impide.
Spring Security lo activa por defecto para POST, PUT, PATCH y DELETE (los métodos seguros e idempotentes de lectura no lo necesitan, 03-03). Es lo que produjo el 403 del ejercicio 3 de 05-01.
¿Se puede desactivar en CicloUrbana? Sí, pero solo bajo condiciones estrictas, y hay que enunciarlas porque csrf.disable() es la línea que más se copia sin entender de todo Spring Security:
| Condición | CicloUrbana a partir de 05-04 |
|---|---|
| La autenticación no usa cookies ni sesión | ✅ Token Bearer en la cabecera Authorization |
| El navegador no adjunta la credencial automáticamente | ✅ La cabecera la pone el código del cliente, no el navegador |
| No hay formularios HTML servidos por la aplicación | ✅ Es una API JSON pura |
La sesión es STATELESS |
✅ Apartado 11 |
| CORS está restringido a orígenes conocidos | ✅ ConfiguracionCors de 03-02 |
El razonamiento, en una frase: CSRF explota que el navegador envía la credencial sola; si la credencial va en una cabecera que solo el código de tu propia aplicación puede añadir, el ataque no tiene con qué operar.
Advertencia importante. Si más adelante CicloUrbana almacenase el JWT en una cookie —una opción legítima que veremos en 05-04— la condición se rompe: la cookie sí viaja sola y CSRF vuelve a ser necesario. Desactivarlo entonces sería una vulnerabilidad real. La decisión de desactivar CSRF depende de dónde vive la credencial, no de que la API sea REST.
Si hiciera falta mantenerlo activo con un cliente JavaScript, la configuración habitual es publicar el token en una cookie legible con .csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())).
- Gestión de sesión:
STATELESS
STATELESS| Política | Comportamiento |
|---|---|
ALWAYS |
Crea sesión siempre, aunque no haga falta |
IF_REQUIRED |
Por defecto: la crea cuando algo la necesita |
NEVER |
No la crea, pero usa la que exista |
STATELESS |
Ni la crea ni la usa. No hay JSESSIONID |
Con STATELESS, cada petición debe autenticarse por sí misma. No hay cookie de sesión, el SecurityContext no se guarda entre peticiones y todo lo que el servidor sabe del usuario procede de la credencial que acaba de recibir. Es exactamente la restricción stateless de REST que estudiamos en 03-01, aplicada a la seguridad.
Tres consecuencias:
- Escala horizontalmente sin esfuerzo: cualquier instancia atiende cualquier petición, sin sesión pegajosa ni replicación. Importará en el módulo 7.
- CSRF deja de ser necesario (con las condiciones del apartado 10), y HTTP Basic sigue funcionando, porque envía credenciales en cada petición.
- No hay «cerrar sesión» en el servidor: no hay nada que invalidar. El problema se trata en 05-04.
- Integrar la configuración CORS de 03-02
En 03-02 escribimos ConfiguracionCors como un WebMvcConfigurer. Ese componente actúa dentro del DispatcherServlet, y ahora hay filtros de seguridad delante. El resultado es un fallo desconcertante: la petición OPTIONS de sondeo (preflight) que el navegador envía antes de un POST no lleva credenciales —la especificación lo prohíbe—, así que la seguridad la rechaza con 401 antes de que la configuración CORS llegue a responder. El navegador informa entonces de un error de CORS que en realidad es un error de autenticación.
La solución es una línea:
Le dice a Spring Security que registre su CorsFilter dentro de la cadena de seguridad, en una posición anterior a la autorización, usando el CorsConfigurationSource que haya en el contexto. Para que lo encuentre, conviene publicar la política CORS como bean en lugar de solo como WebMvcConfigurer:
@Bean
CorsConfigurationSource fuenteConfiguracionCors() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://panel.ribalta.es", "http://localhost:5173"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Traza-Id"));
config.setExposedHeaders(List.of("Location", "ETag", "X-Traza-Id"));
config.setMaxAge(3600L);
var fuente = new UrlBasedCorsConfigurationSource();
fuente.registerCorsConfiguration("/api/**", config);
return fuente;
}Dos añadidos respecto a 03-02: Authorization entre las cabeceras permitidas, sin la cual el navegador no dejaría enviar el token, y X-Traza-Id expuesta, para mostrar el identificador de traza de 03-06 cuando algo falle. Y la regla de siempre, ahora más importante: nunca * en los orígenes junto con credenciales. El endurecimiento de CORS por entorno se completa en 05-05.
- Cabeceras de seguridad de la respuesta
Spring Security añade por defecto un conjunto de cabeceras que instruyen al navegador. No cuestan nada y evitan familias enteras de ataques.
| Cabecera | Valor por defecto | Para qué sirve |
|---|---|---|
X-Content-Type-Options |
nosniff |
Impide que el navegador adivine el tipo de contenido e interprete como script algo que no lo es |
X-Frame-Options |
DENY |
Impide incrustar la respuesta en un iframe: defensa contra clickjacking |
Cache-Control |
no-cache, no-store, max-age=0, must-revalidate |
Evita que datos privados queden en la caché del navegador o de un proxy |
Pragma, Expires |
no-cache, 0 |
Lo mismo para clientes antiguos |
X-XSS-Protection |
0 |
Desactiva un filtro obsoleto de navegadores antiguos que era peor que el problema |
Strict-Transport-Security |
Solo si la petición llegó por HTTPS | Obliga al navegador a usar HTTPS durante el periodo indicado |
Ajustarlas:
.headers(cabeceras -> cabeceras
.frameOptions(f -> f.sameOrigin()) // la consola H2 de 04-02: solo en desarrollo
.httpStrictTransportSecurity(hsts -> hsts.includeSubDomains(true)
.maxAgeInSeconds(31_536_000)) // un año
.contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'")))HSTS es una decisión con efectos duraderos: una vez que un navegador recibe la cabecera, se niega a hablar por HTTP con ese dominio durante el plazo indicado. Si activas includeSubDomains con un año y algún subdominio no tiene certificado válido, queda inaccesible y no hay forma de revertirlo desde el servidor. Empieza con un maxAge pequeño. Una API JSON pura no necesita CSP —no sirve HTML—, pero cuesta poco y protege las páginas que el propio Spring sirva, como el Swagger UI de 03-07.
- Varias cadenas con
@Order y securityMatcher
@Order y securityMatcherUn mismo proyecto suele tener zonas con reglas incompatibles. En CicloUrbana serán tres: la API (sin estado, con tokens), Actuator (07-01, con su propia política) y los recursos de documentación en desarrollo.
@Bean
@Order(1)
SecurityFilterChain cadenaActuator(HttpSecurity http) throws Exception {
http.securityMatcher("/actuator/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/actuator/health/**").permitAll()
.requestMatchers("/actuator/info").permitAll()
.anyRequest().hasRole("ADMIN"))
.csrf(csrf -> csrf.disable())
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
@Order(2)
SecurityFilterChain cadenaApi(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**")
// ... la configuración del apartado 6 ...
;
return http.build();
}
// @Order(3): una tercera cadena sin securityMatcher, con anyRequest().denyAll(),
// cierra todo lo que no encaje en las dos anteriores.Tres reglas que evitan horas de desconcierto:
securityMatcherdecide a qué peticiones aplica la cadena;requestMatchers, dentro deauthorizeHttpRequests, decide qué permiso hace falta. Confundirlos es constante.- Solo se aplica la primera cadena que coincide (05-01). Las demás no se consultan, aunque tengan reglas más específicas.
- El menor
@Ordergana, y la cadena sinsecurityMatchercoincide con todo, así que debe llevar el@Ordermás alto. Si por error quedara la primera, ninguna otra se ejecutaría jamás.
En el arranque, con el log de FilterChainProxy en DEBUG, se comprueba de un vistazo que el orden es el previsto.
- Depurar la seguridad
Dos herramientas resuelven el noventa por ciento de los problemas.
El log de depuración:
# application-dev.yml — SOLO en desarrollo
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.web.FilterChainProxy: TRACECon eso, cada petición deja un rastro que indica qué filtro la atendió y, sobre todo, cuál la rechazó:
FilterChainProxy : Securing GET /api/v1/usuarios
AuthorizationFilter : Authorizing GET /api/v1/usuarios
AuthorizationFilter : Failed to authorize GET /api/v1/usuarios
with authorization manager ... and decision ExpressionAuthorizationDecision
[granted=false, expression=hasRole('ROLE_ADMIN')]Esa última línea contiene la respuesta completa: la expresión que falló y el resultado. Nunca hay que adivinar.
El volcado de la cadena, con @EnableWebSecurity(debug = true) —solo en desarrollo—, imprime al arrancar la lista ordenada de filtros de cada cadena y, en cada petición, un resumen del contexto de seguridad.
Advertencia. Ambas opciones vuelcan información sensible: rutas, roles, y en el modo
debugtambién cabeceras que pueden contener credenciales. Jamás deben activarse en producción. Su sitio esapplication-dev.yml, y los perfiles que garantizan esa separación se estudian en 07-02.
Errores Comunes y Consejos
Poner la regla general antes que la específica. El error número uno del módulo. requestMatchers("/api/**").permitAll() en la primera línea abre toda la API y no genera ningún aviso.
Olvidar anyRequest() al final. Sin él, cualquier ruta no clasificada queda sin regla. Termina siempre con denyAll() o authenticated().
Escribir .roles("ROLE_ADMIN"). Produce ROLE_ROLE_ADMIN. Con roles, sin prefijo; con authorities, con prefijo.
Desactivar CSRF «porque es una API». Solo es válido si la credencial no viaja sola en el navegador. Con el token en una cookie, desactivarlo es una vulnerabilidad.
Usar * donde hacía falta **. /api/v1/usuarios/* no cubre /api/v1/usuarios/7/alquileres.
Declarar dos beans SecurityFilterChain sin @Order, o dejar la cadena sin securityMatcher la primera. En el primer caso el orden es arbitrario; en el segundo, esa cadena coincide con todo y anula las siguientes.
Consejo: escribe primero la tabla de accesos, después el código. El apartado 6 se decidió en una tabla que el ayuntamiento podría revisar, y traducirla a código fue mecánico; al revés, la configuración acaba siendo un montón de reglas que nadie sabe justificar. Y prueba cada regla con curl -w '%{http_code}': cuatro comandos confirman que la política es la que crees. En 06-04 automatizaremos estas comprobaciones.
Ejercicios
Ejercicio 1
Esta configuración tiene cuatro problemas de seguridad o de funcionamiento. Encuéntralos, explica el impacto de cada uno y reescríbela correctamente.
@Bean
SecurityFilterChain cadena(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").permitAll()
.requestMatchers("/api/v1/usuarios/**").hasRole("ROLE_ADMIN")
.requestMatchers("/api/v1/bicicletas/*").hasRole("OPERARIO"))
.formLogin(Customizer.withDefaults());
return http.build();
}Ejercicio 2
Escribe la cadena de seguridad de CicloUrbana que cumpla estos requisitos, y justifica cada decisión:
/api/v1/estacionesenGET: público. Cualquier escritura sobre estaciones:ADMIN.- Todo
/api/v1/alquileres/**: autenticado. /api/v1/bicicletas/**enGET: autenticado; escritura:OPERARIOoADMIN./swagger-ui/**y/v3/api-docs/**: público solo en el perfildev.- Cualquier otra ruta bajo
/api/**: denegada. - Sin estado, sin CSRF, con CORS y HTTP Basic.
Ejercicio 3
Un compañero informa de que POST /api/v1/estaciones responde 403 con las credenciales de [email protected], que es ADMIN. Enumera cinco causas posibles y describe cómo distinguirlas con el log de depuración.
Soluciones
Solución 1
Problema 1 — orden invertido: /api/** con permitAll() la primera. Coincide con todo, incluidos usuarios y bicicletas: las dos reglas siguientes son inalcanzables y la API entera queda pública. Es el fallo más grave y el más silencioso.
Problema 2 — hasRole("ROLE_ADMIN") con prefijo. hasRole añade ROLE_, así que la expresión efectiva busca la autoridad ROLE_ROLE_ADMIN, que nadie tiene: la regla nunca concede acceso aunque el orden fuera correcto. Debe ser hasRole("ADMIN") o hasAuthority("ROLE_ADMIN").
Problema 3 — falta anyRequest(). Cualquier ruta no listada —/actuator/**, la consola H2, recursos estáticos— queda sin regla. Debe cerrarse con anyRequest().denyAll().
Problema 4 — csrf.disable() junto a formLogin. Form login usa cookie de sesión, y con la credencial viajando sola en el navegador se cumple exactamente el escenario del ataque CSRF. Aquí desactivarlo es una vulnerabilidad real. Además, formLogin no le sirve a la app móvil de CicloUrbana.
@Bean
SecurityFilterChain cadena(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable()) // válido: sin sesión y con credencial en cabecera
.cors(Customizer.withDefaults())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/usuarios/**").hasRole("ADMIN")
.requestMatchers("/api/v1/bicicletas/**").hasAnyRole("OPERARIO", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/estaciones/**").permitAll()
.anyRequest().denyAll())
.httpBasic(Customizer.withDefaults());
return http.build();
}Solución 2
@Configuration
@EnableWebSecurity
public class ConfiguracionSeguridad {
private final Environment entorno;
public ConfiguracionSeguridad(Environment entorno) {
this.entorno = entorno; // Environment de 02-04
}
@Bean
SecurityFilterChain cadenaApi(HttpSecurity http) throws Exception {
boolean desarrollo = entorno.matchesProfiles("dev");
http
.securityMatcher("/api/**", "/swagger-ui/**", "/v3/api-docs/**")
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> {
// La documentación, solo en desarrollo
var docs = auth.requestMatchers("/swagger-ui/**", "/swagger-ui.html",
"/v3/api-docs/**");
if (desarrollo) { docs.permitAll(); } else { docs.denyAll(); }
auth
// Lectura pública de la red; cualquier escritura, administración
.requestMatchers(HttpMethod.GET, "/api/v1/estaciones/**").permitAll()
.requestMatchers("/api/v1/estaciones/**").hasRole("ADMIN")
// Flota
.requestMatchers(HttpMethod.GET, "/api/v1/bicicletas/**").authenticated()
.requestMatchers("/api/v1/bicicletas/**").hasAnyRole("OPERARIO", "ADMIN")
// Uso del servicio
.requestMatchers("/api/v1/alquileres/**").authenticated()
// Denegación por defecto
.anyRequest().denyAll();
})
.httpBasic(Customizer.withDefaults());
return http.build();
}
}Justificaciones. Las reglas de escritura de estaciones van antes que la de lectura pública, porque GET ... permitAll sobre /api/v1/estaciones/** no captura POST, pero el orden explícito documenta la intención y evita accidentes si alguien amplía el patrón. El GET de bicicletas precede a la regla general de bicicletas, o los ciudadanos no podrían consultar la flota. La documentación se cierra con denyAll() fuera de dev en lugar de simplemente omitirla, porque anyRequest().denyAll() ya la cerraría pero una regla explícita se lee como una decisión, no como un descuido. Y securityMatcher incluye las rutas de Swagger porque, si no, caerían en otra cadena. Una alternativa más limpia a este if es tener dos beans en clases distintas anotadas con @Profile, que es lo que haremos en 07-02.
Solución 3
Causa 1 — CSRF activo. Si csrf.disable() no está y la petición no lleva token, CsrfFilter responde 403 antes de llegar a la autorización. Log: Invalid CSRF token found for .... Es la causa más probable con un POST desde curl.
Causa 2 — orden de reglas. Una regla anterior más general captura la petición y exige otro rol. Log: la expresión que aparece en Failed to authorize no será hasRole('ROLE_ADMIN'), sino la de la regla que realmente se aplicó. Esa discrepancia es el diagnóstico.
Causa 3 — prefijo duplicado. Si la regla es hasRole("ROLE_ADMIN") o el usuario se creó con .roles("ROLE_ADMIN"), la autoridad buscada y la concedida no coinciden. Log: granted=false, expression=hasRole('ROLE_ROLE_ADMIN'), o las autoridades del usuario impresas como [ROLE_ROLE_ADMIN].
Causa 4 — la cadena aplicada no es la que se cree. Con varios beans, securityMatcher puede enviar la petición a otra cadena; el log de FilterChainProxy en TRACE indica el índice de la cadena elegida. Causa 5 — CORS mal integrado: si el 403 solo lo ve un navegador, está fallando el OPTIONS de sondeo por faltar .cors(Customizer.withDefaults()).
El método general, y la lección de la solución: activar DEBUG, lanzar la petición y leer la línea Failed to authorize. Contiene la expresión evaluada y el resultado, y descarta cuatro de las cinco causas de un vistazo.
Conclusión
CicloUrbana ya tiene una política de seguridad escrita y deliberada. Sabes por qué desapareció WebSecurityConfigurerAdapter y por qué el modelo actual —beans SecurityFilterChain en una clase @Configuration normal— compone mejor que la herencia; has creado ConfiguracionSeguridad en el paquete com.ciclourbana.seguridad y entiendes cada pieza: @EnableWebSecurity, el HttpSecurity inyectado como prototipo y el http.build() final que, al declararse, sustituye por completo los valores por defecto de Spring Boot.
Dominas la DSL de lambdas: authorizeHttpRequests con requestMatchers por patrón, por método o por ambos; la diferencia entre * y ** que abre agujeros cuando se confunde; y el catálogo de reglas, de permitAll a denyAll. Sobre todo, has interiorizado la regla de oro: las reglas se evalúan en orden y gana la primera que coincide, así que van de específica a general, anyRequest() cierra siempre la lista y la política sana es la denegación por defecto. Lo has visto fallar en un ejemplo que dejaba públicos los datos personales de todos los ciudadanos de Ribalta sin emitir un solo aviso.
El mapa de accesos está decidido y escrito: estaciones públicas en lectura, alquileres para autenticados, flota e incidencias para operarios, estaciones y usuarios para administradores, documentación solo en desarrollo. Has arrancado con InMemoryUserDetailsManager y tres usuarios de prueba —Marta ciudadana, Luis operario, Ana administradora— y comprobado con curl los cuatro códigos que demuestran que la política funciona: 200 público, 401 sin identidad, 403 con identidad insuficiente y 200 con el rol correcto. Y sabes por qué nunca se almacena una contraseña recuperable, por qué SHA-256 no vale, qué aporta BCrypt con su sal y su factor de coste, y por qué el DelegatingPasswordEncoder y su prefijo {bcrypt} son la elección correcta incluso hoy que no necesitas migrar nada.
Has tomado además las cuatro decisiones de carácter con criterio propio: CSRF desactivado, pero solo tras enunciar las cinco condiciones que lo hacen seguro y con la advertencia de que un token en cookie las rompe; sesión STATELESS, coherente con la restricción REST de 03-01; CORS integrado en la cadena con cors(Customizer.withDefaults()) y un CorsConfigurationSource que ya permite la cabecera Authorization; y las cabeceras de seguridad, con la advertencia sobre lo irreversible que es un HSTS mal calibrado. Sabes separar zonas con @Order y securityMatcher, y depurar con el log de org.springframework.security sin llevarlo jamás a producción.
Queda, sin embargo, el problema evidente: los usuarios viven en un mapa en memoria. Marta, Luis y Ana desaparecen en cada reinicio, sus contraseñas están escritas en el código, nadie puede registrarse y el Usuario que persiste en la tabla usuarios de PostgreSQL —con su correo, su tipo de tarifa y su fecha de alta— no tiene ninguna relación con el usuario que se autentica. Son dos mundos separados. En 05-03, Autenticación y Autorización de Usuarios, los uniremos: ampliaremos la entidad Usuario con credenciales y roles mediante la migración V4, implementaremos un UserDetailsService propio que cargue por correo, crearemos un UsuarioAutenticado que conserve el id, registraremos el DaoAuthenticationProvider, abriremos el endpoint de registro de ciudadanos y haremos que POST /api/v1/alquileres deje de fiarse del usuarioId que envíe el cliente. Las llaves de Ribalta dejarán de estar escritas en el código.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
