Curso JWT en Spring Boot 3 #3: JTI, Blacklisting con Redis y Revocación de Tokens

Parte 3 de 4 — JTI y Blacklisting con Redis

Fuente: Taylor Vick — Unsplash
Bienvenido a la Parte 3 del Curso JWT en Spring Boot 3. En los artículos anteriores construimos una base sólida:
- Parte 1: Configuramos Spring Security con JWT desde cero — filtro de autenticación, generación y validación de tokens, y protección de endpoints con roles.
- Parte 2: Implementamos refresh tokens con rotación segura, cookies HttpOnly y detección de reutilización de tokens robados.
Sin embargo, dejamos un problema crítico sin resolver: si un access token es robado, sigue siendo válido hasta que expire. No importa si el usuario hace logout o si un administrador detecta actividad sospechosa — el token seguirá funcionando durante sus 15 minutos de vida.
En este artículo vas a aprender a resolver este problema de raíz implementando:
- El claim JTI (JWT ID) para identificar de forma única cada token
- Redis como almacén de alta velocidad para la lista negra de tokens
- Un logout real que invalide tanto el access token como el refresh token
- Estrategias de revocación masiva por cambio de contraseña y fuerza bruta administrativa
- Limpieza automática con TTL de Redis y tareas programadas
- Tests unitarios e integración para garantizar que todo funciona correctamente
¿Qué es el JTI (JWT ID)?
El JTI (JWT ID) es un claim estándar definido en la RFC 7519 sección 4.1.7. Su propósito es proporcionar un identificador único para cada token JWT emitido.
Piénsalo así: sin JTI, todos los tokens de un mismo usuario con la misma expiración son idénticos e indistinguibles. Con JTI, cada token tiene su propia "huella digital" que permite rastrearlo, revocarlo o auditarlo individualmente.
Para implementar JTI, generamos un UUID aleatorio cada vez que creamos un token y lo incluimos como claim. Veamos cómo hacerlo con JJWT 0.12:
1import io.jsonwebtoken.Jwts;
2import java.util.UUID;
3import java.util.Date;
4import java.time.Instant;
5import java.time.temporal.ChronoUnit;
6
7@Service
8@RequiredArgsConstructor
9public class JwtService {
10
11 @Value("${jwt.secret}")
12 private String secretKey;
13
14 @Value("${jwt.access-token.expiration}")
15 private long accessTokenExpiration; // 900000 ms = 15 min
16
17 private SecretKey getSigningKey() {
18 byte[] keyBytes = Decoders.BASE64.decode(secretKey);
19 return Keys.hmacShaKeyFor(keyBytes);
20 }
21
22 public String generateAccessToken(UserDetails userDetails) {
23 String jti = UUID.randomUUID().toString();
24 Instant now = Instant.now();
25 Instant expiration = now.plus(accessTokenExpiration, ChronoUnit.MILLIS);
26
27 return Jwts.builder()
28 .id(jti) // <-- claim JTI
29 .subject(userDetails.getUsername())
30 .claim("roles", extractRoles(userDetails))
31 .issuedAt(Date.from(now))
32 .expiration(Date.from(expiration))
33 .signWith(getSigningKey())
34 .compact();
35 }
36
37 public String extractJti(String token) {
38 return Jwts.parser()
39 .verifyWith(getSigningKey())
40 .build()
41 .parseSignedClaims(token)
42 .getPayload()
43 .getId(); // <-- extrae el JTI
44 }
45
46 public String extractUsername(String token) {
47 return Jwts.parser()
48 .verifyWith(getSigningKey())
49 .build()
50 .parseSignedClaims(token)
51 .getPayload()
52 .getSubject();
53 }
54
55 public Date extractExpiration(String token) {
56 return Jwts.parser()
57 .verifyWith(getSigningKey())
58 .build()
59 .parseSignedClaims(token)
60 .getPayload()
61 .getExpiration();
62 }
63
64 private List<String> extractRoles(UserDetails userDetails) {
65 return userDetails.getAuthorities().stream()
66 .map(GrantedAuthority::getAuthority)
67 .toList();
68 }
69}
El payload del token generado se verá así:
1{
2 "jti": "a3f8c1d2-7b4e-4f9a-b6c8-3e2d1f0a9b8c",
3 "sub": "[email protected]",
4 "roles": ["ROLE_USER"],
5 "iat": 1712400000,
6 "exp": 1712400900
7}
Ahora cada token tiene un identificador único que podemos usar como clave en nuestra lista negra.
El Problema de Revocar JWTs
La naturaleza stateless de JWT es su mayor fortaleza y, paradójicamente, su mayor debilidad. Cuando emites un token, el servidor no guarda ninguna referencia a él. La validación se hace completamente con la firma y los claims contenidos en el propio token.
Esto significa que no existe un mecanismo nativo para invalidar un JWT antes de su expiración. Considera estos escenarios donde necesitas revocación inmediata:
- Logout del usuario: El usuario hace clic en "Cerrar sesión", pero su access token sigue siendo válido durante los minutos restantes de vida.
- Cambio de contraseña: El usuario cambia su contraseña (o un admin la resetea), pero las sesiones existentes con tokens antiguos siguen activas.
- Cuenta comprometida: Se detecta actividad sospechosa y necesitas invalidar todas las sesiones del usuario inmediatamente.
- Force-logout administrativo: Un administrador necesita cerrar la sesión de un usuario específico por razones de seguridad o cumplimiento.
- Cambio de roles: Un usuario pierde permisos pero su token actual aún contiene los roles anteriores.
La solución es introducir un componente con estado que actúe como lista negra: cada vez que el servidor recibe un token, verifica si su JTI está en la lista negra antes de aceptarlo. Si está, lo rechaza aunque la firma y la expiración sean válidas.
Estrategias de Revocación
Existen varias estrategias para revocar JWTs. Cada una tiene compromisos diferentes entre complejidad, rendimiento y robustez:
| Estrategia | Ventajas | Desventajas | Caso de uso |
|---|---|---|---|
| Solo expiración corta | Simple, stateless puro | No hay revocación real; ventana de vulnerabilidad | Apps de bajo riesgo |
| Blacklist en base de datos | Persistencia durable, fácil de implementar | Latencia alta por consulta SQL en cada petición | Baja concurrencia |
| Blacklist en Redis | Sub-milisegundo, TTL automático, alta disponibilidad | Dependencia adicional (Redis) | Producción real |
| Versionado de token | Revoca todos los tokens de un usuario a la vez | No revoca tokens individuales; requiere lookup en cada petición | Cambio de contraseña masivo |
En este artículo implementaremos la estrategia de blacklist en Redis, que ofrece el mejor equilibrio entre rendimiento, confiabilidad y simplicidad operativa.
Configurando Redis en Spring Boot
Primero necesitamos agregar la dependencia de Redis y configurar la conexión. Agrega la siguiente dependencia a tu pom.xml:
1<dependency>
2 <groupId>org.springframework.boot</groupId>
3 <artifactId>spring-boot-starter-data-redis</artifactId>
4</dependency>
Configura la conexión a Redis en application.yml:
1spring:
2 data:
3 redis:
4 host: ${REDIS_HOST:localhost}
5 port: ${REDIS_PORT:6379}
6 password: ${REDIS_PASSWORD:}
7 timeout: 2000ms
8 lettuce:
9 pool:
10 max-active: 10
11 max-idle: 5
12 min-idle: 2
13 max-wait: 1000ms
Para desarrollo local, levanta Redis con Docker Compose. Agrega este servicio a tu docker-compose.yml:
1services:
2 redis:
3 image: redis:7-alpine
4 container_name: jwt-course-redis
5 ports:
6 - "6379:6379"
7 command: redis-server --requirepass ${REDIS_PASSWORD:-secretredis}
8 volumes:
9 - redis-data:/data
10 healthcheck:
11 test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-secretredis}", "ping"]
12 interval: 10s
13 timeout: 5s
14 retries: 3
15
16volumes:
17 redis-data:
Ahora configura el bean de RedisTemplate con serializadores adecuados:
1import org.springframework.context.annotation.Bean;
2import org.springframework.context.annotation.Configuration;
3import org.springframework.data.redis.connection.RedisConnectionFactory;
4import org.springframework.data.redis.core.RedisTemplate;
5import org.springframework.data.redis.serializer.StringRedisSerializer;
6
7@Configuration
8public class RedisConfig {
9
10 @Bean
11 public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
12 RedisTemplate<String, String> template = new RedisTemplate<>();
13 template.setConnectionFactory(connectionFactory);
14 template.setKeySerializer(new StringRedisSerializer());
15 template.setValueSerializer(new StringRedisSerializer());
16 template.setHashKeySerializer(new StringRedisSerializer());
17 template.setHashValueSerializer(new StringRedisSerializer());
18 template.afterPropertiesSet();
19 return template;
20 }
21}
redis-cli.
Implementando el Servicio de Blacklist
El corazón de nuestra solución es el TokenBlacklistService. Este servicio tiene dos responsabilidades: agregar tokens a la lista negra y verificar si un token está en ella.
La clave de diseño es que cada entrada en Redis expira automáticamente usando un TTL que coincide con la expiración del token. Esto significa que un token blacklisteado se elimina de Redis exactamente cuando habría expirado de forma natural, evitando que la lista negra crezca indefinidamente.
1import lombok.RequiredArgsConstructor;
2import lombok.extern.slf4j.Slf4j;
3import org.springframework.data.redis.core.RedisTemplate;
4import org.springframework.stereotype.Service;
5
6import java.time.Duration;
7import java.time.Instant;
8import java.util.Date;
9import java.util.concurrent.TimeUnit;
10
11@Service
12@RequiredArgsConstructor
13@Slf4j
14public class TokenBlacklistService {
15
16 private static final String BLACKLIST_PREFIX = "jwt:blacklist:";
17
18 private final RedisTemplate<String, String> redisTemplate;
19
20 /**
21 * Agrega un token a la lista negra con TTL igual a su tiempo restante de vida.
22 *
23 * @param jti identificador único del token
24 * @param expiration fecha de expiración del token
25 * @param reason razón de la revocación (logout, password-change, admin-revoke)
26 */
27 public void blacklistToken(String jti, Date expiration, String reason) {
28 long ttlMillis = expiration.getTime() - System.currentTimeMillis();
29 if (ttlMillis <= 0) {
30 log.debug("Token {} ya expiró, no es necesario blacklistear", jti);
31 return;
32 }
33
34 String key = BLACKLIST_PREFIX + jti;
35 String value = reason + "|" + Instant.now().toString();
36
37 redisTemplate.opsForValue().set(key, value, ttlMillis, TimeUnit.MILLISECONDS);
38 log.info("Token blacklisteado: jti={}, razón={}, TTL={}ms", jti, reason, ttlMillis);
39 }
40
41 /**
42 * Verifica si un token está en la lista negra.
43 *
44 * @param jti identificador único del token
45 * @return true si el token está blacklisteado
46 */
47 public boolean isBlacklisted(String jti) {
48 String key = BLACKLIST_PREFIX + jti;
49 Boolean exists = redisTemplate.hasKey(key);
50 return Boolean.TRUE.equals(exists);
51 }
52
53 /**
54 * Obtiene la razón de blacklisting de un token.
55 *
56 * @param jti identificador único del token
57 * @return razón de la revocación o null si no está blacklisteado
58 */
59 public String getBlacklistReason(String jti) {
60 String key = BLACKLIST_PREFIX + jti;
61 String value = redisTemplate.opsForValue().get(key);
62 if (value != null && value.contains("|")) {
63 return value.split("\\|")[0];
64 }
65 return value;
66 }
67}
Observa los detalles de diseño importantes:
- Prefijo de clave: Usamos
jwt:blacklist:como prefijo para organizar las claves en Redis y evitar colisiones con otras partes de la aplicación. - TTL automático: Cada entrada expira cuando el token habría expirado de forma natural. No necesitamos limpiar manualmente.
- Valor descriptivo: Almacenamos la razón y el timestamp de la revocación, útil para auditoría y debugging.
- Tokens ya expirados: Si el token ya expiró, no tiene sentido agregarlo a la lista negra — ahorramos espacio en Redis.
Integrando el Blacklist en el Filtro JWT
Ahora debemos modificar nuestro JwtAuthenticationFilter para verificar la lista negra en cada petición. La verificación se hace después de validar la firma del token pero antes de establecer el contexto de seguridad:
1import jakarta.servlet.FilterChain;
2import jakarta.servlet.ServletException;
3import jakarta.servlet.http.HttpServletRequest;
4import jakarta.servlet.http.HttpServletResponse;
5import lombok.RequiredArgsConstructor;
6import lombok.extern.slf4j.Slf4j;
7import org.springframework.lang.NonNull;
8import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
9import org.springframework.security.core.context.SecurityContextHolder;
10import org.springframework.security.core.userdetails.UserDetails;
11import org.springframework.security.core.userdetails.UserDetailsService;
12import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
13import org.springframework.stereotype.Component;
14import org.springframework.web.filter.OncePerRequestFilter;
15
16import java.io.IOException;
17
18@Component
19@RequiredArgsConstructor
20@Slf4j
21public class JwtAuthenticationFilter extends OncePerRequestFilter {
22
23 private final JwtService jwtService;
24 private final UserDetailsService userDetailsService;
25 private final TokenBlacklistService tokenBlacklistService;
26
27 @Override
28 protected void doFilterInternal(
29 @NonNull HttpServletRequest request,
30 @NonNull HttpServletResponse response,
31 @NonNull FilterChain filterChain
32 ) throws ServletException, IOException {
33 final String authHeader = request.getHeader("Authorization");
34
35 if (authHeader == null || !authHeader.startsWith("Bearer ")) {
36 filterChain.doFilter(request, response);
37 return;
38 }
39
40 final String jwt = authHeader.substring(7);
41
42 try {
43 final String username = jwtService.extractUsername(jwt);
44 final String jti = jwtService.extractJti(jwt);
45
46 // Verificar blacklist ANTES de autenticar
47 if (tokenBlacklistService.isBlacklisted(jti)) {
48 log.warn("Token blacklisteado detectado: jti={}, usuario={}", jti, username);
49 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
50 response.setContentType("application/json");
51 response.getWriter().write("""
52 {"error": "Token revocado", "message": "Este token ha sido invalidado"}
53 """);
54 return;
55 }
56
57 if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
58 UserDetails userDetails = userDetailsService.loadUserByUsername(username);
59
60 if (jwtService.isTokenValid(jwt, userDetails)) {
61 UsernamePasswordAuthenticationToken authToken =
62 new UsernamePasswordAuthenticationToken(
63 userDetails,
64 null,
65 userDetails.getAuthorities()
66 );
67 authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
68 SecurityContextHolder.getContext().setAuthentication(authToken);
69 }
70 }
71 } catch (Exception e) {
72 log.error("Error procesando token JWT: {}", e.getMessage());
73 }
74
75 filterChain.doFilter(request, response);
76 }
77}
El flujo completo de cada petición autenticada ahora es:
- Extraer el token del header
Authorization - Validar la firma y parsear los claims (incluido el JTI)
- Verificar que el JTI no está en la lista negra de Redis
- Cargar el
UserDetailsdesde la base de datos - Verificar que el token no ha expirado
- Establecer la autenticación en el
SecurityContext
Endpoint de Logout Real
Hasta ahora, un "logout" en una API JWT típica simplemente borraba el token del lado del cliente. Eso es un logout cosmético — el token sigue siendo válido en el servidor. Ahora podemos implementar un logout real:
1import lombok.RequiredArgsConstructor;
2import org.springframework.http.ResponseEntity;
3import org.springframework.security.core.annotation.AuthenticationPrincipal;
4import org.springframework.security.core.userdetails.UserDetails;
5import org.springframework.web.bind.annotation.*;
6
7import java.util.Map;
8
9@RestController
10@RequestMapping("/auth")
11@RequiredArgsConstructor
12public class AuthController {
13
14 private final JwtService jwtService;
15 private final TokenBlacklistService tokenBlacklistService;
16 private final RefreshTokenService refreshTokenService;
17
18 @PostMapping("/logout")
19 public ResponseEntity<Map<String, String>> logout(
20 @RequestHeader("Authorization") String authHeader,
21 @AuthenticationPrincipal UserDetails userDetails
22 ) {
23 String token = authHeader.substring(7);
24 String jti = jwtService.extractJti(token);
25 java.util.Date expiration = jwtService.extractExpiration(token);
26
27 // 1. Blacklistear el access token actual
28 tokenBlacklistService.blacklistToken(jti, expiration, "logout");
29
30 // 2. Revocar todos los refresh tokens del usuario
31 refreshTokenService.revokeAllUserTokens(userDetails.getUsername());
32
33 return ResponseEntity.ok(Map.of(
34 "message", "Sesión cerrada correctamente",
35 "status", "success"
36 ));
37 }
38
39 @PostMapping("/logout-all")
40 public ResponseEntity<Map<String, String>> logoutAll(
41 @RequestHeader("Authorization") String authHeader,
42 @AuthenticationPrincipal UserDetails userDetails
43 ) {
44 String token = authHeader.substring(7);
45 String jti = jwtService.extractJti(token);
46 java.util.Date expiration = jwtService.extractExpiration(token);
47
48 // 1. Blacklistear el access token actual
49 tokenBlacklistService.blacklistToken(jti, expiration, "logout-all");
50
51 // 2. Revocar TODOS los refresh tokens del usuario
52 refreshTokenService.revokeAllUserTokens(userDetails.getUsername());
53
54 // 3. Incrementar la versión de token del usuario (invalida tokens futuros)
55 // Esto es útil si el usuario tiene múltiples access tokens activos
56 // en diferentes dispositivos
57 userService.incrementTokenVersion(userDetails.getUsername());
58
59 return ResponseEntity.ok(Map.of(
60 "message", "Todas las sesiones han sido cerradas",
61 "status", "success"
62 ));
63 }
64}
/logout cierra solo la sesión actual (blacklistea el access token presente y revoca el refresh token asociado). El endpoint /logout-all cierra todas las sesiones del usuario en todos los dispositivos, incrementando además la versión del token para invalidar cualquier access token que aún no haya sido presentado.
El RefreshTokenService necesita un método para revocar todos los tokens de un usuario:
1@Transactional
2public void revokeAllUserTokens(String username) {
3 List<RefreshToken> activeTokens = refreshTokenRepository
4 .findAllByUserEmailAndRevokedFalse(username);
5
6 activeTokens.forEach(token -> {
7 token.setRevoked(true);
8 token.setRevokedAt(Instant.now());
9 token.setRevokedReason("logout");
10 });
11
12 refreshTokenRepository.saveAll(activeTokens);
13 log.info("Revocados {} refresh tokens para usuario {}", activeTokens.size(), username);
14}
Revocación por Cambio de Contraseña
Cuando un usuario cambia su contraseña, es una buena práctica de seguridad invalidar todas las sesiones existentes. Esto protege contra el escenario donde un atacante que conoce la contraseña anterior aún tiene tokens válidos.
1@Service
2@RequiredArgsConstructor
3@Slf4j
4public class PasswordChangeService {
5
6 private final UserRepository userRepository;
7 private final PasswordEncoder passwordEncoder;
8 private final TokenBlacklistService tokenBlacklistService;
9 private final RefreshTokenService refreshTokenService;
10 private final JwtService jwtService;
11
12 @Transactional
13 public void changePassword(
14 String currentToken,
15 String currentPassword,
16 String newPassword,
17 String username
18 ) {
19 // 1. Verificar contraseña actual
20 User user = userRepository.findByEmail(username)
21 .orElseThrow(() -> new UsernameNotFoundException("Usuario no encontrado"));
22
23 if (!passwordEncoder.matches(currentPassword, user.getPassword())) {
24 throw new BadCredentialsException("La contraseña actual es incorrecta");
25 }
26
27 // 2. Actualizar la contraseña
28 user.setPassword(passwordEncoder.encode(newPassword));
29
30 // 3. Incrementar versión de token (invalida todos los access tokens futuros)
31 user.setTokenVersion(user.getTokenVersion() + 1);
32 userRepository.save(user);
33
34 // 4. Blacklistear el access token actual
35 String jti = jwtService.extractJti(currentToken);
36 Date expiration = jwtService.extractExpiration(currentToken);
37 tokenBlacklistService.blacklistToken(jti, expiration, "password-change");
38
39 // 5. Revocar TODOS los refresh tokens del usuario
40 refreshTokenService.revokeAllUserTokens(username);
41
42 log.info("Contraseña cambiada para {}. Todas las sesiones invalidadas.", username);
43 }
44}
El campo tokenVersion en la entidad User es un mecanismo complementario. Al incluir esta versión como claim en el JWT y verificarla en cada petición, puedes invalidar todos los tokens de un usuario de golpe sin tener que conocer sus JTIs individuales:
1// En JwtService: incluir tokenVersion al generar
2public String generateAccessToken(UserDetails userDetails, int tokenVersion) {
3 return Jwts.builder()
4 .id(UUID.randomUUID().toString())
5 .subject(userDetails.getUsername())
6 .claim("roles", extractRoles(userDetails))
7 .claim("tokenVersion", tokenVersion) // <-- versión del token
8 .issuedAt(Date.from(Instant.now()))
9 .expiration(Date.from(Instant.now().plus(accessTokenExpiration, ChronoUnit.MILLIS)))
10 .signWith(getSigningKey())
11 .compact();
12}
13
14// En el filtro: verificar tokenVersion
15int tokenVersion = claims.get("tokenVersion", Integer.class);
16User user = userRepository.findByEmail(username).orElseThrow();
17if (tokenVersion < user.getTokenVersion()) {
18 log.warn("Token con versión obsoleta: token={}, actual={}", tokenVersion, user.getTokenVersion());
19 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
20 return;
21}
Limpieza Automática y Monitoreo
Redis se encarga automáticamente de eliminar las entradas expiradas gracias al TTL que configuramos en cada entrada. Sin embargo, hay algunos aspectos adicionales de mantenimiento que debemos considerar.
Tarea programada para entradas huérfanas
En casos excepcionales (como un reinicio abrupto del servidor durante el proceso de blacklisting), pueden quedar entradas sin TTL. Una tarea programada las detecta y corrige:
1@Component
2@RequiredArgsConstructor
3@Slf4j
4public class BlacklistCleanupTask {
5
6 private final RedisTemplate<String, String> redisTemplate;
7
8 @Scheduled(fixedRate = 3600000) // Cada hora
9 public void cleanupOrphanedEntries() {
10 var keys = redisTemplate.keys("jwt:blacklist:*");
11 if (keys == null || keys.isEmpty()) {
12 return;
13 }
14
15 int orphaned = 0;
16 for (String key : keys) {
17 Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
18 if (ttl != null && ttl == -1) {
19 // Clave sin TTL: establecer expiración de 1 hora como fallback
20 redisTemplate.expire(key, Duration.ofHours(1));
21 orphaned++;
22 }
23 }
24
25 if (orphaned > 0) {
26 log.warn("Encontradas {} entradas huérfanas en blacklist, TTL de 1h asignado", orphaned);
27 }
28 }
29}
Monitoreo con Spring Boot Actuator
Expón métricas personalizadas para monitorear el tamaño y la salud de la lista negra:
1import io.micrometer.core.instrument.Gauge;
2import io.micrometer.core.instrument.MeterRegistry;
3import org.springframework.data.redis.core.RedisTemplate;
4import org.springframework.stereotype.Component;
5
6@Component
7public class BlacklistMetrics {
8
9 public BlacklistMetrics(MeterRegistry meterRegistry, RedisTemplate<String, String> redisTemplate) {
10 Gauge.builder("jwt.blacklist.size", redisTemplate, template -> {
11 var keys = template.keys("jwt:blacklist:*");
12 return keys != null ? keys.size() : 0;
13 })
14 .description("Número de tokens en la lista negra")
15 .tag("store", "redis")
16 .register(meterRegistry);
17 }
18}
Con esto puedes configurar alertas en Prometheus/Grafana para detectar anomalías. Por ejemplo, un pico repentino en el tamaño de la lista negra podría indicar un ataque de fuerza bruta o un problema con la generación de tokens.
jwt.blacklist.size— Número total de tokens blacklisteadosjwt.blacklist.additions— Contador de tokens agregados por minutojwt.blacklist.checks— Contador de verificaciones por minutojwt.blacklist.hits— Tokens rechazados por estar en la lista negra
Testing del Blacklist
Todo código de seguridad debe tener tests exhaustivos. Vamos a cubrir tests unitarios para el servicio y un test de integración para el flujo completo de logout.
Tests unitarios para TokenBlacklistService
1import org.junit.jupiter.api.Test;
2import org.junit.jupiter.api.extension.ExtendWith;
3import org.mockito.InjectMocks;
4import org.mockito.Mock;
5import org.mockito.junit.jupiter.MockitoExtension;
6import org.springframework.data.redis.core.RedisTemplate;
7import org.springframework.data.redis.core.ValueOperations;
8
9import java.util.Date;
10import java.util.concurrent.TimeUnit;
11
12import static org.assertj.core.api.Assertions.assertThat;
13import static org.mockito.ArgumentMatchers.*;
14import static org.mockito.Mockito.*;
15
16@ExtendWith(MockitoExtension.class)
17class TokenBlacklistServiceTest {
18
19 @Mock
20 private RedisTemplate<String, String> redisTemplate;
21
22 @Mock
23 private ValueOperations<String, String> valueOperations;
24
25 @InjectMocks
26 private TokenBlacklistService tokenBlacklistService;
27
28 @Test
29 void blacklistToken_tokenValido_guardaEnRedisConTTL() {
30 // Arrange
31 String jti = "abc-123-def-456";
32 Date expiration = new Date(System.currentTimeMillis() + 600_000); // 10 min
33 when(redisTemplate.opsForValue()).thenReturn(valueOperations);
34
35 // Act
36 tokenBlacklistService.blacklistToken(jti, expiration, "logout");
37
38 // Assert
39 verify(valueOperations).set(
40 eq("jwt:blacklist:" + jti),
41 contains("logout"),
42 longThat(ttl -> ttl > 0 && ttl <= 600_000),
43 eq(TimeUnit.MILLISECONDS)
44 );
45 }
46
47 @Test
48 void blacklistToken_tokenYaExpirado_noGuardaEnRedis() {
49 // Arrange
50 String jti = "expired-token-id";
51 Date expiration = new Date(System.currentTimeMillis() - 1000); // ya expiró
52
53 // Act
54 tokenBlacklistService.blacklistToken(jti, expiration, "logout");
55
56 // Assert
57 verify(redisTemplate, never()).opsForValue();
58 }
59
60 @Test
61 void isBlacklisted_tokenEnListaNegra_retornaTrue() {
62 // Arrange
63 String jti = "blacklisted-token-id";
64 when(redisTemplate.hasKey("jwt:blacklist:" + jti)).thenReturn(true);
65
66 // Act
67 boolean result = tokenBlacklistService.isBlacklisted(jti);
68
69 // Assert
70 assertThat(result).isTrue();
71 }
72
73 @Test
74 void isBlacklisted_tokenNoEnListaNegra_retornaFalse() {
75 // Arrange
76 String jti = "valid-token-id";
77 when(redisTemplate.hasKey("jwt:blacklist:" + jti)).thenReturn(false);
78
79 // Act
80 boolean result = tokenBlacklistService.isBlacklisted(jti);
81
82 // Assert
83 assertThat(result).isFalse();
84 }
85
86 @Test
87 void isBlacklisted_redisRetornaNull_retornaFalse() {
88 // Arrange
89 String jti = "null-response-token";
90 when(redisTemplate.hasKey("jwt:blacklist:" + jti)).thenReturn(null);
91
92 // Act
93 boolean result = tokenBlacklistService.isBlacklisted(jti);
94
95 // Assert
96 assertThat(result).isFalse();
97 }
98}
Test de integración para el flujo de logout
1@SpringBootTest
2@AutoConfigureMockMvc
3class LogoutIntegrationTest {
4
5 @Autowired
6 private MockMvc mockMvc;
7
8 @Autowired
9 private JwtService jwtService;
10
11 @MockBean
12 private TokenBlacklistService tokenBlacklistService;
13
14 @Test
15 void logout_conTokenValido_blacklisteaYRetorna200() throws Exception {
16 // Arrange
17 String token = jwtService.generateAccessToken(testUserDetails());
18
19 // Act & Assert
20 mockMvc.perform(post("/auth/logout")
21 .header("Authorization", "Bearer " + token))
22 .andExpect(status().isOk())
23 .andExpect(jsonPath("$.status").value("success"));
24
25 // Verificar que se llamó al servicio de blacklist
26 verify(tokenBlacklistService).blacklistToken(
27 anyString(), // jti
28 any(Date.class), // expiration
29 eq("logout") // reason
30 );
31 }
32
33 @Test
34 void request_conTokenBlacklisteado_retorna401() throws Exception {
35 // Arrange
36 String token = jwtService.generateAccessToken(testUserDetails());
37 String jti = jwtService.extractJti(token);
38 when(tokenBlacklistService.isBlacklisted(jti)).thenReturn(true);
39
40 // Act & Assert
41 mockMvc.perform(get("/api/protected-resource")
42 .header("Authorization", "Bearer " + token))
43 .andExpect(status().isUnauthorized());
44 }
45}
RedisTemplate completo. En los tests de integración usamos @MockBean para el TokenBlacklistService, lo que nos permite verificar la interacción sin necesitar una instancia real de Redis. Para tests end-to-end, considera usar Testcontainers con una imagen de Redis.
Próximos Pasos
En este artículo implementamos un sistema completo de revocación de tokens JWT usando JTI y Redis. Nuestro sistema de autenticación ahora tiene las siguientes capacidades:
- Cada token tiene un identificador único (JTI) que permite rastrearlo individualmente
- Redis almacena la lista negra con TTL automático que elimina entradas expiradas
- El filtro JWT verifica la blacklist en cada petición con latencia sub-milisegundo
- El logout real invalida tanto el access token como todos los refresh tokens del usuario
- El cambio de contraseña fuerza re-autenticación cerrando todas las sesiones activas
- Las métricas de Actuator permiten monitorear la salud del sistema de blacklisting
- Agregar custom claims para incluir información específica del dominio en los tokens
- Implementar RBAC (Role-Based Access Control) granular con permisos por endpoint
- Aplicar hardening de producción: CORS, rate limiting, headers de seguridad
- Configurar rotación de secretos sin downtime usando múltiples claves de firma
El código completo de este artículo está disponible en el repositorio del curso. Nos vemos en la Parte 4.
Comments
Sign in to leave a comment
No comments yet. Be the first!
Related Articles
Stay updated
Get notified when I publish new articles. No spam, unsubscribe anytime.