====== 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: * 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. @startuml actor Cliente participant "Cadena de filtros\nSpring Security" as Chain participant "JwtAuthenticationFilter" as Jwt participant Controller Cliente -> Chain : petición Chain -> Jwt : extrae y valida Bearer JWT Jwt -> Jwt : construye Authentication Chain -> Chain : autoriza según reglas Chain -> Controller : continúa si está autorizado Chain --> Cliente : 401 / 403 si falla @enduml ===== 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 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: * **''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. ===== 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 ===== * 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.