Tabla de Contenidos

07 - Integración con Spring Security

En 1.º DAW el login con tokens se implementó con un filtro Servlet propio: extraer el token, autenticar, dejar el Principal en el contexto de la petición y comprobar los roles. Spring Security ofrece esa infraestructura hecha: una cadena de filtros, gestión del contexto de seguridad y reglas de autorización declarativas. El flujo de autenticación no cambia; lo que cambia es quién construye y orquesta la maquinaria.

1. La cadena de filtros

Spring Security instala una cadena de filtros delante de los controller: cada petición la recorre y puede terminar en un error o continuar con la identidad establecida. Aporta entre otros:

En una API REST stateless con JWT, el filtro propio del tema anterior se integra en esa cadena en lugar de registrarse aparte.

ClienteCadena de filtrosJwtAuthenticationFilterControllerClienteCadena de filtrosSpring SecurityJwtAuthenticationFilterControllerClienteCadena de filtrosSpring SecurityJwtAuthenticationFilterControllerpeticiónextrae y valida Bearer JWTconstruye Authenticationautoriza según reglascontinúa si está autorizado401 / 403 si falla

2. Configuración

Un @Configuration declara un bean SecurityFilterChain que define el comportamiento de la cadena:

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(
            HttpSecurity http,
            JwtAuthenticationFilter jwtFilter,
            AuthenticationEntryPoint entryPoint,
            AccessDeniedHandler accessDeniedHandler) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .sessionManagement(session ->
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers(HttpMethod.POST, "/auth/login").permitAll()
                        .anyRequest().authenticated())
                .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(handling -> handling
                        .authenticationEntryPoint(entryPoint)
                        .accessDeniedHandler(accessDeniedHandler));
        return http.build();
    }
}

Decisiones de esta configuración para una API con tokens:

Decisión Motivo
STATELESS Sin sesiones ni cookies: la identidad viaja en cada petición en el token
CSRF desactivado El ataque CSRF explota la autenticación por cookie; con Authorization: Bearer no aplica
formLogin y httpBasic desactivados El login es un endpoint propio que valida credenciales y emite tokens
addFilterBefore El filtro JWT se sitúa antes del filtro estándar de autenticación
Rutas públicas con permitAll Solo el login y los recursos públicos; todo lo demás exige identidad
Desactivar funcionalidades no es opcional por capricho: cada desactivación responde a que el mecanismo asociado (sesión, cookie, formulario) no forma parte del diseño de la API. Si la aplicación usara cookies, el CSRF volvería a ser necesario.

3. El filtro de autenticación

El filtro extiende OncePerRequestFilter —se ejecuta una vez por petición— y delega la validación del token en el servicio de autenticación del dominio, como en el filtro propio:

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final AuthService authService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws ServletException, IOException {
        String accessToken = extractAccessToken(request.getHeader("Authorization"));
        if (accessToken != null) {
            authService.authenticate(accessToken).ifPresent(principal -> {
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                                principal, null,
                                principal.roles().stream()
                                        .map(SimpleGrantedAuthority::new)
                                        .toList());
                SecurityContextHolder.getContext().setAuthentication(authentication);
            });
        }
        chain.doFilter(request, response);
    }
}

La diferencia con el filtro propio está en el resultado: en lugar de un atributo de petición, se publica en el SecurityContextHolder un UsernamePasswordAuthenticationToken con las authorities derivadas de los roles. A partir de aquí, el resto de Spring Security —autorización, expresiones, anotaciones— trabaja con esa identidad.

Si el token falta o es inválido, no se publica autenticación y la petición continúa; será la autorización quien decida si la ruta exige identidad.

4. Autorización

4.1 Por rutas

Las reglas declaradas en authorizeHttpRequests deciden por URL y método. Se evalúan en orden: la primera que aplica gana.

4.2 Por método

Con @EnableMethodSecurity, la anotación @PreAuthorize expresa el requisito en el propio controller:

@DeleteMapping("/users/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> delete(@PathVariable long id) {
    // Solo con ROLE_ADMIN.
}

Enfoque Se expresa en Adecuado para
Por rutas Configuración central Reglas generales de zonas enteras de la API
Por método @PreAuthorize en el controller Requisitos concretos de una operación

Los dos enfoques pueden combinarse: las reglas por rutas marcan el perímetro y @PreAuthorize refina operaciones concretas.

5. Errores de seguridad

Sin configuración, Spring Security responde con HTML o redirecciones propias de aplicaciones web. En una API REST con JSON, se sustituyen los dos puntos de fallo:

@Component
public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException exception) throws IOException {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write("""
                {"code":"UNAUTHORIZED","message":"Autenticación requerida"}
                """);
    }
}

Ambos devuelven el mismo formato de error JSON que el resto de la API, coherente con el contrato público. No exponen detalles internos del fallo de autenticación.

6. Contraseñas

Spring Security aporta PasswordEncoder con implementaciones de hash adaptativo como BCrypt. El login del dominio compara con matches y el registro codifica con encode:

@Bean
PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

El hash de la contraseña es un detalle de persistencia y de autenticación, no del contrato: nunca se expone en DTO de respuesta, como se vio al estudiar los DTO de entrada y salida.

7. Pruebas

@WebMvcTest(DemoAccessController.class)
@Import(SecurityConfig.class)
class SecurityTest {
    @Autowired MockMvc mockMvc;

    @Test
    void protectedRouteWithoutTokenIs401() throws Exception {
        mockMvc.perform(get("/demo/protected"))
                .andExpect(status().isUnauthorized());
    }
}

A diferencia de las pruebas de controller que simulan los servicios, aquí se importa la configuración de seguridad real: lo que se prueba es precisamente la integración entre la cadena, el filtro y las reglas.

El filtro del tema de JWT ya delegaba la autenticación en el dominio; con Spring Security solo cambia dónde se publica el resultado y quién aplica la autorización. Si el flujo cambia de framework, el dominio no se entera.