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