La lección anterior cerró el módulo de datos con una frase incómoda: la API de CicloUrbana está completamente abierta. Cualquiera que conozca la URL puede crear estaciones, dar de baja bicicletas, leer el correo y el nombre de los ciudadanos de Ribalta o finalizar el alquiler de otra persona. Mientras todo vivía en memoria era una demo; ahora hay una base de datos PostgreSQL con datos personales reales de una red municipal y ni una sola comprobación de quién está al otro lado del cable.

Esta lección no escribe todavía configuración: construye el mapa mental sin el cual configurar Spring Security es copiar y pegar a ciegas. Veremos qué problemas resuelve el framework y por qué la seguridad es el último sitio donde conviene improvisar; separaremos autenticación, autorización y auditoría; observaremos qué le ocurre al proyecto en el instante en que añadimos una dependencia; y entenderemos a fondo la pieza central de toda la arquitectura, la cadena de filtros, que se inserta delante del DispatcherServlet que conocimos en 03-01. Al terminar sabrás nombrar cada objeto que aparece en un stack trace de Spring Security y explicar qué hace, que es exactamente lo que separa a quien depura un 403 en cinco minutos de quien pasa la tarde probando anotaciones al azar.

Advertencia que atraviesa todo el módulo. Lo que vamos a construir es un punto de partida didáctico, correcto pero mínimo. Ninguna configuración de seguridad debe llegar a producción sin la revisión de un profesional de seguridad y, si el servicio es sensible, sin una auditoría externa. Y una regla que no admite excepciones: los secretos —contraseñas, claves de firma, credenciales de base de datos— jamás se versionan en el repositorio. Todos los valores de este módulo son ficticios y sirven solo para el ejemplo.

Contenido

  1. Por qué la seguridad no se improvisa
  2. Autenticación, autorización y auditoría
  3. Qué ocurre al añadir spring-boot-starter-security
  4. La cadena de filtros de servlet
  5. Los filtros importantes, en orden
  6. El modelo de objetos de Spring Security
  7. El flujo de autenticación genérico
  8. SecurityContextHolder y el ThreadLocal
  9. Mecanismos de autenticación disponibles
  10. El OWASP Top 10 aplicado a una API REST
  11. Errores Comunes y Consejos
  12. Ejercicios

  1. Por qué la seguridad no se improvisa

La reacción natural de un desarrollador ante el problema de CicloUrbana es escribir un filtro propio: leer una cabecera, comparar con una tabla, dejar pasar o devolver 401. En una tarde funciona. El problema es todo lo que ese filtro no contempla y que un atacante sí:

  • Almacenamiento de contraseñas. Guardar MD5(contrasena) o incluso SHA-256(contrasena) es hoy equivalente a guardarlas en claro: una GPU doméstica calcula miles de millones de hashes SHA-256 por segundo. Hace falta una función deliberadamente lenta y con sal, como BCrypt, Argon2 o PBKDF2, y saber ajustar su factor de coste.
  • Comparaciones en tiempo constante. Comparar dos cadenas con equals termina en cuanto encuentra una diferencia. Midiendo el tiempo de respuesta con suficiente precisión, un atacante puede deducir caracteres. Spring Security compara credenciales de forma resistente a este análisis.
  • Fijación de sesión. Si el identificador de sesión no se regenera al iniciar sesión, un atacante que consiga plantar un identificador conocido en el navegador de la víctima hereda su sesión autenticada.
  • CSRF. Un formulario en un sitio malicioso puede provocar que el navegador de un usuario autenticado envíe una petición legítima —con sus cookies— a CicloUrbana.
  • Enumeración de usuarios. Responder «ese correo no existe» y «contraseña incorrecta» con mensajes distintos regala al atacante la lista de correos válidos.
  • Orden de las reglas, rutas equivalentes, codificaciones alternativas. /api/v1/Estaciones, /api/v1//estaciones, /api/v1/estaciones;jsessionid=x y /api/v1/%65staciones pueden llegar al mismo controlador y esquivar una comprobación ingenua basada en startsWith.

Spring Security es la respuesta a veinte años de estos errores cometidos en público. Es una biblioteca madura, con divulgación responsable de vulnerabilidades, versiones parcheadas y un modelo que separa claramente responsabilidades. La regla profesional es simple: no escribas criptografía ni mecanismos de autenticación propios; configura los que ya están auditados.

  1. Autenticación, autorización y auditoría

Tres conceptos que el lenguaje coloquial confunde y que en el código son tres capas distintas.

Concepto Pregunta que responde Cuándo ocurre En Spring Security Ejemplo en CicloUrbana
Autenticación (AuthN) ¿Quién eres? Al principio de la petición AuthenticationManager, AuthenticationProvider Marta presenta su correo [email protected] y su contraseña; el sistema confirma que es ella
Autorización (AuthZ) ¿Puedes hacer esto? Antes de ejecutar la operación AuthorizationManager, AuthorizationFilter, @PreAuthorize Marta es CIUDADANO: puede alquilar, pero no puede crear la estación "Plaza Mayor"
Auditoría ¿Quién hizo qué y cuándo? Después, y de forma permanente Eventos (AuthenticationSuccessEvent), EntidadAuditable de 04-03 Queda registrado que el operario [email protected] marcó RB-0142 como averiada el 12 de marzo a las 09:14

Fallan de formas distintas y con códigos HTTP distintos, y confundirlos es el error más común del módulo:

