Spring Security: Invalidación en memoria de tokens JWT durante el cierre de sesión del usuario

En esta guía, profundizaremos en cómo invalidar los tokens JWT cuando un usuario cierra sesión en una aplicación basada en Spring, usando Spring Security.

Introducción

A medida que la tecnología evoluciona y se vuelve más predominante, incluida la evolución de arquitecturas orientadas a servicios a gran escala, la gestión de la seguridad web se vuelve cada vez más compleja. Hay muchos más casos extremos ahora que antes, y mantener la información personal del usuario segura y protegida es cada vez más difícil. Sin medidas de seguridad proactivas, las empresas corren el riesgo de filtrar información confidencial, y en la era de la información, esto puede convertirse en un gran problema para los usuarios en línea.

Esta es la razón por la cual la seguridad debe ser primero, y no como una idea de último momento, al crear aplicaciones.

Muchos usuarios terminan creando muchas cuentas diferentes a través de varios navegadores y dispositivos, lo que significa que también debemos considerar y realizar un seguimiento de los diversos dispositivos que los usuarios usan para iniciar sesión, no sea que terminemos bloqueándolos de su propia cuenta por accidente, pensando que alguien ganó acceso no autorizado, mientras que en realidad, el usuario simplemente se fue de viaje y usó su teléfono en el wi-fi del hotel.

En esta guía, analizaremos la estrategia de seguridad proactiva común de invalidar un token JWT cuando un usuario cierra sesión en un sistema, desde un dispositivo específico.

Nota: Esta guía asume que ya configuró Spring Security Authentication y tiene como objetivo proporcionar orientación sobre invalidar tokens JWT, de una manera independiente de la implementación. Ya sea que haya definido sus propios roles y autoridades o haya utilizado la GrantedAuthority de Spring, su propio Usuario o confiado en UserDetails de Spring, no importará mucho. Dicho esto, algunos de los filtros, clases y configuraciones subyacentes no estarán disponibles en la guía en sí, ya que podrían diferir para su aplicación.

