Ir al contenido

Spring SecurityLección 02

Configurar la cadena con el DSL

Toda la configuración moderna es un bean SecurityFilterChain construido con HttpSecurity y lambdas. Cómo leerlo, cómo se evalúan las reglas y cómo tener varias cadenas.

Intermedio15 min de lectura

Al terminar sabrás

  • Leer y escribir un SecurityFilterChain con el DSL de lambdas de Spring Security 7.
  • Ordenar reglas de autorización sabiendo que gana la primera que coincide.
  • Usar los patrones de PathPatternRequestMatcher sin errores de arranque.
  • Separar API y web con varias cadenas, securityMatcher y @Order.

En Spring Security 7 no hay clase base que extender (WebSecurityConfigurerAdapter desapareció en la 6.0) ni .and() para encadenar (eliminado en la 7.0). Toda la configuración es un @Bean que devuelve un SecurityFilterChain, construido con HttpSecurity y lambdas.

SecurityConfig.java
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth // 1. reglas de autorización
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()) // 2. regla por defecto: siempre al final
.httpBasic(Customizer.withDefaults()) // 3. un mecanismo de autenticación
.formLogin(form -> form.disable()) // 4. apaga lo que no usas
.sessionManagement(s -> s
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build(); // 5. congela la cadena
}
}

Cómo leerlo:

  • Cada método de http (authorizeHttpRequests, httpBasic, csrf, cors, sessionManagement, exceptionHandling, oauth2ResourceServer…) es un configurer: añade, quita o configura uno o más filtros de la cadena.
  • La lambda recibe el configurer y lo ajusta. Customizer.withDefaults() significa “actívalo con su configuración por defecto”.
  • x -> x.disable() quita ese filtro. No es lo mismo que no llamarlo: CSRF, cabeceras de seguridad y gestión de sesión están activos por defecto y solo desaparecen si los desactivas.
  • http.build() congela la cadena.

authorizeHttpRequests funciona como un if / else if: gana la primera regla que coincide. Por eso lo específico va arriba y anyRequest() siempre al final (si lo pones antes de otra regla, la aplicación no arranca).

Evita

.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
// nunca se evalúa: /api/** ya coincidió arriba
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().denyAll())

Prefiere

.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll())

Los strings de requestMatchers("...") se interpretan con PathPatternRequestMatcher, el mismo motor de patrones que usa Spring MVC para mapear tus controladores. AntPathRequestMatcher y MvcRequestMatcher ya no existen.

PatrónCoincide conOjo
/api/notesSolo esa ruta exacta
/api/notes/*/api/notes/5, pero no /api/notes/5/tags* = un segmento
/api/notes/**Todo lo que cuelga de /api/notes (y la propia ruta)** solo al final del patrón
/api/**/admin—Error al arrancar: ** en medio ya no se acepta
/api/notes/{id}/api/notes/5Captura variables, útiles en reglas con access(...)

Puedes filtrar también por método: requestMatchers(HttpMethod.DELETE, "/api/notes/**").

ReglaSignifica
permitAll()Cualquiera, incluido anónimo (los filtros se ejecutan igual)
denyAll()Nadie
authenticated()Cualquier usuario no anónimo
hasRole("ADMIN")Tiene la authority ROLE_ADMIN
hasAnyRole("ADMIN", "MOD")Tiene alguna de ellas
hasAuthority("notes:write")Tiene esa authority exacta, sin prefijo
hasAllAuthorities(...) / hasAllRoles(...)Tiene todas (nuevo en la 7.0)
access(authorizationManager)Lógica propia (lo verás en autorización)

Cuando partes de la aplicación necesitan seguridad distinta (una API stateless con tokens y un panel web con sesión, o el Actuator), declaras varias SecurityFilterChain:

  1. PeticiónGET /api/notes
  2. @Order(1)Cadena APIsecurityMatcher("/api/**") → coincidestatelessHTTP Basic / JWTsin CSRF
  3. @Order(2)Cadena websin securityMatcher: atrapa el restosesiónform loginCSRF
FilterChainProxy prueba las cadenas por orden y ejecuta solo la primera cuyo securityMatcher coincide.
SecurityConfig.java
@Bean
@Order(1)
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**") // esta cadena solo atiende /api/**
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(csrf -> csrf.disable()) // sin cookies de sesión: CSRF no aplica
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
@Bean
@Order(2) // sin securityMatcher: todo lo demás
SecurityFilterChain web(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}

Verás código antiguo con WebSecurityCustomizer y web.ignoring(). Eso saca la ruta de Spring Security por completo: sin cabeceras de seguridad, sin contexto, sin firewall. La recomendación oficial es usar permitAll() incluso para recursos estáticos: la petición sigue siendo pública, pero conserva las protecciones.

Mientras no tengas base de datos (llegará en la siguiente lección), puedes declarar usuarios en memoria:

SecurityConfig.java
@Bean
UserDetailsService users(PasswordEncoder encoder) {
return new InMemoryUserDetailsManager(
User.withUsername("ana").password(encoder.encode("ana-password-123")).roles("USER").build(),
User.withUsername("root").password(encoder.encode("root-password-123")).roles("ADMIN").build());
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

Mini lab · 20 min

Dos cadenas

Objetivo: Que /api/** sea stateless con HTTP Basic y que el resto de la aplicación use formulario y sesión.

  1. Crea SecurityConfig con las dos cadenas de arriba y los usuarios en memoria.
  2. Añade GET /api/admin/stats y protégelo con hasRole("ADMIN") en la cadena de la API (antes de anyRequest()).
  3. Prueba:
Ventana de terminal
curl -i -u ana:ana-password-123 http://localhost:8080/api/admin/stats
curl -i -u root:root-password-123 http://localhost:8080/api/admin/stats
  1. Abre http://localhost:8080/ en el navegador.

Preguntas:

  • El log de arranque muestra dos DefaultSecurityFilterChain. ¿Cuál no tiene CsrfFilter y por qué es correcto?
  • ana recibe 403 en /api/admin/stats. ¿Por qué 403 y no 401?
  • Cambia el @Order(2) de la cadena web por @Order(0). ¿Qué pasa ahora con curl -u ana:ana-password-123 http://localhost:8080/api/admin/stats y por qué?
Pista

Para la última pregunta, piensa en qué cadena prueba primero FilterChainProxy y qué securityMatcher tiene.

Mostrar respuestaOcultar respuesta
  • La cadena de la API, porque la desactivaste con csrf.disable(). Es correcto porque esa cadena es stateless y se autentica con la cabecera Authorization, que el navegador no adjunta solo: no hay cookie de la que un atacante pueda abusar.
  • Porque ana sí está autenticada (sus credenciales son correctas); lo que no tiene es el rol ADMIN. ExceptionTranslationFilter recibe AccessDeniedException con un usuario no anónimo y responde 403. Un 401 sería para credenciales ausentes o incorrectas.
  • La cadena web pasa a evaluarse primero y, como no tiene securityMatcher, coincide con todo, también con /api/**. La petición de curl ya no usa la cadena API: no hay HTTP Basic en la cadena web, así que la cabecera Authorization se ignora, ana llega como anónima y recibe un 302 hacia /login. La cadena API ha dejado de ejecutarse.