Cristhian Villegas
Cursos22 min read1 views

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

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

Blacklisting de tokens JWT 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.

El problema real: Un JWT es stateless por diseño. Una vez emitido, el servidor no tiene forma de invalidarlo antes de su expiración natural. Esto es inaceptable en escenarios como logout, cambio de contraseña o compromiso de cuenta.

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.

Analogía: El JTI es como el número de serie de un billete. Dos billetes de 100 pesos tienen el mismo valor, pero cada uno tiene un número de serie único que permite identificarlo si es robado o falsificado.

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:

java
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í:

json
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.
Riesgo de seguridad: Sin un mecanismo de revocación, un access token robado es una "llave maestra" válida hasta que expire. Con una expiración de 15 minutos, un atacante tiene una ventana generosa para causar daño.

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
Recomendación: Para aplicaciones en producción, Redis es la opción estándar de la industria. Su latencia sub-milisegundo no impacta perceptiblemente el rendimiento, y el TTL automático elimina la necesidad de tareas de limpieza manuales para la mayoría de los casos.

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:

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:

yaml
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:

yaml
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:

java
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}
¿Por qué StringRedisSerializer? Nuestras claves serán JTIs (UUIDs como strings) y los valores serán el nombre de usuario o la razón de la revocación. Usar StringRedisSerializer evita problemas de serialización y hace que las claves sean legibles en herramientas como 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.

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

java
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}
Orden de verificación: Es importante verificar la blacklist después de extraer el JTI (lo que implica validar la firma) y antes de establecer la autenticación. Si verificas la blacklist antes de validar la firma, un atacante podría enviar tokens malformados para generar consultas innecesarias a Redis.

El flujo completo de cada petición autenticada ahora es:

  1. Extraer el token del header Authorization
  2. Validar la firma y parsear los claims (incluido el JTI)
  3. Verificar que el JTI no está en la lista negra de Redis
  4. Cargar el UserDetails desde la base de datos
  5. Verificar que el token no ha expirado
  6. 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:

java
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}
Diferencia entre /logout y /logout-all: El endpoint /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:

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

java
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}
No olvides el token actual: Un error común es revocar los refresh tokens pero olvidar blacklistear el access token con el que se hizo la petición de cambio de contraseña. Si no lo blacklisteas, ese token seguirá siendo válido hasta que expire, incluso después del cambio de contraseña.

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:

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

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

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

Métricas recomendadas:
  • jwt.blacklist.size — Número total de tokens blacklisteados
  • jwt.blacklist.additions — Contador de tokens agregados por minuto
  • jwt.blacklist.checks — Contador de verificaciones por minuto
  • jwt.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

java
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

java
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}
Patrón de testing: En los tests unitarios mockeamos 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
En la Parte 4 (final del curso) llevaremos nuestra API a producción. Aprenderás a:
  • 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.

Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

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.