Si desea consultar la implementación específica utilizada en esta guía, incluida toda la configuración que no se muestra aquí, puede acceder al código fuente completo en [GitHub](https://github. com/arpendu11/spring-security-jwt-jpa).

Seguridad de primavera {#seguridad de primavera}

Seguridad de primavera es un marco simple pero poderoso que permite a un ingeniero de software imponer restricciones de seguridad en aplicaciones web basadas en Spring a través de varios componentes JEE. Es un marco personalizable y fácil de ampliar que se centra en la provisión de funciones de autenticación y control de acceso para aplicaciones basadas en Spring.

En esencia, se ocupa de tres obstáculos principales:

  • Autenticación: comprueba si el usuario es la persona adecuada para acceder a algunos recursos restringidos. Se encarga de dos procesos básicos: identificación (quién es el usuario) y verificación (si el usuario es quien dice ser).
  • Autorización: Garantiza que un usuario tenga acceso solo a aquellas partes del recurso para las que ha sido autorizado mediante una combinación de Roles y Permisos.
  • Filtros de servlet: cualquier aplicación web de Spring es solo un servlet que redirige las solicitudes HTTP entrantes a @Controller o @RestController. Dado que no hay una implementación de seguridad dentro del DispatcherServlet principal, necesita filtros como SecurityFilter delante de los servlets para que la Autenticación y la Autorización se atiendan antes de redirigir a los Controladores. .

Nota: Vale la pena señalar que algunos usan los términos "Función" y "Permiso" indistintamente, lo que puede resultar un poco confuso para los estudiantes. Los roles tienen un conjunto de permisos. Un Administrador (Rol) puede tener permisos para realizar X e Y, mientras que un Ingeniero puede tener permisos para realizar Y y Z.

Fichas web JSON

Un JWT (token web JSON) es un token que facilita el enfoque sin estado para manejar la autenticación de usuarios. Ayuda a realizar la autenticación sin almacenar su estado en forma de una sesión o un objeto de base de datos. Cuando el servidor intenta autenticar a un usuario, no accede a la sesión del usuario ni realiza ninguna consulta a la base de datos de ningún tipo. Este token se genera con la ayuda de una carga útil de entidad de usuario y objetos internos conocidos como reclamaciones y los clientes lo utilizan para identificar al usuario en el servidor.

Un JWT se compone de la siguiente estructura:

1
header.payload.signature
  • Encabezado: contiene toda la información relevante sobre cómo se puede interpretar o firmar un token.
  • Carga útil: contiene reclamaciones en forma de objeto de datos de usuario o entidad. Por lo general, hay tres tipos de reclamos: reclamos registrados, públicos y privados.
  • Firma: Compuesto por el encabezado, payload, un secreto y el algoritmo de codificación. Todos los contenidos están firmados y algunos de ellos codificados por defecto.

Si desea leer más sobre JWT, lea nuestra guía sobre Comprender los tokens web JSON (JWT).

Ciclo de vida del token web JSON

Echemos un vistazo al ciclo de vida clásico de JWT, desde el momento en que un usuario intenta iniciar sesión:

User Login Workflow

En el diagrama, el cliente pasa sus credenciales de usuario en forma de solicitud al servidor. El servidor, después de realizar la identificación y la verificación, devuelve un token JWT como respuesta. De ahora en adelante, el cliente utilizará este token JWT para solicitar acceso a puntos finales seguros.

Por lo general, el usuario intentará acceder a algún punto final o recurso seguro después de iniciar sesión:

User Accessing Endpoint Workflow

Sin embargo, esta vez, el cliente pasa el token JWT que adquirió antes con la solicitud para acceder a datos protegidos. El servidor realizará una introspección del token y realizará una autenticación y autorización sin estado y brindará acceso a contenido seguro que se devuelve como respuesta.

Finalmente, una vez que el usuario haya terminado con la aplicación, normalmente cerrará sesión:

User Logout Workflow

Si el usuario desea cerrar sesión en el sistema, el cliente le pediría al servidor que cierre la sesión del usuario en un dispositivo específico e invalide todas sus sesiones activas. Al hacer eso, el servidor podría cerrar todas las * sesiones de usuario * pero no podrá invalidar el token JWT ya que es * un objeto inmutable y sin estado *.

Esto puede convertirse rápidamente en un problema: cuando un usuario cierra la sesión, el token JWT debe invalidarse para su uso posterior. Además, si alguien intenta acceder a un recurso restringido con un token invalidado, no se le debe permitir el acceso, con un mecanismo para recuperarse de este estado excepcional.

¿Cómo podemos invalidar tokens? Podemos hacer que caduquen rápidamente, incluir tokens caducados/eliminados en la lista negra y/o rotarlos a través de un token de actualización emitido junto con el JWT.

Avancemos y configuremos Spring Security para realizar la invalidación en memoria de los tokens JWT, cuando un usuario cierra la sesión.

Configuración de Spring Boot y Spring Security

Ahora que hemos solucionado los JWT y el problema principal, inicialicemos una aplicación Spring Boot simple y configurémosla. La forma más fácil de comenzar con un proyecto básico es a través de Spring Initializr:

Spring Initializr

Agregamos la dependencia de Spring Security porque nos gustaría incluir y aprovechar el módulo para manejar la seguridad por nosotros. También hemos incluido los módulos Spring Web y Spring Data JPA ya que, en última instancia, estamos creando una aplicación web que tiene una capa de persistencia. El uso de Lombok es opcional, ya que es una biblioteca conveniente que nos ayuda a reducir el código repetitivo, como captadores, definidores y constructores, simplemente al anotar nuestras entidades con anotaciones Lombok.

También necesitaremos importar algunas dependencias adicionales, que no están disponibles en el inicializador de Spring. Es decir, importaremos la biblioteca JWT, así como la biblioteca de mapas que caducan. Expiring Map nos presenta una implementación ConcurrentMap de alto rendimiento y segura para subprocesos que caduca las entradas, lo que utilizaremos para caducar ciertos tokens:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<!--Jwt-->
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt</artifactId>
  <version>0.9.1</version>
</dependency>
        
<!--Expiring Map-->
<dependency>
   <groupId>net.jodah</groupId>
   <artifactId>expiringmap</artifactId>
   <version>0.5.9</version>
</dependency>

Implementación de una aplicación web Spring Boot

Asignación de dispositivos a usuarios al iniciar sesión

Cada vez más, los usuarios inician sesión en los sistemas a través de diferentes dispositivos. Un escenario genérico y común es un usuario que inicia sesión a través de un sitio web de escritorio y un teléfono inteligente. De forma predeterminada, en ambos casos, el back-end generará el mismo token JWT para un correo electrónico determinado, ya que el correo electrónico es el identificador. Una vez que el usuario cierra la sesión de la aplicación en su escritorio, también cerrará la sesión de su teléfono.

Una forma de resolver esto, si no es la funcionalidad que imaginó, es pasar la información del dispositivo al enviar la solicitud de inicio de sesión, junto con el nombre de usuario y la contraseña. Para generar una ID única desde el dispositivo la primera vez que un usuario intenta iniciar sesión, podemos aprovechar la biblioteca Huella digital.js del cliente frontend.

Querremos asignar varios dispositivos a un usuario, ya que un usuario puede usar más de un dispositivo, por lo que necesitaremos un mecanismo para asignar un dispositivo a una sesión de inicio de sesión de usuario. También querremos generar un token de actualización para mantener la misma sesión de usuario (actualizando la caducidad) siempre que estén conectados. Una vez que hayan cerrado la sesión, podemos dejar que el token JWT caduque, y invalidarlo.

Dicho esto, necesitaremos asignar un dispositivo así como el token de actualización a la sesión de un usuario.

Dado que tenemos un mecanismo para identificar dispositivos, implementemos la funcionalidad para asignar un dispositivo de usuario a una sesión de inicio de sesión de usuario. También necesitaremos generar el token de actualización para mantener la misma sesión de usuario en todo momento. Entonces, también hablaremos sobre cómo podemos asignar un token de actualización con el dispositivo del usuario a la sesión del usuario.

Modelo de dominio: definición de entidades

Comencemos con el modelo de dominio y las entidades que usaremos. Es decir, comencemos con User y UserDevice:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Lombok annotations for getters, setters and constructor
@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_seq")
    private Long id;  
    private String email;
    private String password;
    private String name;
    private Boolean active;
    
    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id"))
    private Set<Role> roles = new HashSet<>();
    
    public void activate() {
            this.active = true;
      }
    
      public void deactivate() {
            this.active = false;
    }
}

Este ‘Usuario’ utilizará algún tipo de dispositivo para enviar una solicitud de inicio de sesión. Definamos también el modelo UserDevice:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Lombok annotations for getters, setters and constructor
@Entity
public class UserDevice {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_device_seq")
    private Long id;
  
    private User user;
    private String deviceType;
    private String deviceId;

    @OneToOne(optional = false, mappedBy = "userDevice")
    private RefreshToken refreshToken;
    private Boolean isRefreshActive;
}

Finalmente, también nos gustaría tener un RefreshToken para cada dispositivo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Lombok annotations
@Entity
public class RefreshToken {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "refresh_token_seq")
    private Long id;
    private String token;
  
    @OneToOne(optional = false, cascade = CascadeType.ALL)
    @JoinColumn(name = "USER_DEVICE_ID", unique = true)
    private UserDevice userDevice;
    private Long refreshCount;
    private Instant expiryDate;    
    
    public void incrementRefreshCount() {
        refreshCount = refreshCount + 1;
    }
}
Objetos de transferencia de datos: definición de la carga útil de la solicitud

