Spring SecurityLección 04
Contraseñas comprometidas, remember-me y fuerza bruta
El ciclo de vida de una credencial en Spring Security. Rechazar contraseñas filtradas, migrar hashes sin que el usuario lo note, recordar la sesión sin abrir un agujero y frenar la fuerza bruta.
Al terminar sabrás
- Rechazar contraseñas filtradas con
CompromisedPasswordChecker. - Re-hashear contraseñas antiguas automáticamente con
UserDetailsPasswordService. - Configurar remember-me persistente y exigir login completo en operaciones sensibles.
- Frenar la fuerza bruta con límites y eventos de autenticación.
En la lección anterior viste cómo se verifica una contraseña. Pero una credencial tiene una vida entera alrededor de ese momento: se elige, se guarda, envejece, se recuerda entre visitas y, a veces, alguien intenta adivinarla mil veces por minuto. Spring Security tiene piezas para cada fase.
Rechazar contraseñas filtradas
Sección titulada «Rechazar contraseñas filtradas»Millones de contraseñas reales circulan en listas filtradas, y son las primeras que prueba un atacante. Spring Security (desde la 6.3) puede consultarlas con la API de Have I Been Pwned sin enviar nunca la contraseña:
- Contraseña
password - Hash
5BAA61E4C9B…68FD8 - API de HIBPdevuelve todos los sufijos con ese prefijo
- Comparación local¿está nuestro sufijo en la lista?
@BeanCompromisedPasswordChecker compromisedPasswordChecker() { return new HaveIBeenPwnedRestApiPasswordChecker();}Con ese bean, el login con usuario y contraseña rechaza las contraseñas filtradas lanzando CompromisedPasswordException (puedes capturarla en el AuthenticationFailureHandler para redirigir al usuario a cambiarla). Úsalo también al registrar y al cambiar la contraseña:
public void register(String username, String password) { if (checker.check(password).isCompromised()) { throw new WeakPasswordException("Esa contraseña aparece en filtraciones públicas. Elige otra."); } users.save(new UserAccount(username, encoder.encode(password), "USER"));}Migrar hashes sin que nadie lo note
Sección titulada «Migrar hashes sin que nadie lo note»Si tus contraseñas están en bcrypt con coste 10 y quieres pasar a Argon2 (o subir el coste), no puedes re-hashearlas en bloque: no conoces las contraseñas. Pero en cada login sí la conoces durante un instante. Implementa UserDetailsPasswordService y DaoAuthenticationProvider la actualizará cuando PasswordEncoder.upgradeEncoding diga que el hash está anticuado:
@Serviceclass DbUserDetailsService implements UserDetailsService, UserDetailsPasswordService {
private final UserAccountRepository repo;
DbUserDetailsService(UserAccountRepository repo) { this.repo = repo; }
@Override public UserDetails loadUserByUsername(String username) { UserAccount u = repo.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("no existe")); return User.withUsername(u.getUsername()).password(u.getPassword()).roles(u.getRole()).build(); }
@Override @Transactional public UserDetails updatePassword(UserDetails user, String newEncodedPassword) { UserAccount u = repo.findByUsername(user.getUsername()).orElseThrow(); u.setPassword(newEncodedPassword); // ya viene hasheado con el algoritmo nuevo return User.withUserDetails(user).password(newEncodedPassword).build(); }}Cambiar la contraseña
Sección titulada «Cambiar la contraseña»- Exige la contraseña actual (o un login reciente) para cambiarla: si alguien roba una sesión, no debería poder quedarse con la cuenta.
- Invalida las demás sesiones y los tokens de remember-me del usuario.
http.passwordManagement(Customizer.withDefaults())redirige/.well-known/change-passworda tu página/change-password: así los gestores de contraseñas saben dónde llevar al usuario.
Remember-me
Sección titulada «Remember-me»“Recuérdame” mantiene al usuario identificado tras cerrar el navegador mediante una cookie de larga duración. Hay dos implementaciones:
Basada en hash (TokenBasedRememberMeServices) | Persistente (PersistentTokenBasedRememberMeServices) | |
|---|---|---|
| Qué contiene la cookie | usuario + caducidad + firma | una serie y un token aleatorios |
| Estado en servidor | Ninguno | Tabla persistent_logins |
| Revocar | Cambiando la contraseña o la clave | Borrando la fila |
| Detecta robo de cookie | No | Sí: si se reutiliza un token viejo de la serie, invalida todo |
@BeanPersistentTokenRepository persistentTokenRepository(DataSource dataSource) { JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl(); repo.setDataSource(dataSource); return repo;}
@BeanSecurityFilterChain web(HttpSecurity http, PersistentTokenRepository tokens) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/account/**").fullyAuthenticated() // remember-me NO basta aquí .anyRequest().authenticated()) .formLogin(Customizer.withDefaults()) .rememberMe(r -> r .tokenRepository(tokens) .tokenValiditySeconds((int) Duration.ofDays(14).toSeconds())); return http.build();}Frenar la fuerza bruta
Sección titulada «Frenar la fuerza bruta»Spring Security no limita los intentos de login por sí solo. Combina dos piezas:
1. Un límite de ritmo delante del login (repasa abuso de APIs). Un filtro sencillo con un bucket por IP:
class LoginRateLimitFilter extends OncePerRequestFilter {
private final Map<String, TokenBucket> buckets = new ConcurrentHashMap<>();
@Override protected boolean shouldNotFilter(HttpServletRequest request) { return !("POST".equals(request.getMethod()) && "/login".equals(request.getServletPath())); }
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { TokenBucket bucket = buckets.computeIfAbsent(request.getRemoteAddr(), ip -> new TokenBucket(10, Duration.ofMinutes(1))); if (!bucket.tryConsume()) { response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.setHeader(HttpHeaders.RETRY_AFTER, "60"); return; } chain.doFilter(request, response); }}http.addFilterBefore(new LoginRateLimitFilter(), UsernamePasswordAuthenticationFilter.class);(En producción, con varias instancias, el estado de los buckets debe vivir en Redis o en el API gateway; Bucket4j lo resuelve).
2. Retraso progresivo por cuenta, escuchando los eventos del framework:
@Componentclass LoginAttempts {
private final Map<String, Integer> failures = new ConcurrentHashMap<>();
@EventListener void onFailure(AuthenticationFailureBadCredentialsEvent e) { failures.merge(e.getAuthentication().getName(), 1, Integer::sum); }
@EventListener void onSuccess(AuthenticationSuccessEvent e) { failures.remove(e.getAuthentication().getName()); }
Duration delayFor(String username) { // 0, 1, 2, 4, 8… hasta 30 s int n = failures.getOrDefault(username, 0); return n < 3 ? Duration.ZERO : Duration.ofSeconds(Math.min(30, 1L << (n - 3))); }}Aplica delayFor(username) antes de autenticar (por ejemplo, en un filtro como el anterior que lea el parámetro username) para que cada intento fallido contra la misma cuenta cueste más tiempo, vengan de la IP que vengan.
Mini lab · 35 min
Endurece el login
Objetivo: Rechazar contraseñas filtradas, migrar hashes y limitar los intentos de login de la API de notas.
- Declara el bean
CompromisedPasswordCheckere intenta registrar un usuario con la contraseñapassword123. Haz que el registro responda400con unProblemDetailclaro. - Cambia tu
PasswordEncoderpara que los hashes nuevos usen Argon2 ({argon2}) manteniendo la verificación de los{bcrypt}existentes, e implementaUserDetailsPasswordService. Inicia sesión con un usuario antiguo y mira su hash en la base de datos. - Añade el
LoginRateLimitFiltery lanza 15 intentos seguidos concurl.
Preguntas:
- ¿Qué prefijo tiene el hash del usuario antiguo después de iniciar sesión? ¿Por qué cambió?
- ¿En qué intento empieza a responder
429y qué cabecera te dice cuándo reintentar? - Un atacante con una botnet de 10 000 IPs prueba 1 contraseña por IP contra la cuenta
admin. ¿Lo frena tu filtro? ¿Qué lo frenaría?
Pista
Para el punto 2, crea un DelegatingPasswordEncoder con "argon2" como id por defecto y un mapa que incluya también "bcrypt".
Mostrar respuestaOcultar respuesta
- Pasa de
{bcrypt}a{argon2}. En el login correcto,DaoAuthenticationProviderpregunta aPasswordEncoder.upgradeEncoding(hash); como el algoritmo del hash no es el de por defecto, re-codifica la contraseña recibida y llama aupdatePassword. El usuario no nota nada. - En el 11.º intento (capacidad 10 por minuto), con
Retry-After: 60. - No: cada IP hace un solo intento, así que ningún bucket por IP se vacía. Lo frenan el límite por cuenta objetivo (retraso progresivo de
LoginAttemptssobreadmin), un límite global del endpoint, la comprobación de contraseñas filtradas y, sobre todo, MFA: aunque acierten la contraseña, falta el segundo factor.