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.

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:

  • Un filtro de autenticación que puebla el SecurityContextHolder.
  • Reglas de autorización sobre las peticiones.
  • Manejo centralizado de errores de autenticación y acceso denegado.
  • Protecciones por defecto (CSRF, cabeceras, sesiones) que se ajustan o desactivan según el tipo de aplicación.

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

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.

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.

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

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.

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:

  • AuthenticationEntryPoint: se invoca cuando una petición sin autenticación válida accede a una ruta protegida; responde 401.
  • AccessDeniedHandler: se invoca cuando la identidad está autenticada pero carece de permiso; responde 403.

@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.

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.

  • Probar la cadena con MockMvc y el contexto de seguridad real: rutas públicas sin token, rutas protegidas con y sin token válido.
  • Comprobar los estados: 401 sin identidad, 403 con identidad insuficiente, resultado esperado con acceso permitido.
  • Probar @PreAuthorize con usuarios de prueba para cada rol relevante.
  • Probar el formato JSON de los errores de seguridad, coherente con el resto de la API.

@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.
  • clase/daw/dws/1eval/spring_security.txt
  • Última modificación: 2026/10/21 10:27
  • por cesguiro