Ahora, definamos los Objetos de transferencia de datos para la API entrante solicitar carga útil. Necesitaremos un DTO DeviceInfo que simplemente contenga deviceId y deviceType para nuestro modelo UserDevice. También tendremos un DTO LoginForm, que contiene las credenciales del usuario y el DTO DeviceInfo.

El uso de ambos nos permite enviar la información mínima requerida para autenticar a un usuario dado su dispositivo y asignar el dispositivo a su sesión:

1
2
3
4
5
6
7
// Lombok annotations
public class DeviceInfo {

    // Payload Validators
    private String deviceId;
    private String deviceType;
}
1
2
3
4
5
6
7
8
// Lombok annotations
public class LoginForm {

    // Payload Validators
    private String email;
    private String password;
    private DeviceInfo deviceInfo;
}

Vamos a crear también la carga útil JWTResponse que contiene todos los tokens y la duración de vencimiento. Esta es la respuesta generada del servidor al cliente que se usa para verificar un cliente y se puede utilizar más para realizar solicitudes a puntos finales seguros:

1
2
3
4
5
6
7
// Lombok annotations
public class JwtResponse {   
    private String accessToken;
    private String refreshToken;
    private String tokenType = "Bearer";
    private Long expiryDuration;
}

Dado que hemos definido dos nuevas entidades, UserDevice y RefreshToken, definamos sus repositorios para que podamos realizar operaciones CRUD en estas entidades.

Capa de persistencia: definición de repositorios
1
2
3
4
5
6
7
public interface UserDeviceRepository extends JpaRepository<UserDevice, Long> {

    @Override
    Optional<UserDevice> findById(Long id);
    Optional<UserDevice> findByRefreshToken(RefreshToken refreshToken);
    Optional<UserDevice> findByUserId(Long userId);
}
1
2
3
4
5
6
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {

    @Override
    Optional<RefreshToken> findById(Long id);
    Optional<RefreshToken> findByToken(String token);
}
Capa de servicio: definición de servicios

Ahora, querremos tener servicios intermediarios que interactúen con los controladores que nos permitan usar los repositorios. Vamos a crear la capa de servicio para manejar las solicitudes de operación CRUD para las entidades UserDevice y RefreshToken:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Service
public class UserDeviceService {

    // Autowire Repositories

    public Optional<UserDevice> findByUserId(Long userId) {
        return userDeviceRepository.findByUserId(userId);
    }

    // Other Read Services

    public UserDevice createUserDevice(DeviceInfo deviceInfo) {
        UserDevice userDevice = new UserDevice();
        userDevice.setDeviceId(deviceInfo.getDeviceId());
        userDevice.setDeviceType(deviceInfo.getDeviceType());
        userDevice.setIsRefreshActive(true);
        return userDevice;
    }

    public void verifyRefreshAvailability(RefreshToken refreshToken) {
        UserDevice userDevice = findByRefreshToken(refreshToken)
                .orElseThrow(() -> new TokenRefreshException(refreshToken.getToken(), "No device found for the matching token. Please login again"));

        if (!userDevice.getIsRefreshActive()) {
            throw new TokenRefreshException(refreshToken.getToken(), "Refresh blocked for the device. Please login through a different device");
        }
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Service
public class RefreshTokenService {

    // Autowire Repositories
    
    public Optional<RefreshToken> findByToken(String token) {
        return refreshTokenRepository.findByToken(token);
    }

    // other CRUD methods
    
    public RefreshToken createRefreshToken() {
        RefreshToken refreshToken = new RefreshToken();
        refreshToken.setExpiryDate(Instant.now().plusMillis(3600000));
        refreshToken.setToken(UUID.randomUUID().toString());
        refreshToken.setRefreshCount(0L);
        return refreshToken;
    }

    public void verifyExpiration(RefreshToken token) {
        if (token.getExpiryDate().compareTo(Instant.now()) < 0) {
            throw new TokenRefreshException(token.getToken(), "Expired token. Please issue a new request");
        }
    }

    public void increaseCount(RefreshToken refreshToken) {
        refreshToken.incrementRefreshCount();
        save(refreshToken);
    }
}

Con estos dos, podemos continuar y concentrarnos en los controladores.

Controladores

Con nuestras entidades definidas, sus repositorios y servicios listos, y los DTO para estas entidades listos para transferir datos, finalmente podemos crear un controlador para iniciar sesión. Durante el proceso de inicio de sesión, generaremos un UserDevice y RefreshToken para el usuario, así como asignarlos a la sesión del usuario.

Una vez que los guardamos en la base de datos, podemos devolver un JwtResponse que contiene estos tokens e información de caducidad al usuario:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@PostMapping("/signin")
public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginForm loginRequest) {
        
  User user = userRepository.findByEmail(loginRequest.getEmail())
        .orElseThrow(() -> new RuntimeException("Fail! -> Cause: User not found."));
        
  if (user.getActive()) {
    Authentication authentication = authenticationManager.authenticate(
              new UsernamePasswordAuthenticationToken(
                            loginRequest.getEmail(),
                            loginRequest.getPassword()
              )
    ); 
    SecurityContextHolder.getContext().setAuthentication(authentication); 
    String jwtToken = jwtProvider.generateJwtToken(authentication);
    userDeviceService.findByUserId(user.getId())
      .map(UserDevice::getRefreshToken)
      .map(RefreshToken::getId)
      .ifPresent(refreshTokenService::deleteById);

    UserDevice userDevice = userDeviceService.createUserDevice(loginRequest.getDeviceInfo());
    RefreshToken refreshToken = refreshTokenService.createRefreshToken();
    userDevice.setUser(user);
    userDevice.setRefreshToken(refreshToken);
    refreshToken.setUserDevice(userDevice);
    refreshToken = refreshTokenService.save(refreshToken);
    return ResponseEntity.ok(new JwtResponse(jwtToken, refreshToken.getToken(), jwtProvider.getExpiryDuration()));
  }
  return ResponseEntity.badRequest().body(new ApiResponse(false, "User has been deactivated/locked !!"));
}

Aquí, hemos verificado que el usuario con el correo electrónico dado existe, lanzando una excepción si no. Si el usuario está realmente activo, autenticamos al usuario con sus credenciales. Luego, usando el JwtProvider (ver GitHub, asumiendo que no tiene su propio proveedor JWT ya implementado), generamos el token JWT para el usuario, basado en la Autenticación de Spring Security.

Si ya hay un RefreshToken asociado con la sesión del usuario, se elimina porque actualmente estamos formando una nueva sesión.

Finalmente, creamos un dispositivo de usuario a través de UserDeviceService y generamos un nuevo token de actualización para el usuario, guardamos ambos en la base de datos y devolvemos un JwtResponse que contiene jwtToken, refreshToken y la duración de caducidad utilizada para caducar la sesión de un usuario. De lo contrario, devolvemos un badRequest(), ya que el usuario ya no está activo.

Para actualizar el token JWT mientras el usuario esté usando la aplicación, periódicamente enviaremos una solicitud de actualización:

1
2
3
4
5
6
public class TokenRefreshRequest {
      @NotBlank(message = "Refresh token cannot be blank")
      private String refreshToken;
  