Situación Código HTTP Significado literal
No hay credenciales, o son inválidas 401 Unauthorized «No sé quién eres. Autentícate.»
Credenciales válidas, permisos insuficientes 403 Forbidden «Sé quién eres, y no puedes.»
Recurso inexistente o existente pero ajeno 404 Not Found «Aquí no hay nada» (a veces preferible a un 403 que confirma la existencia)

El nombre 401 Unauthorized es un error histórico de la especificación: significa no autenticado. La cabecera que lo acompaña, WWW-Authenticate, deja claro que habla de autenticación.

Una cuarta pieza aparece constantemente y conviene nombrarla: la identificación. Marta se identifica diciendo que es [email protected] —eso es un dato público— y se autentica demostrándolo con algo que solo ella sabe. El correo identifica; la contraseña autentica.

  1. Qué ocurre al añadir spring-boot-starter-security

La forma más rápida de entender el framework es observar el efecto de una sola línea en el pom.xml. Partimos del proyecto tal como quedó en 04-08.

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

Sin versión, porque el spring-boot-starter-parent de 01-04 la gestiona: con Spring Boot 3.x corresponde a Spring Security 6.x. Al arrancar, el log muestra algo que no estaba antes:

Using generated security password: 8f2a4c31-5b7e-4d19-9c02-6ea3f1b0d47c

This generated password is for development use only.
Your security configuration must be updated before running your application in production.

Ese identificador es aleatorio en cada arranque y solo existe en desarrollo (lo comprobaremos en el ejercicio 1). Ahora probemos la API con curl, exactamente como en 03-02:

curl -i http://localhost:8080/api/v1/estaciones
HTTP/1.1 401
WWW-Authenticate: Basic realm="Realm"
Set-Cookie: JSESSIONID=6C0F...; Path=/; HttpOnly
Content-Type: application/json
{"timestamp":"2026-03-12T09:14:22.881+00:00","status":401,
 "error":"Unauthorized","path":"/api/v1/estaciones"}

Ha cambiado todo sin escribir una línea de código. La autoconfiguración de 02-06 ha detectado la dependencia y ha aplicado su decisión por defecto, que es la única defensa razonable: todo protegido. Merece la pena enumerar qué se ha activado exactamente, porque cada punto se puede modificar y lo haremos en 05-02:

