Ir al contenido

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.

Intermedio17 min de lectura

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.

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:

  1. Contraseñapassword
  2. Hash5BAA61E4C9B…68FD8
  3. API de HIBPdevuelve todos los sufijos con ese prefijo
  4. Comparación local¿está nuestro sufijo en la lista?
k-anonimato: solo salen los 5 primeros caracteres del hash; la comparación final ocurre en tu servidor.
SecurityConfig.java
@Bean
CompromisedPasswordChecker 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:

RegistrationService.java
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"));
}

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:

DbUserDetailsService.java
@Service
class 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();
}
}
  • 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-password a tu página /change-password: así los gestores de contraseñas saben dónde llevar al usuario.

“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 cookieusuario + caducidad + firmauna serie y un token aleatorios
Estado en servidorNingunoTabla persistent_logins
RevocarCambiando la contraseña o la claveBorrando la fila
Detecta robo de cookieNoSí: si se reutiliza un token viejo de la serie, invalida todo
SecurityConfig.java
@Bean
PersistentTokenRepository persistentTokenRepository(DataSource dataSource) {
JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
repo.setDataSource(dataSource);
return repo;
}
@Bean
SecurityFilterChain 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();
}

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:

LoginRateLimitFilter.java
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:

LoginAttempts.java
@Component
class 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.

  1. Declara el bean CompromisedPasswordChecker e intenta registrar un usuario con la contraseña password123. Haz que el registro responda 400 con un ProblemDetail claro.
  2. Cambia tu PasswordEncoder para que los hashes nuevos usen Argon2 ({argon2}) manteniendo la verificación de los {bcrypt} existentes, e implementa UserDetailsPasswordService. Inicia sesión con un usuario antiguo y mira su hash en la base de datos.
  3. Añade el LoginRateLimitFilter y lanza 15 intentos seguidos con curl.

Preguntas:

  • ¿Qué prefijo tiene el hash del usuario antiguo después de iniciar sesión? ¿Por qué cambió?
  • ¿En qué intento empieza a responder 429 y 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, DaoAuthenticationProvider pregunta a PasswordEncoder.upgradeEncoding(hash); como el algoritmo del hash no es el de por defecto, re-codifica la contraseña recibida y llama a updatePassword. 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 LoginAttempts sobre admin), 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.