      // Getters, Setters, Constructor
}

Una vez enviado, verificaremos que existe un token en la base de datos y, si existe, verificaremos la caducidad y la disponibilidad de actualización. Si la sesión se puede actualizar, la actualizamos y, de lo contrario, solicitamos al usuario que inicie sesión nuevamente:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@PostMapping("/refresh")
public ResponseEntity<?> refreshJwtToken(@Valid @RequestBody TokenRefreshRequest tokenRefreshRequest) {
        
  String requestRefreshToken = tokenRefreshRequest.getRefreshToken();
        
  Optional<String> token = Optional.of(refreshTokenService.findByToken(requestRefreshToken)
      .map(refreshToken -> {
          refreshTokenService.verifyExpiration(refreshToken);
          userDeviceService.verifyRefreshAvailability(refreshToken);
          refreshTokenService.increaseCount(refreshToken);
          return refreshToken;
      })
      .map(RefreshToken::getUserDevice)
      .map(UserDevice::getUser)
      .map(u -> jwtProvider.generateTokenFromUser(u))
      .orElseThrow(() -> new TokenRefreshException(requestRefreshToken, "Missing refresh token in database. Please login again")));
  return ResponseEntity.ok().body(new JwtResponse(token.get(), tokenRefreshRequest.getRefreshToken(), jwtProvider.getExpiryDuration()));
}

¿Qué sucede cuando nos desconectamos? {#lo que sucede cuando salimos}

Ahora podemos intentar cerrar sesión en el sistema. Una de las opciones más fáciles que el cliente puede probar es eliminar el token del almacenamiento local o de la sesión del navegador para que el token no se reenvíe a las API de back-end para solicitar acceso. Pero, ¿será suficiente? Aunque el usuario no podrá iniciar sesión desde el cliente, ese token aún está activo y se puede usar para acceder a las API. Entonces necesitamos invalidar la sesión del usuario desde el backend.

¿Recuerda que mapeamos el dispositivo del usuario y actualizamos el objeto token para administrar la sesión? Podemos eliminar fácilmente ese registro de la base de datos para que el backend no encuentre ninguna sesión activa del usuario.

Ahora deberíamos volver a hacer la pregunta de ¿Es eso realmente suficiente? Alguien aún puede tener el JWT y puede usarlo para autenticarse ya que acabamos de invalidar la sesión. Necesitamos invalidar el token JWT también para que no pueda ser mal utilizado. Pero espera, ¿no son los JWT objetos inmutables y sin estado?

Bueno, prueba que no puede caducar manualmente un token JWT que ya se ha creado. Entonces, una de las implementaciones para invalidar un token JWT sería crear un almacén en memoria llamado "lista negra", que puede almacenar todos los tokens que ya no son válidos pero que aún no han caducado.

Podemos usar un almacén de datos que tenga opciones TTL (Tiempo de vida) que se pueden configurar en la cantidad de tiempo restante hasta que caduque el token. Una vez que el token caduca, se elimina de la memoria, finalmente invalidando el token para siempre.

Nota: Redis o MemcachedDB pueden servir para nuestro propósito, pero estamos buscando una solución que pueda almacenar datos en la memoria y no queremos introducir otro almacenamiento persistente.

Esta es exactamente la razón por la que agregamos la dependencia Expiring Map anteriormente. Caduca las entradas y el servidor puede almacenar en caché los tokens con un TTL en el mapa que expira:

Token Blacklist

Cada vez que intentamos acceder a un punto final seguro, el ‘JWTAuthenticationFilter’ puede verificar adicionalmente si el token está presente en el mapa en la lista negra/en caché o no. De esta manera, también podemos invalidar un token JWT inmutable que va a caducar pronto, pero aún no lo ha hecho:

Token Cache Filter

Lista negra de tokens JWT antes de que caduquen {#lista negra de tokens JWT antes de que caduquen}

Implementemos la lógica para cachear cada token no caducado en una solicitud de cierre de sesión en un ExpiringMap donde el TTL para cada token será la cantidad de segundos que quedan hasta el vencimiento. Para evitar que el caché se acumule indefinidamente, también estableceremos un tamaño máximo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Component
public class LoggedOutJwtTokenCache {

    private ExpiringMap<String, OnUserLogoutSuccessEvent> tokenEventMap;
    private JwtProvider tokenProvider;

    @Autowired
    public LoggedOutJwtTokenCache(JwtProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
        this.tokenEventMap = ExpiringMap.builder()
                .variableExpiration()
                .maxSize(1000)
                .build();
    }

    public void markLogoutEventForToken(OnUserLogoutSuccessEvent event) {
        String token = event.getToken();
        if (tokenEventMap.containsKey(token)) {
            logger.info(String.format("Log out token for user [%s] is already present in the cache", event.getUserEmail()));

        } else {
            Date tokenExpiryDate = tokenProvider.getTokenExpiryFromJWT(token);
            long ttlForToken = getTTLForToken(tokenExpiryDate);
            logger.info(String.format("Logout token cache set for [%s] with a TTL of [%s] seconds. Token is due expiry at [%s]", event.getUserEmail(), ttlForToken, tokenExpiryDate));
            tokenEventMap.put(token, event, ttlForToken, TimeUnit.SECONDS);
        }
    }

    public OnUserLogoutSuccessEvent getLogoutEventForToken(String token) {
        return tokenEventMap.get(token);
    }

    private long getTTLForToken(Date date) {
        long secondAtExpiry = date.toInstant().getEpochSecond();
        long secondAtLogout = Instant.now().getEpochSecond();
        return Math.max(0, secondAtExpiry - secondAtLogout);
    }
}

También necesitamos definir un Objeto de transferencia de datos para que el cliente lo envíe cuando desee cerrar la sesión:

1
2
3
4
5
6
// Lombok annotations
public class LogOutRequest {

    private DeviceInfo deviceInfo;
    private String token;
}

También necesitaremos definir un Oyente de eventos para escuchar un evento de cierre de sesión para que pueda marcar inmediatamente el token para que se almacene en caché en la lista negra. Así que definamos el evento OnUserLogoutSuccessEvent y el detector de eventos OnUserLogoutSuccessEventListener:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Lombok annotations
public class OnUserLogoutSuccessEvent extends ApplicationEvent {

    private static final long serialVersionUID = 1L;
    private final String userEmail;
    private final String token;
    private final transient LogOutRequest logOutRequest;
    private final Date eventTime;
    
    // All Arguments Constructor with modifications
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@Component
public class OnUserLogoutSuccessEventListener implements ApplicationListener<OnUserLogoutSuccessEvent> {

    private final LoggedOutJwtTokenCache tokenCache;

    @Autowired
    public OnUserLogoutSuccessEventListener(LoggedOutJwtTokenCache tokenCache) {
        this.tokenCache = tokenCache;
    }

    public void onApplicationEvent(OnUserLogoutSuccessEvent event) {
        if (null != event) {
            DeviceInfo deviceInfo = event.getLogOutRequest().getDeviceInfo();
            logger.info(String.format("Log out success event received for user [%s] for device [%s]", event.getUserEmail(), deviceInfo));
            tokenCache.markLogoutEventForToken(event);
        }
    }
}

Finalmente, en JWTProvider, agregaremos una verificación para validar un token JWT para realizar una verificación adicional para ver si el token entrante está presente en la lista negra o no:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public boolean validateJwtToken(String authToken) {
    try {
      Jwts.parser().setSigningKey("HelloWorld").parseClaimsJws(authToken);
      validateTokenIsNotForALoggedOutDevice(authToken);
      return true;
    } catch (MalformedJwtException e) {
        logger.error("Invalid JWT token -> Message: {}", e);
    } catch (ExpiredJwtException e) {
        logger.error("Expired JWT token -> Message: {}", e);
    } catch (UnsupportedJwtException e) {
        logger.error("Unsupported JWT token -> Message: {}", e);
    } catch (IllegalArgumentException e) {
        logger.error("JWT claims string is empty -> Message: {}", e);
    }
    return false;
}
    
private void validateTokenIsNotForALoggedOutDevice(String authToken) {
    OnUserLogoutSuccessEvent previouslyLoggedOutEvent = loggedOutJwtTokenCache.getLogoutEventForToken(authToken);
    if (previouslyLoggedOutEvent != null) {
        String userEmail = previouslyLoggedOutEvent.getUserEmail();
        Date logoutEventDate = previouslyLoggedOutEvent.getEventTime();
        String errorMessage = String.format("Token corresponds to an already logged out user [%s] at [%s]. Please login again", userEmail, logoutEventDate);
        throw new InvalidTokenRequestException("JWT", authToken, errorMessage);
    }
}

Ejecución de la invalidación en memoria de tokens JWT

Finalmente, con la implementación realizada, podemos echar un vistazo al ciclo de sesión del usuario y ver qué sucede cuando iniciamos sesión y luego cerramos sesión: nos registraremos, iniciaremos sesión, actualizaremos nuestros tokens y luego saldremos del sistema. Finalmente, intentaremos acceder a un punto final seguro utilizando un token JWT generado previamente y veremos qué sucede.

De ahora en adelante, usaremos Postman para probar la funcionalidad de nuestra API. Si no está familiarizado con Postman, lea nuestra guía sobre Primeros pasos con Postman.

Primero registremos a un nuevo usuario, Adam Smith, como administrador de nuestra aplicación:

SignUp

Es fundamental que el JWT se invalide después de que el administrador cierre la sesión, ya que un usuario malintencionado podría obtener una autoridad destructiva sobre la aplicación si roba el JWT antes de que caduque.

Naturalmente, Adam querrá iniciar sesión en la aplicación:

Login

El servidor responde con un accessToken (JWT), un refreshToken y la expiryDuration. Dado que Adam tiene mucho trabajo por hacer en la aplicación, es posible que desee actualizar el token JWT que se le asignó en algún momento para ampliar su acceso mientras todavía está en línea.

Esto se hace pasando el Token de Acceso desde arriba como un Token de Portador en Autorización:

Refresh Access Token

Finalmente, Adam cierra sesión en la aplicación y pasa la información del dispositivo y el token de acceso para hacerlo:

Logout

Una vez no autorizado, intentemos acceder al punto final /users/me con el token JWT utilizado anteriormente, aunque aún no haya expirado, para ver si podemos acceder o no:

Acess Secured Endpoint

La API arroja el error 401 No autorizado, ya que el token JWT ahora está en la lista negra almacenada en caché.

Conclusión

Como puede ver, el flujo de cierre de sesión con JSON Web Tokens no es tan sencillo. Debemos seguir algunas mejores prácticas para acomodar algunos escenarios:

  • Defina un tiempo de vencimiento asequible en tokens. A menudo se recomienda mantener el tiempo de caducidad lo más bajo posible, para no sobrecargar la lista negra con muchos tokens.
  • Elimine el token que está almacenado en el almacenamiento local o de sesión del navegador.
  • Utilice un almacenamiento en memoria o basado en TTL de alto rendimiento para almacenar en caché el token que aún no ha caducado.
  • Consulta contra el token en la lista negra en cada llamada de solicitud autorizada.

Como se mencionó al principio de la guía, puede encontrar el código fuente completo en GitHub. rity-jwt-jpa).