Comportamiento por defecto Detalle
Todas las rutas requieren autenticación Incluidos /api/v1/**, /swagger-ui.html y /actuator/**
Un usuario en memoria Nombre user, contraseña generada en el log
HTTP Basic activado De ahí la cabecera WWW-Authenticate: Basic
Formulario de login activado En /login, generado por el propio framework
Sesión HTTP creada La cookie JSESSIONID de la respuesta
CSRF activado POST, PUT, PATCH y DELETE exigen un token
Cabeceras de seguridad añadidas X-Content-Type-Options, X-Frame-Options, Cache-Control
Consola H2 y recursos estáticos También protegidos

Con las credenciales por defecto la petición vuelve a funcionar:

curl -i -u user:8f2a4c31-5b7e-4d19-9c02-6ea3f1b0d47c \
     http://localhost:8080/api/v1/estaciones
# HTTP/1.1 200  →  las cuatro estaciones de Ribalta

La opción -u de curl construye la cabecera Authorization: Basic dXNlcjo4ZjJhNGMzMS0..., que es simplemente usuario:contraseña codificado en Base64. Base64 no es cifrado: cualquiera puede decodificarlo con base64 -d. Por eso HTTP Basic solo es admisible sobre HTTPS, un punto al que volveremos en 05-05.

Y si abres http://localhost:8080/api/v1/estaciones en un navegador, no verás el 401: verás un formulario de login. La diferencia está en la cabecera Accept que envía el navegador (text/html), que Spring Security usa para elegir entre responder con el formulario o con el reto HTTP Basic. Ese formulario es una página HTML que genera el propio framework; no existe ningún fichero en el proyecto.

Ninguno de estos valores por defecto sirve para CicloUrbana: un usuario llamado user con una contraseña que cambia en cada reinicio no es un modelo de usuarios, y un formulario HTML no le sirve a una aplicación móvil que consume JSON. Pero son un andamio deliberado: el proyecto queda seguro por defecto y roto de forma visible, que es infinitamente mejor que quedar abierto de forma silenciosa.

Qué NO hace por ti esa dependencia

Conviene marcar el límite desde el principio, porque la comodidad de la autoconfiguración induce a pensar que el trabajo está hecho:

  • No sabe quiénes son tus usuarios. El usuario user es un sustituto temporal; conectar la tabla usuarios de Ribalta es trabajo tuyo (05-03).
  • No conoce tus roles ni tus reglas de negocio. «Un ciudadano solo finaliza sus alquileres» es una frase sobre el dominio de CicloUrbana que ningún framework puede adivinar.
  • No cifra el transporte. Sin HTTPS, la cabecera Authorization viaja legible por la red (05-05).
  • No valida los datos de entrada. Eso sigue siendo Bean Validation (03-04).
  • No te protege de tus propias consultas. Si concatenas cadenas en una consulta nativa, la inyección SQL sigue siendo posible (04-06).

  1. La cadena de filtros de servlet

Para entender dónde ocurre todo eso hay que retomar el DispatcherServlet de 03-01. Una aplicación web Java se apoya en la API de Servlet, que define dos piezas: los servlets, que atienden peticiones, y los filtros, que las envuelven formando una cadena por la que la petición pasa antes de llegar al servlet y por la que la respuesta vuelve después.

Spring Security es, en esencia, un filtro. No toca el DispatcherServlet, no modifica tus controladores y no depende de Spring MVC: se sitúa delante de todo y decide si la petición sigue avanzando.

flowchart LR
    C["Cliente<br/>app móvil / curl"] --> T["Contenedor de servlets<br/>Tomcat"]
    T --> DFP["DelegatingFilterProxy<br/>springSecurityFilterChain"]
    DFP --> FCP["FilterChainProxy<br/>bean de Spring"]
    FCP --> SFC["SecurityFilterChain<br/>lista ordenada de filtros"]
    SFC --> DS["DispatcherServlet<br/>lección 03-01"]
    DS --> CTRL["EstacionController<br/>EstacionService"]

Tres nombres que aparecen sin falta en cualquier traza de error y que conviene distinguir:

DelegatingFilterProxy. Un filtro estándar de la API de Servlet, registrado en el contenedor (Tomcat). Su único trabajo es delegar en un bean de Spring llamado springSecurityFilterChain. Existe porque Tomcat no conoce el contenedor de Spring: instancia filtros por su clase, sin inyección de dependencias ni ciclo de vida de beans. DelegatingFilterProxy es el puente entre los dos mundos, y gracias a él los filtros de seguridad son beans normales que pueden inyectar UsuarioRepositorio como cualquier servicio.

FilterChainProxy. El bean al que delega. Es el punto de entrada único de Spring Security, y su trabajo es elegir qué cadena aplica a esta petición concreta. Puede haber varias.

SecurityFilterChain. Una pareja formada por un criterio de coincidencia (RequestMatcher) y una lista ordenada de filtros. FilterChainProxy recorre las cadenas registradas en orden, se queda con la primera cuyo criterio coincide y ejecuta sus filtros. Las demás ni se consultan. Este detalle causa muchas confusiones cuando hay varias cadenas, y lo trataremos en 05-02.

flowchart TD
    R["Petición: GET /api/v1/estaciones"] --> FCP["FilterChainProxy"]
    FCP --> M1{"¿Coincide con<br/>/actuator/**?"}
    M1 -- "No" --> M2{"¿Coincide con<br/>/api/**?"}
    M1 -- "Sí" --> CH1["Cadena 1 · @Order(1)"]
    M2 -- "Sí" --> CH2["Cadena 2 · @Order(2)"]
    M2 -- "No" --> CH3["Cadena por defecto"]
    CH2 --> F["Filtros: contexto → CORS → autenticación → autorización"]
    F --> DS["DispatcherServlet"]

Una consecuencia que sorprende: los filtros de seguridad se ejecutan antes que el @RestControllerAdvice de 03-06. Cuando el fallo es de autenticación o autorización, ManejadorGlobalExcepciones no se entera, porque la excepción se produce fuera del alcance del DispatcherServlet. Ese es el motivo de que la respuesta 401 de arriba no tenga el formato ProblemDetail que tanto cuidamos en 03-06, sino el error genérico de Spring Boot. Lo arreglaremos en 05-03 con un AuthenticationEntryPoint propio. Del mismo modo, FiltroTraza —también un OncePerRequestFilter— solo aporta su identificador de traza a los eventos de seguridad si se registra en la posición adecuada de la cadena.

  1. Los filtros importantes, en orden

Una cadena típica tiene unos quince filtros. Su orden es fijo y está definido en FilterOrderRegistration; no se elige libremente, aunque sí se pueden insertar filtros propios en posiciones relativas (addFilterBefore, addFilterAfter), como haremos con el filtro JWT en 05-04. Estos son los que hay que conocer:

# Filtro Qué hace
1 DisableEncodeUrlFilter Impide que el contenedor añada el jsessionid a las URLs, evitando que se filtre en logs y enlaces
2 SecurityContextHolderFilter Carga el SecurityContext de la sesión al empezar y lo limpia al terminar. En Spring Security 6 ya no lo guarda automáticamente
3 HeaderWriterFilter Escribe las cabeceras de seguridad de la respuesta (X-Frame-Options, etc.)
4 CorsFilter Aplica la política CORS. Debe ir antes de la autorización para que las peticiones OPTIONS de sondeo no requieran credenciales
5 CsrfFilter Verifica el token anti-CSRF en las peticiones que modifican estado
6 LogoutFilter Intercepta /logout, limpia el contexto e invalida la sesión
7 UsernamePasswordAuthenticationFilter Procesa el envío del formulario de login (POST /login)
8 BasicAuthenticationFilter Lee la cabecera Authorization: Basic
9 BearerTokenAuthenticationFilter Lee Authorization: Bearer cuando se usa OAuth2 Resource Server. En 05-04 escribiremos nuestro equivalente, FiltroAutenticacionJwt
10 RequestCacheAwareFilter Recupera la petición original guardada antes de redirigir al login
11 AnonymousAuthenticationFilter Si nadie se autenticó, coloca un Authentication anónimo. Nunca hay null en el contexto
12 ExceptionTranslationFilter Captura AuthenticationException y AccessDeniedException del filtro siguiente y las traduce a 401 o 403
13 AuthorizationFilter El último. Consulta las reglas de authorizeHttpRequests y decide si la petición pasa

Esa lista no hay que memorizarla: se puede imprimir. Con el nivel de log adecuado, Spring Security escribe al arrancar la cadena completa en el orden real de la aplicación, que es la referencia definitiva cuando algo no cuadra:

logging:
  level:
    org.springframework.security.web.FilterChainProxy: DEBUG
Will secure any request with [
  org.springframework.security.web.session.DisableEncodeUrlFilter,
  org.springframework.security.web.context.SecurityContextHolderFilter,
  org.springframework.security.web.header.HeaderWriterFilter,
  org.springframework.web.filter.CorsFilter,
  org.springframework.security.web.csrf.CsrfFilter,
  ...
  org.springframework.security.web.access.ExceptionTranslationFilter,
  org.springframework.security.web.access.intercept.AuthorizationFilter ]

Tres observaciones que evitan errores muy caros:

AnonymousAuthenticationFilter explica por qué SecurityContextHolder.getContext().getAuthentication() casi nunca devuelve null. Devuelve un AnonymousAuthenticationToken con la autoridad ROLE_ANONYMOUS. Comprobar if (auth != null) para saber si hay usuario es un bug clásico: hay que comprobar auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken), o usar directamente la expresión authenticated de la configuración.

ExceptionTranslationFilter va justo antes de AuthorizationFilter, y ese orden es intencionado. Envuelve al último filtro en un try/catch: cuando la autorización rechaza la petición, la excepción sube y este filtro decide. Si el usuario es anónimo, invoca el AuthenticationEntryPoint → 401. Si ya estaba autenticado, invoca el AccessDeniedHandler → 403. Esa es toda la lógica que distingue un 401 de un 403 en Spring Security, y por eso personalizar ambos objetos (05-03) es lo que integra la seguridad con nuestro ProblemDetail.

SecurityContextHolderFilter limpia el contexto en su bloque finally. El motivo lo veremos en el apartado 8 y es imprescindible entenderlo.

  1. El modelo de objetos de Spring Security

Nueve tipos que aparecen una y otra vez. Aprenderlos ahora ahorra horas después.

Tipo Qué es Analogía en CicloUrbana
Authentication El objeto central: representa una petición de autenticación o el resultado. Contiene principal, credentials, authorities e isAuthenticated() El carné de Marta, antes y después de validarlo
Principal La identidad. Antes de autenticar suele ser el String del correo; después, un UserDetails «Marta Aguiló, [email protected]»
GrantedAuthority Un permiso concreto, casi siempre una cadena. Con el prefijo ROLE_ representa un rol ROLE_CIUDADANO, ROLE_OPERARIO, ROLE_ADMIN
SecurityContext Un contenedor con un Authentication dentro La ficha de la petición en curso
SecurityContextHolder El almacén estático que da acceso al SecurityContext del hilo actual El mostrador donde se consulta esa ficha
AuthenticationManager La puerta de entrada: recibe un Authentication sin validar y devuelve uno validado o lanza excepción El jefe de la oficina de atención
AuthenticationProvider Cada mecanismo concreto de validación. ProviderManager los prueba en orden El ventanilla que sabe validar carnés con contraseña
UserDetails Lo que el sistema sabe de un usuario: nombre, hash de contraseña, autoridades, si está activo o bloqueado La ficha de Marta en la base de datos
UserDetailsService La única función que carga un UserDetails a partir de su nombre El archivo donde se busca esa ficha

Merece la pena ver las firmas reales, porque son sorprendentemente pequeñas:

public interface Authentication extends Principal, Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();
    Object getCredentials();   // la contraseña; se borra tras autenticar
    Object getDetails();       // IP, identificador de sesión...
    Object getPrincipal();     // el usuario: String o UserDetails
    boolean isAuthenticated();
    void setAuthenticated(boolean autenticado) throws IllegalArgumentException;
}

@FunctionalInterface
public interface AuthenticationManager {
    Authentication authenticate(Authentication autenticacion) throws AuthenticationException;
}

public interface UserDetailsService {
    UserDetails loadUserByUsername(String nombreUsuario) throws UsernameNotFoundException;
}

Y la ficha del usuario, que implementaremos con una clase propia en 05-03:

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();
    String getPassword();                 // el HASH, nunca la contraseña en claro
    String getUsername();                 // en CicloUrbana será el correo
    boolean isAccountNonExpired();
    boolean isAccountNonLocked();         // bloqueo por intentos fallidos
    boolean isCredentialsNonExpired();    // caducidad de contraseña
    boolean isEnabled();                  // el campo `activo` de la tabla usuarios
}

Los cuatro métodos booleanos son cuatro razones distintas por las que un usuario existente puede no poder entrar, y Spring Security las distingue con excepciones diferentes (AccountExpiredException, LockedException, CredentialsExpiredException, DisabledException). Si no necesitas alguna, devuelve true, pero hazlo conscientemente: devolver true en isEnabled() deja entrar a los usuarios dados de baja.

Tres detalles que revelan el diseño. AuthenticationManager recibe y devuelve el mismo tipo: entra un objeto «pretendo ser Marta con esta contraseña» y sale un objeto «soy Marta y tengo estas autoridades». UserDetailsService no valida nada: solo busca y devuelve; quien compara la contraseña es el AuthenticationProvider. Y getCredentials() devuelve Object porque unas veces es una contraseña, otras un token y otras un certificado.

UserDetailsService es además el punto de extensión que usaremos en 05-03: implementarlo contra UsuarioRepositorio es todo lo que hace falta para que Spring Security autentique contra la tabla usuarios de Ribalta.

  1. El flujo de autenticación genérico

Con los nombres claros, el flujo completo de un inicio de sesión con usuario y contraseña:

sequenceDiagram
    participant C as Cliente
    participant F as Filtro de autenticación
    participant AM as AuthenticationManager<br/>(ProviderManager)
    participant AP as DaoAuthenticationProvider
    participant UDS as UserDetailsService
    participant PE as PasswordEncoder
    participant SCH as SecurityContextHolder

    C->>F: Credenciales (Basic, formulario o JSON)
    F->>F: Construye UsernamePasswordAuthenticationToken<br/>(sin autenticar)
    F->>AM: authenticate(token)
    AM->>AP: ¿Soportas este tipo de token?
    AP->>UDS: loadUserByUsername("[email protected]")
    UDS-->>AP: UserDetails (hash + autoridades + activo)
    AP->>PE: matches(contrasenaPlana, hashAlmacenado)
    PE-->>AP: true
    AP-->>AM: Authentication AUTENTICADO<br/>credenciales borradas
    AM-->>F: Authentication autenticado
    F->>SCH: setContext(contexto con el Authentication)
    F->>C: Continúa la cadena → controlador

Cinco puntos que conviene retener:

  1. El filtro no valida nada. Solo extrae credenciales del transporte (cabecera, formulario o JSON) y construye un Authentication sin autenticar. Por eso hay un filtro distinto por mecanismo y todos terminan en el mismo AuthenticationManager.
  2. ProviderManager es la implementación habitual de AuthenticationManager y contiene una lista de AuthenticationProvider. Pregunta a cada uno si soporta el tipo de token, y el primero que responda que sí decide. Esto permite convivir varios mecanismos —base de datos, LDAP, JWT— en la misma aplicación.
  3. La comparación de contraseñas ocurre en el AuthenticationProvider, con el PasswordEncoder, no en el UserDetailsService.
  4. El Authentication devuelto es un objeto nuevo, autenticado y con las credenciales borradas: la contraseña en claro no debe sobrevivir en memoria más de lo imprescindible.
  5. Guardar el resultado en el SecurityContextHolder es responsabilidad del filtro, no del manager. Es exactamente lo que hará nuestro FiltroAutenticacionJwt en 05-04.

  1. SecurityContextHolder y el ThreadLocal

El SecurityContextHolder es el punto desde el cual cualquier código de la aplicación —un servicio, un aspecto, un @PreAuthorize— averigua quién está haciendo la petición:

Authentication autenticacion = SecurityContextHolder.getContext().getAuthentication();
String correo = autenticacion.getName();                    // "[email protected]"
boolean esOperario = autenticacion.getAuthorities().stream()
        .anyMatch(a -> a.getAuthority().equals("ROLE_OPERARIO"));

Su estrategia de almacenamiento por defecto es MODE_THREADLOCAL: el contexto se guarda en una variable ligada al hilo que atiende la petición. Es una decisión elegante —permite consultar el usuario en cualquier punto sin arrastrarlo como parámetro— con tres consecuencias que hay que conocer.

Primera: el contexto debe limpiarse siempre. Los contenedores de servlets reutilizan hilos mediante un pool. Si al terminar la petición de Marta el contexto siguiera ahí, la siguiente petición atendida por ese mismo hilo —quizá de un usuario anónimo— heredaría la identidad de Marta. Es una vulnerabilidad grave de suplantación. Por eso SecurityContextHolderFilter limpia en un finally, y por eso un filtro propio que llame a SecurityContextHolder.setContext(...) debe limpiar también, o delegar en el filtro estándar. Es el mismo problema que el MDC.remove() de FiltroTraza en 03-06, con consecuencias mucho peores.

Segunda: el contexto no se propaga a otros hilos. Si un servicio lanza una tarea con @Async o con un ExecutorService, ese hilo nuevo tiene un contexto vacío, y cualquier @PreAuthorize allí verá un usuario anónimo:

@Service
public class ServicioNotificaciones {

    @Async   // ¡ojo! hilo distinto: el SecurityContext NO viaja
    public void avisarBateriaBaja(Long idBicicleta) {
        var auth = SecurityContextHolder.getContext().getAuthentication();
        // auth es el token anónimo, no el operario que disparó la operación
    }
}

Las soluciones estándar son DelegatingSecurityContextExecutor, DelegatingSecurityContextRunnable o la estrategia MODE_INHERITABLETHREADLOCAL. Como CicloUrbana no tiene todavía tareas asíncronas, dejamos el asunto anotado: la asincronía y su interacción con el contexto de seguridad se tratan en 07-03.

Tercera: en Spring Security 6 el contexto ya no se guarda solo. En la versión 5, SecurityContextPersistenceFilter guardaba el contexto en la sesión automáticamente al terminar. En la 6, SecurityContextHolderFilter solo lee; guardar es una acción explícita mediante SecurityContextRepository. El cambio buscaba evitar sesiones creadas sin querer, y es una fuente habitual de migraciones rotas. Para CicloUrbana da igual: la API será sin estado y no habrá sesión que guardar.

  1. Mecanismos de autenticación disponibles

Spring Security no impone un mecanismo; ofrece muchos sobre el mismo modelo de objetos.

Mecanismo Cómo viaja la credencial Con estado Encaja bien en Limitaciones
Form login POST /login + cookie de sesión Sí Aplicaciones web con vistas en el servidor Inútil para una API JSON; arrastra CSRF y sesión
HTTP Basic Authorization: Basic base64(u:p) No Pruebas, herramientas internas Envía la contraseña en cada petición; exige HTTPS
JWT / OAuth2 Resource Server Authorization: Bearer <token> No APIs REST y apps móviles Revocación difícil; hay que gestionar expiración
OAuth2 / OIDC Client Redirección a un proveedor externo Depende «Entra con Google», corporativo Requiere un proveedor de identidad
SAML 2.0 Aserciones XML firmadas Sí Integración con identidad corporativa clásica Complejo, orientado a navegador
LDAP / Active Directory Usuario y contraseña contra el directorio Indiferente Empresas con directorio central Necesita el directorio operativo
Certificados de cliente (mTLS) Certificado X.509 en el handshake TLS No Comunicación máquina a máquina Gestión de certificados costosa
API keys Cabecera propia No Integraciones de servidor a servidor No identifica personas; rotación manual

La elección de CicloUrbana es JWT, y conviene justificarla con los requisitos concretos del proyecto:

  • El cliente principal es una aplicación móvil, que no tiene navegador ni gestiona cookies de forma natural.
  • La API es REST y sin estado (03-01): una sesión en el servidor contradice esa restricción y complica escalar horizontalmente a varias instancias, algo que importará en el módulo 7.
  • El Ayuntamiento de Ribalta no dispone de un proveedor de identidad corporativo, así que OIDC y SAML añadirían una pieza de infraestructura sin aportar valor hoy.
  • Un Bearer lo entiende cualquier cliente: la app móvil, el panel web, curl y el propio Swagger UI de 03-07.

Una precisión importante: elegir un mecanismo no es elegir una única línea de defensa. La seguridad se construye por capas —TLS en el transporte, autenticación en la entrada, autorización por URL, autorización por método, validación de datos y restricciones en la base de datos—, de modo que el fallo de una no comprometa el sistema entero. El índice único parcial uk_alquileres_usuario_en_curso de 04-08 es un buen ejemplo: aunque un fallo de autorización permitiera iniciar un alquiler indebido, el motor seguiría impidiendo que un usuario tenga dos alquileres simultáneos.

Todo eso se implementa en 05-04. Y allí veremos también la alternativa madura para producción, spring-boot-starter-oauth2-resource-server con un proveedor externo como Keycloak, que evita escribir código de emisión de tokens.

  1. El OWASP Top 10 aplicado a una API REST

El OWASP Top 10 es la lista de referencia de los riesgos de seguridad más críticos en aplicaciones web, publicada por la Open Worldwide Application Security Project. Repasarla con CicloUrbana en la cabeza aclara qué resuelve el framework y qué sigue siendo responsabilidad del programador. Esta distinción es la lección más importante del módulo.

Riesgo OWASP Qué significa en CicloUrbana Qué aporta Spring Security Qué te toca a ti
A01 Control de acceso roto Un ciudadano finaliza el alquiler de otro cambiando el id de la URL Reglas por URL, @PreAuthorize, AuthorizationFilter Escribir las reglas correctas y comprobar la propiedad del recurso (05-05)
A02 Fallos criptográficos Contraseñas en claro; API por HTTP sin cifrar PasswordEncoder con BCrypt, HSTS, requiresChannel Terminar TLS bien, no inventar cifrado, rotar claves
A03 Inyección ... WHERE correo = ' + entrada + ' Nada directamente Consultas parametrizadas: JPA y @Query de 04-06 ya lo hacen
A04 Diseño inseguro No limitar los alquileres simultáneos por usuario Nada Modelar bien las reglas de negocio (el índice único parcial de 04-08 es un ejemplo)
A05 Configuración incorrecta Swagger UI o la consola H2 abiertas en producción Valores por defecto seguros y cabeceras Revisar perfiles (07-02) y cerrar lo que no debe exponerse
A06 Componentes vulnerables Una versión antigua de una librería con CVE Sus propias versiones parcheadas Actualizar y auditar dependencias (05-05)
A07 Fallos de identificación y autenticación Contraseñas débiles, sin bloqueo de cuenta Mecanismos robustos y protección de sesión Política de contraseñas, límite de intentos, segundo factor
A08 Fallos de integridad Aceptar un JWT sin verificar la firma Verificación de firma, protección CSRF No deserializar datos no confiables
A09 Fallos de registro y monitorización Nadie detecta 10 000 intentos de login fallidos Eventos de autenticación publicados Registrarlos, alertar y no registrar nunca contraseñas ni tokens
A10 SSRF Un endpoint que descarga una URL indicada por el cliente Nada Validar y limitar destinos de salida

La conclusión es doble y muy práctica. Spring Security no te hace seguro: te da herramientas correctas. De los diez riesgos, el framework cubre parcialmente cuatro, ayuda en tres y no interviene en tres. Y los dos riesgos donde más aporta —A01 y A07— son precisamente donde más fácil es equivocarse, porque una regla mal escrita se ve igual que una bien escrita: la aplicación arranca, responde 200 y nadie nota nada hasta que alguien mira lo que no debía.

Existe además un OWASP API Security Top 10 específico para APIs, cuyos dos primeros riesgos son BOLA (autorización rota a nivel de objeto: acceder al recurso de otro cambiando un identificador) y la autenticación rota. Ambos son exactamente los problemas que resolveremos en 05-03 y 05-05.

Errores Comunes y Consejos

Creer que quitar el enlace del frontend protege un endpoint. Si DELETE /api/v1/estaciones/1 funciona sin credenciales, da igual que ningún botón lo invoque: curl existe. La seguridad se aplica siempre en el servidor.

Escribir un filtro de autenticación propio «porque es más simple». Lo es hasta el primer incidente. Todo lo enumerado en el apartado 1 —temporización, fijación de sesión, enumeración de usuarios, normalización de rutas— hay que resolverlo, y ya está resuelto.

Confundir 401 con 403. Devolver 403 a quien no ha presentado credenciales impide que un cliente sepa que debe autenticarse; devolver 401 a quien sí está autenticado le hará reintentar en bucle.

Confiar en la ofuscación. Base64 no es cifrado, un identificador largo en la URL no es un secreto y un endpoint «que nadie conoce» aparece en los logs, en el historial del navegador y en el escaneo automático a los pocos días de publicarse.

Comprobar authentication != null para saber si hay usuario. AnonymousAuthenticationFilter garantiza que casi nunca es null. Comprueba autenticación real o, mejor, delega en las reglas del framework.

Dejar la contraseña generada en el log de un entorno compartido. Está pensada exclusivamente para desarrollo local, y el propio mensaje lo advierte. Cualquiera con acceso a los logs de preproducción entra.

Consejo: activa el log de depuración desde el primer día. Con logging.level.org.springframework.security: DEBUG verás qué filtros se ejecutan y qué regla rechaza la petición. Es la diferencia entre depurar y adivinar. Detalles en 05-02.

Consejo: dibuja tu cadena de filtros. Cuando algo no funciona, la pregunta útil casi nunca es «¿qué anotación falta?», sino «¿en qué filtro se detuvo la petición?».

Consejo: la seguridad tiene fecha de caducidad. Una configuración correcta hoy puede no serlo dentro de dos años. Programa revisiones periódicas y actualizaciones de dependencias como parte del mantenimiento.

Ejercicios

Ejercicio 1

Añade spring-boot-starter-security al proyecto CicloUrbana y, sin escribir configuración, responde con curl y con el log:

  1. ¿Qué devuelve GET /api/v1/estaciones sin credenciales? ¿Y con las credenciales del log?
  2. ¿Qué devuelve POST /api/v1/estaciones con las credenciales correctas y un cuerpo válido? Explica el resultado.
  3. ¿Sigue accesible /swagger-ui.html?
  4. ¿Qué ocurre si defines spring.security.user.name y spring.security.user.password en application.yml? ¿Desaparece el mensaje del log? ¿Por qué no debe hacerse así en un proyecto real?

Ejercicio 2

Clasifica cada situación de CicloUrbana como fallo de autenticación (401), de autorización (403) o de ninguno de los dos, e indica qué componente de Spring Security interviene:

# Situación
a Un cliente llama a GET /api/v1/alquileres/7 sin cabecera Authorization
b El ciudadano Marta llama a DELETE /api/v1/estaciones/1
c El operario Luis llama a PATCH /api/v1/bicicletas/RB-0142 con un estado inexistente
d Un cliente presenta un token caducado
e Marta llama a POST /api/v1/alquileres/9/finalizar, siendo el alquiler 9 de otro ciudadano
f Un cliente llama a GET /api/v1/estaciones/99, que no existe

Ejercicio 3

Describe, filtro a filtro, el recorrido completo de esta petición en el CicloUrbana actual (con la dependencia añadida y sin configuración propia), indicando qué hace cada filtro relevante y dónde y por qué se detiene:

curl -i -X POST http://localhost:8080/api/v1/estaciones \
     -H 'Content-Type: application/json' \
     -d '{"nombre":"Mercado Viejo","capacidad":20}'

Soluciones

Solución 1

1. Sin credenciales, 401 con WWW-Authenticate: Basic realm="Realm" y un cuerpo de error genérico de Spring Boot —no un ProblemDetail—, porque el rechazo ocurre en ExceptionTranslationFilter, antes de que la petición llegue al DispatcherServlet y por tanto fuera del alcance de ManejadorGlobalExcepciones (03-06). Con -u user:<contraseña del log>, 200 y el listado de las cuatro estaciones de Ribalta.

2. Devuelve 403 Forbidden, y desconcierta porque las credenciales son correctas. El culpable es CSRF: está activado por defecto y CsrfFilter rechaza todo POST, PUT, PATCH y DELETE que no traiga un token válido. No es un problema de permisos, aunque el código lo parezca. La confirmación está en el log con DEBUG activado:

o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for
http://localhost:8080/api/v1/estaciones

En 05-02 desactivaremos CSRF justificando por qué es seguro hacerlo en una API sin estado con tokens.

3. No. /swagger-ui.html y /v3/api-docs quedan protegidos como todo lo demás: en el navegador aparece el formulario de login. En 05-02 se abrirán solo en desarrollo.

4. Definiendo ambas propiedades, el usuario en memoria pasa a tener ese nombre y esa contraseña, y el mensaje del log desaparece: solo se genera cuando la contraseña no está configurada. No sirve para un proyecto real por tres motivos: es un único usuario sin roles ni identidad, así que no puede haber ciudadanos, operarios y administradores; la contraseña está en claro en un fichero versionado en Git, exactamente lo que 02-04 prohibió al hablar de secretos; y no hay forma de dar de alta usuarios sin reiniciar. La solución llega en 05-03 con UserDetailsService contra la tabla usuarios.

Solución 2

# Tipo Código Componente
a Autenticación 401 ExceptionTranslationFilter detecta usuario anónimo e invoca el AuthenticationEntryPoint
b Autorización 403 AuthorizationFilter: Marta está autenticada pero no tiene ROLE_ADMIN
c Ninguno 400 Es validación de entrada (03-04). La seguridad ya la dejó pasar; responde ManejadorGlobalExcepciones
d Autenticación 401 El filtro de token rechaza la credencial: es inválida, no insuficiente
e Autorización 403 Ningún filtro por URL puede resolverlo: la ruta es legítima y el permiso depende del dato. Requiere seguridad de método (05-05)
f Ninguno 404 RecursoNoEncontradoException de 03-06

Los casos c y f enseñan que no todo error es de seguridad. El caso e es el más importante del módulo: es el riesgo A01/BOLA del apartado 10 y demuestra por qué las reglas por URL no bastan.

Solución 3

  1. DelegatingFilterProxy recibe la petición de Tomcat y delega en el bean springSecurityFilterChain.
  2. FilterChainProxy busca la primera SecurityFilterChain que coincide. Solo hay la de por defecto, que aplica a /**.
  3. SecurityContextHolderFilter intenta cargar un SecurityContext de la sesión. No hay cookie JSESSIONID, así que el contexto queda vacío.
  4. HeaderWriterFilter prepara las cabeceras de seguridad de la respuesta.
  5. CsrfFilter se activa porque POST sí modifica estado. Busca el token anti-CSRF en el parámetro _csrf o en la cabecera X-CSRF-TOKEN. No lo encuentra y lanza InvalidCsrfTokenException (o MissingCsrfTokenException).
  6. ExceptionTranslationFilter, que envuelve a los siguientes, no llega a intervenir en este caso: CsrfFilter está antes que él en la cadena y resuelve la respuesta con su propio AccessDeniedHandler.
  7. La respuesta es 403 Forbidden. BasicAuthenticationFilter, AuthorizationFilter, el DispatcherServlet, EstacionController y EstacionService nunca se ejecutan.

La moraleja es el objetivo de esta lección: la petición murió a cinco filtros de distancia de tu código, y ninguna anotación en el controlador lo habría cambiado. Sin el mapa de la cadena, este 403 parece un problema de permisos y se pierde una tarde entera.

Conclusión

Ya tienes el mapa. Sabes por qué la seguridad no se improvisa —almacenamiento de contraseñas, comparaciones en tiempo constante, fijación de sesión, CSRF, enumeración de usuarios y normalización de rutas son problemas resueltos que nadie debería reescribir— y distingues con precisión autenticación (quién eres, 401), autorización (qué puedes hacer, 403) y auditoría (qué hiciste). Has visto el efecto inmediato de añadir spring-boot-starter-security: todo protegido, un usuario user con contraseña generada en el log, HTTP Basic y formulario activados, sesión, CSRF y cabeceras de seguridad; y has comprobado con curl que GET /api/v1/estaciones responde 401 y que un POST con credenciales correctas responde 403 por CSRF, un desconcierto que ahora sabes explicar.

Sobre todo, entiendes la arquitectura. DelegatingFilterProxy tiende el puente entre Tomcat y el contenedor de Spring; FilterChainProxy elige la primera SecurityFilterChain que coincide; y esa cadena ejecuta sus filtros en un orden fijo antes de que la petición alcance el DispatcherServlet de 03-01. Conoces el papel de SecurityContextHolderFilter, CorsFilter, CsrfFilter, los filtros de autenticación, AnonymousAuthenticationFilter, ExceptionTranslationFilter —el que decide entre 401 y 403— y AuthorizationFilter. Y sabes la consecuencia práctica más útil: cuando la seguridad rechaza una petición, tu @RestControllerAdvice de 03-06 ni se entera, porque el rechazo ocurre fuera del alcance del DispatcherServlet.

Tienes también el vocabulario: Authentication, Principal, GrantedAuthority, SecurityContext, SecurityContextHolder, AuthenticationManager, AuthenticationProvider, UserDetails y UserDetailsService, con el flujo que los une y el detalle del ThreadLocal que obliga a limpiar el contexto en cada petición y que impide que la identidad viaje sola a un hilo @Async —un cabo que retomaremos en 07-03—. Has comparado los mecanismos de autenticación disponibles y sabes por qué CicloUrbana elige JWT: cliente móvil, API sin estado y ausencia de un proveedor de identidad corporativo. Y has situado el OWASP Top 10 sobre el proyecto, con la conclusión que gobierna todo el módulo: Spring Security da herramientas correctas, no seguridad automática, y los riesgos donde más aporta son también donde más fácil es equivocarse.

La siguiente lección deja la teoría y escribe la primera clase real del paquete com.ciclourbana.seguridad. En 05-02, Configuración de Spring Security, crearemos ConfiguracionSeguridad con su bean SecurityFilterChain y la DSL de lambdas de Spring Security 6; definiremos con authorizeHttpRequests y requestMatchers el mapa completo de accesos de Ribalta —estaciones públicas, alquileres autenticados, bicicletas para operarios, estaciones y usuarios para administradores— y aprenderemos la regla de oro del orden de las reglas con un ejemplo que falla; sustituiremos el usuario user por usuarios en memoria con InMemoryUserDetailsManager; estudiaremos a fondo la codificación de contraseñas con DelegatingPasswordEncoder y BCrypt; y decidiremos, con criterio y no por costumbre, qué hacer con CSRF, la sesión y las cabeceras de seguridad. La red de Ribalta va a tener por fin sus primeras cerraduras.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados