Día 16 de 30 Fase 4: Seguridad, Concurrencia y Reactividad vs Virtual Threads

Seguridad Stateless con Spring Security 6 y JWT en Java 17/21

SecurityFilterChain, Sesiones sin Estado, Filtros OncePerRequestFilter y Autorización por Método

Para dominar. Al terminar deberías poder hacerlo sin ayuda.

  • 4h de estudio
  • 5 conceptos
  • 3 preguntas
  • 2 katas
Spring Security 6JWTSecurityFilterChainStatelessBCryptRBAC
Al terminar este día sabrás 4
  • Configurar Spring Security 6 con SecurityFilterChain y el DSL de lambdas.
  • Entender qué contiene un JWT, qué garantiza y qué no.
  • Seguir el recorrido de una petición por la cadena de filtros hasta el SecurityContext.
  • Autorizar por método y almacenar contraseñas con un algoritmo adaptativo.

De WebSecurityConfigurerAdapter a SecurityFilterChain con Lambdas

En Spring Security 6 ya no se extiende de clases base obsoletas. Se expone un @Bean public SecurityFilterChain filterChain(HttpSecurity http) donde cada regla (autorización, csrf, sessionManagement) se define con lambdas fluidas sin encadenamientos rotos con .and().

En una línea http.csrf(csrf -> csrf.disable()).authorizeHttpRequests(auth -> auth.requestMatchers("/api/auth/**").permitAll().anyRequest().authenticated());

