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