Ir al contenido

Spring SecurityLección 12

Seguridad reactiva con WebFlux

Los mismos conceptos, otra fontanería. Cómo se protege una aplicación WebFlux, por qué el contexto de seguridad no vive en un ThreadLocal y cómo se prueba con WebTestClient.

Avanzado15 min de lectura

Al terminar sabrás

  • Traducir la configuración Servlet a su equivalente reactivo.
  • Leer el usuario actual con ReactiveSecurityContextHolder y entender por qué no sirve un ThreadLocal.
  • Proteger métodos que devuelven Mono y Flux.
  • Probar reglas de seguridad reactivas con WebTestClient.

Spring Security funciona igual en espíritu con Spring WebFlux: autenticación, autorización, CSRF, OAuth2 y JWT son los mismos conceptos. Lo que cambia es la fontanería, porque en una aplicación reactiva una petición no tiene un hilo propio.

Servlet (Spring MVC)Reactivo (WebFlux)
@EnableWebSecurity@EnableWebFluxSecurity
SecurityFilterChain + HttpSecuritySecurityWebFilterChain + ServerHttpSecurity
authorizeHttpRequests / requestMatchers / anyRequestauthorizeExchange / pathMatchers / anyExchange
SecurityContextHolder (ThreadLocal)ReactiveSecurityContextHolder (contexto de Reactor)
UserDetailsServiceReactiveUserDetailsService (MapReactiveUserDetailsService en memoria)
JwtDecoderReactiveJwtDecoder
@EnableMethodSecurity@EnableReactiveMethodSecurity (métodos que devuelven Mono/Flux)
MockMvc + jwt()WebTestClient + mockJwt()
SecurityConfig.java
@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
class SecurityConfig {
@Bean
SecurityWebFilterChain api(ServerHttpSecurity http) {
http
.authorizeExchange(ex -> ex
.pathMatchers(HttpMethod.GET, "/api/public/**").permitAll()
.pathMatchers("/api/admin/**").hasAuthority("SCOPE_notes:admin")
.anyExchange().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.csrf(ServerHttpSecurity.CsrfSpec::disable); // API stateless con Bearer
return http.build();
}
}

La estructura es la misma que ya conoces: reglas de arriba abajo, un mecanismo de autenticación y la decisión sobre CSRF. Las propiedades de issuer-uri y audiences del resource server también son las mismas.

  1. Petición
  2. ReactorContextWebFilterescribe el SecurityContext en el contexto de Reactor
  3. Tu pipeline Mono/Fluxlo lee con ReactiveSecurityContextHolder.getContext()
El contexto viaja con la suscripción, no con el hilo: sobrevive a los cambios de hilo de Reactor.

Evita

// En WebFlux esto devuelve null: no hay ThreadLocal fiable
@GetMapping("/api/me")
Mono<String> me() {
return Mono.just(SecurityContextHolder.getContext()
.getAuthentication().getName());
}

Prefiere

@GetMapping("/api/me")
Mono<String> me() {
return ReactiveSecurityContextHolder.getContext()
.map(ctx -> ctx.getAuthentication().getName());
}
// …o, más simple, como parámetro:
@GetMapping("/api/me2")
Mono<String> me2(@AuthenticationPrincipal Jwt jwt) {
return Mono.just(jwt.getSubject());
}

Con @EnableReactiveMethodSecurity, @PreAuthorize y @PostAuthorize funcionan sobre métodos que devuelven Mono o Flux; la expresión se evalúa dentro del pipeline:

NoteService.java
@PreAuthorize("hasAuthority('SCOPE_notes:admin')")
public Flux<Note> all() {
return repo.findAll();
}
public Mono<Note> get(Long id, String owner) {
return repo.findByIdAndOwner(id, owner)
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND)));
}
NoteRoutesTest.java
@WebFluxTest(NoteController.class)
@Import(SecurityConfig.class)
class NoteRoutesTest {
@Autowired WebTestClient client;
@MockitoBean NoteService notes;
@Test
void anonimo_recibe401() {
client.get().uri("/api/notes").exchange().expectStatus().isUnauthorized();
}
@Test
void sinScopeAdmin_recibe403() {
client.mutateWith(mockJwt().authorities(new SimpleGrantedAuthority("SCOPE_notes:read")))
.get().uri("/api/admin/notes")
.exchange()
.expectStatus().isForbidden();
}
}

Mini lab · 30 min

La API de notas, en reactivo

Objetivo: Portar la configuración de seguridad de la API de notas a WebFlux y comprobar que las reglas se mantienen.

  1. Crea un proyecto con spring-boot-starter-webflux, spring-boot-starter-security y el starter de resource server.
  2. Escribe un SecurityWebFilterChain equivalente a la cadena de la API de la lección de JWT: lectura con SCOPE_notes:read, escritura con SCOPE_notes:write, admin con SCOPE_notes:admin.
  3. Implementa GET /api/me leyendo el usuario con ReactiveSecurityContextHolder.
  4. Escribe la matriz de autorización con WebTestClient y mockJwt().

Preguntas:

  • Si en el paso 3 usas SecurityContextHolder.getContext(), ¿qué obtienes y por qué?
  • ¿Qué método sustituye a requestMatchers y cuál a anyRequest?
  • Tu ReactiveUserDetailsService usa un repositorio JPA. ¿Qué problema tiene?
Pista

Piensa en qué hilo ejecuta tu código en WebFlux y si es el mismo que el que inició la petición.

Mostrar respuestaOcultar respuesta
  • Un contexto vacío (autenticación null): SecurityContextHolder usa un ThreadLocal, y en WebFlux el código de la petición puede ejecutarse en cualquier hilo del event loop. El contexto de seguridad vive en el contexto de Reactor y se lee con ReactiveSecurityContextHolder.getContext() (o con @AuthenticationPrincipal).
  • pathMatchers(...) sustituye a requestMatchers(...) y anyExchange() a anyRequest(), dentro de authorizeExchange.
  • JPA es bloqueante: cada login bloquea un hilo del event loop, y con pocos usuarios simultáneos el servidor deja de responder. Usa R2DBC, o aísla la llamada bloqueante con Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()).