Cadena de seguridad para una API sin estado java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SeguridadConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/api/**")
            .csrf(csrf -> csrf.disable())                         // token en cabecera, no en cookie
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.GET, "/api/tareas/**").hasAuthority("SCOPE_tareas:leer")
                .anyRequest().authenticated())                    // siempre al final
            .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
            .exceptionHandling(e -> e
                .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())   // 401
                .accessDeniedHandler(new BearerTokenAccessDeniedHandler()))            // 403
            .build();
    }
}
Cómo funciona por dentro

Spring Security 6 eliminó WebSecurityConfigurerAdapter. La configuración se declara ahora como un bean SecurityFilterChain construido con HttpSecurity, y el DSL usa lambdas en lugar de encadenar .and(). El resultado es más legible y permite tener varias cadenas, por ejemplo una para /api/** sin estado y otra para una consola web con sesión, ordenadas con @Order.

Las reglas de authorizeHttpRequests se evalúan en orden y gana la primera que coincide, así que las más específicas van primero y se termina con anyRequest(). Un error frecuente es poner anyRequest().authenticated() al principio y que nunca se evalúen las demás.

Para una API REST sin estado, lo típico es desactivar la sesión (STATELESS), desactivar CSRF (si el token viaja en una cabecera, no en una cookie) y configurar el servidor de recursos OAuth2 para validar JWT.

Facilita la modularidad y elimina métodos obsoletos propensos a fallos de configuración.

Se conecta con Spring Bean ContainerDSL FluidaFiltros HTTP

Cuatro bloques cronometrados. No pases al siguiente sin tener el resultado esperado.

  1. Bloque 1: SecurityFilterChain en Spring Boot 3 (0 - 25 min)

    Qué estudiar. Configuración lambda con SecurityFilterChain y sesión stateless.

    Pregunta a responder. ¿Cómo permitir rutas públicas (/api/auth/**) y proteger el resto?

    Resultado esperado. Clase SecurityConfig funcional con rutas públicas y privadas.

  2. Bloque 2: Generación y Validación de JWT (25 - 50 min)

    Qué estudiar. Librería jjwt o nimbus-jose-jwt. Firma HMAC-SHA256.

    Pregunta a responder. ¿Cómo verificar la expiración y claims de un token?

    Resultado esperado. Servicio JwtService con tests unitarios verificando firma y expiración.

  3. Bloque 3: Filtro de Autenticación HTTP (50 - 80 min)

    Qué estudiar. Clase OncePerRequestFilter y población del SecurityContextHolder.

    Pregunta a responder. ¿Cómo extraer el token de la cabecera Authorization: Bearer?

    Resultado esperado. Filtro que autentica al usuario antes de alcanzar los controladores.

  4. Cierre y Consolidación (80 - 90 min)

    Qué estudiar. Prueba de endpoints con @PreAuthorize("hasRole("ADMIN")").

    Pregunta a responder. Verificar que un usuario sin rol recibe HTTP 403 Forbidden.

    Resultado esperado. Progreso registrado en la app.

Tres formas de comprobar si lo has entendido, de menos a más exigente: explicarlo con tus palabras, responder preguntas trampa y resolver el reto del tutor.

Explícalo desde cero

Lee cada ficha y explícala en voz alta antes de seguir: si puedes contárselo a alguien que empieza, lo has entendido.

Por qué no se debe usar SHA-256 simple para almacenar contraseñas

Lo que debes entender

SHA-256 está diseñado para ser extremadamente rápido (procesar gigabytes por segundo para verificar integridad de ficheros). Un atacante con una GPU moderna puede calcular miles de millones de hashes por segundo. BCrypt está diseñado intencionalmente para ser lento y costoso en CPU y memoria, haciendo inviable la fuerza bruta.

Analogía para contarlo

Una cerradura normal que se abre en milisegundos vs una cerradura de caja fuerte que impone un retardo de 500 ms por cada intento de llave, bloqueando a quien pretenda probar un millón de llaves seguidas.

Lo que suele explicarse mal

Confundir un algoritmo de hash criptográfico general (SHA-256) con una función de derivación de claves para contraseñas con sal y coste ajustable (BCrypt, Argon2).

Señal de que aún no lo dominas

Si propones hacer MessageDigest.getInstance("SHA-256") para contraseñas en una entrevista técnica.

Preguntas trampa

Responde de memoria, en voz alta o por escrito, y después abre la respuesta razonada: lo importante es el porqué, no la palabra exacta.

Nivel 1 (Básico) ¿Por qué en Spring Security 6 se suele desactivar CSRF (csrf.disable()) en APIs REST que utilizan tokens JWT? Ver respuesta

Respuesta razonada

CSRF explota que el navegador adjunta automáticamente las cookies de un dominio en cualquier petición a ese dominio, aunque la inicie otra web. Si la sesión viaja en una cookie, una página maliciosa puede hacer que tu navegador envíe un POST autenticado sin que te enteres.

Si la API se autentica con un JWT en la cabecera Authorization: Bearer ..., el navegador no la añade solo: la tiene que poner el código JavaScript de tu aplicación, y una web de otro origen no puede leer tu token ni añadir esa cabecera. Sin credenciales automáticas, no hay ataque CSRF posible y la protección sobra. Ojo: si guardas el JWT en una cookie, vuelves a necesitar protección CSRF (o cookies SameSite=Strict).

Qué está evaluando la pregunta

Evalúa si entiendes el vector de ataque de Cross-Site Request Forgery. Los ataques CSRF dependen de que el navegador envíe automáticamente cookies de sesión en peticiones cruzadas. Las APIs REST que autentican mediante el header Authorization: Bearer <token> no usan cookies y por ende son inmunes a CSRF.

Nivel 2 (Intermedio) ¿Cuál es la diferencia exacta entre un Role y una Authority en Spring Security? Ver respuesta

Respuesta razonada

Para Spring Security ambos son GrantedAuthority, es decir, cadenas de texto. Un rol es simplemente una autoridad con el prefijo ROLE_. hasRole('ADMIN') comprueba si existe la autoridad ROLE_ADMIN; hasAuthority('ADMIN') busca exactamente ADMIN, sin prefijo. Mezclarlos es el error más común: guardar ADMIN y comprobar hasRole('ADMIN') siempre deniega.

Conceptualmente, un rol agrupa permisos de grano grueso (ADMIN, EDITOR) y una autoridad suele representar un permiso concreto (tareas:borrar). Con el servidor de recursos JWT, los scopes del token se convierten en autoridades con prefijo SCOPE_, así que se comprueban con hasAuthority('SCOPE_tareas:leer').

Qué está evaluando la pregunta

Una Authority representa un permiso individual y granular (e.g. OP_CREAR_FACTURA). Un Role es una agrupación de permisos que por convención en Spring Security se prefija con ROLE_ (e.g. hasRole("ADMIN") busca internamente la autoridad ROLE_ADMIN).

Nivel 3 (Entrevista Senior) ¿Qué problema ocurre con SecurityContextHolder cuando lanzas tareas asíncronas con @Async o Virtual Threads si no configuras la propagación del contexto? Ver respuesta

Respuesta razonada

Por defecto, SecurityContextHolder guarda el contexto en un ThreadLocal. Si lanzas trabajo en otro hilo con @Async, un ExecutorService o un hilo virtual nuevo, ese hilo no tiene contexto: getAuthentication() devuelve null y la autorización falla o, peor, el código se comporta como anónimo.

La solución es propagar el contexto explícitamente: envolver el ejecutor con DelegatingSecurityContextExecutorService (o DelegatingSecurityContextAsyncTaskExecutor para @Async), que copia el contexto al lanzar la tarea y lo limpia al terminar. Spring Boot puede hacerlo con un TaskDecorator, y la librería Micrometer Context Propagation unifica esto para seguridad, trazas y MDC. La estrategia MODE_INHERITABLETHREADLOCAL no es buena idea con pools, porque los hilos reutilizados heredan el contexto de quien los creó, no el de la tarea.

Qué está evaluando la pregunta

Por defecto, SecurityContextHolder almacena la autenticación en un ThreadLocal exclusivo del hilo que atiende la petición HTTP. Si la ejecución salta a otro hilo o a un pool de fondo, el nuevo hilo no hereda el contexto y las llamadas a @PreAuthorize fallarán por considerarlo anónimo. Se resuelve configurando MODE_INHERITABLETHREADLOCAL o usando un DelegatingSecurityContextExecutor.

Reto del tutor

Un desarrollador propone: "Voy a meter toda la información del usuario (sueldo, dirección, teléfono, permisos) dentro del payload del token JWT para no tener que consultar la base de datos nunca más". Evalúa esta propuesta como un arquitecto senior.

Lo que una respuesta suele tener bien

Reconoce que el JWT transporta claims para evitar consultas repetitivas de identidad.

Lo que suele quedarse en la superficie

Decir únicamente que "el token va a pesar mucho".

El concepto de fondo que suele faltar

No advertir que el payload del JWT NO está cifrado (solo está firmado y en Base64url), exponiendo datos confidenciales y personales a cualquiera que intercepte la cabecera HTTP.

Cómo lo respondería alguien senior en una entrevista

Es una brecha grave de seguridad y un fallo de diseño: (1) El JWT está firmado (integridad garantizada), pero NO encriptado: cualquiera puede decodificar el Base64url y leer datos personales sensibles (violación de GDPR/privacidad); (2) Aumentar el tamaño del token obliga a transmitir esos kilobytes extra en CADA petición HTTP de la red; (3) Inconsistencia de estado: si el rol o estado del usuario cambia en la base de datos, el token emitido seguirá siendo válido con datos obsoletos hasta su expiración. En el JWT solo debe viajar la identidad mínima (sub: idUsuario) y los roles necesarios para la autorización.

Proyecto «Pedidos» · paso 16 de 30

Seguridad con JWT y roles

Que cada cliente vea solo sus pedidos y que solo la tienda pueda cambiar su estado.

De qué va el proyecto

El backend de una tienda online: clientes, catálogo de productos y pedidos con sus líneas, pagos y envíos. Empieza como un proyecto Maven con records y termina como un sistema con API REST, seguridad, mensajería, contenedores y observabilidad. Cada paso construye sobre el anterior, así que trabaja siempre en el mismo repositorio.

Qué hacer

  1. Configura una SecurityFilterChain stateless que valide tokens JWT.
  2. Define los roles CLIENTE y ADMIN: el cliente crea y consulta sus pedidos; el admin confirma, envía y cancela.
  3. Usa @PreAuthorize para que un cliente no pueda leer los pedidos de otro.

Está listo cuando

  • Pedir el pedido de otro cliente devuelve 403, y hacerlo sin token, 401.
  • Las contraseñas se guardan con BCrypt.

Katas: ejercicios cortos

Reglas de oro

  • Jamás guardes secretos criptográficos quemados (hardcoded) en el código; inyéctalos desde variables de entorno con @Value("${jwt.secret}").
  • El payload de un JWT es público para quien tenga el token; nunca guardes datos sensibles o privados allí.
  • En APIs REST stateless desactiva CSRF (csrf.disable()) y configura SessionCreationPolicy.STATELESS.
  • Asegúrate de que tu OncePerRequestFilter siempre llame a filterChain.doFilter() en el bloque finally o flujo normal.
  • Utiliza siempre BCryptPasswordEncoder o Argon2 para validar contraseñas con passwordEncoder.matches().

Java 17 frente a Java 21

CaracterísticaJava 17 LTSJava 21 LTS
Soporte Virtual Threads No disponible en 17 Compatible mediante DelegatingSecurityContextExecutor
Se guarda automáticamente

Fuentes oficiales

Revisado el . Este día en Markdown