Рубрики
Без рубрики

Безопасность Spring: Недействительность токенов JWT в памяти во время выхода пользователя из системы

В этом руководстве мы подробно рассмотрим, как аннулировать токены JWT при выходе пользователя из приложения на базе Spring с помощью Spring Security.

Автор оригинала: Arpendu Kumar Garai.

Вступление

По мере развития и распространения технологий, включая развитие крупномасштабных архитектур, ориентированных на обслуживание, управление веб – безопасностью становится все более и более сложным. Сейчас существует гораздо больше крайних случаев, чем было раньше, и обеспечение безопасности личной информации пользователей становится все более трудным. Без упреждающих мер безопасности компании рискуют утечкой конфиденциальной информации , а в наше время и в эпоху информации – это может стать огромной проблемой для пользователей онлайн.

Вот почему безопасность должна быть первой , а не запоздалой мыслью при создании приложений.

Многие пользователи в конечном итоге создают множество различных учетных записей через различные браузеры и устройства, что означает, что мы также должны учитывать и отслеживать различные устройства, которые пользователи используют для входа в систему, чтобы случайно не заблокировать их собственную учетную запись, думая, что кто – то получил несанкционированный доступ, в то время как на самом деле-пользователь просто отправился в поездку и использовал свой телефон на Wi-Fi отеля.

В этом руководстве мы рассмотрим общую стратегию упреждающей безопасности аннулирования токена JWT при выходе пользователя из системы с определенного устройства.

Примечание: В этом руководстве предполагается, что у вас уже настроена аутентификация безопасности Spring , и цель состоит в том, чтобы предоставить рекомендации по аннулированию токенов JWT , независимо от реализации. Независимо от того , определили ли вы свои собственные роли и полномочия или использовали Spring GrantedAuthority , ваш собственный Пользователь или полагались на Spring Пользовательские данные , это не будет иметь большого значения. При этом некоторые из базовых фильтров, классов и конфигураций не будут доступны в самом руководстве, поскольку могут отличаться для вашего приложения.

Если вы хотите ознакомиться с конкретной реализацией, используемой в этом руководстве, включая всю конфигурацию, которая не показана здесь, вы можете получить доступ к полному исходному коду на GitHub .

Весенняя Безопасность

Spring Security -это простая, но мощная платформа, которая позволяет инженеру-программисту накладывать ограничения безопасности на веб-приложения на основе Spring с помощью различных компонентов JEE. Это простая в расширении и настраиваемая платформа, которая сосредоточена вокруг предоставления средств аутентификации и контроля доступа для приложений на базе Spring.

По своей сути, он решает три основных препятствия:

  • Аутентификация : Проверяет, является ли пользователь подходящим лицом для доступа к некоторым ограниченным ресурсам. Он выполняет два основных процесса: идентификация (кто такой пользователь) и проверка (является ли пользователь тем, за кого он себя выдает).
  • Авторизация : Гарантирует, что пользователю разрешен доступ только к тем частям ресурса, на использование которых он был авторизован с помощью комбинации Ролей и Разрешений .
  • Фильтры сервлетов : Любое веб-приложение Spring-это всего лишь один сервлет, который перенаправляет входящие HTTP-запросы на @Контроллер или @RestController . Поскольку в главном DispatcherServlet нет реализации безопасности , вам нужны фильтры, такие как Фильтр безопасности перед сервлетами, чтобы Аутентификация и Авторизация были обработаны перед перенаправлением на контроллеры.

Примечание: Стоит отметить, что некоторые используют термины “Роль” и “Разрешение” взаимозаменяемо, что может немного сбить с толку учащихся. Роли имеют набор разрешений . Администратор (Роль) может иметь разрешения для выполнения X и Y, в то время как Инженер может иметь разрешения для выполнения Ваших и Z.

Веб-токены JSON

JWT (веб-токен JSON) – это токен, который облегчает без состояния подход к обработке аутентификации пользователя. Это помогает выполнять аутентификацию без сохранения ее состояния в виде сеанса или объекта базы данных. Когда сервер пытается аутентифицировать пользователя, он не получает доступ к сеансу пользователя и не выполняет никаких запросов к базе данных. Этот токен генерируется с помощью полезной нагрузки сущности пользователя и внутренних объектов, известных как утверждения , и используется клиентами для идентификации пользователя на сервере.

JWT состоит из следующей структуры:

header.payload.signature
  • Заголовок : Содержит всю соответствующую информацию о том, как токен может быть интерпретирован или подписан.
  • Полезная нагрузка : Содержит утверждения в виде объекта данных пользователя или сущности. Обычно существует три типа претензий: Зарегистрированные , Публичные и Частные претензии.
  • Подпись : Состоит из заголовка , полезной нагрузки , секрета и алгоритма кодирования . Все содержимое подписано, а некоторые из них закодированы по умолчанию.

Если вы хотите узнать больше о JWTS, прочитайте наше руководство по пониманию веб-токенов JSON (JWT) .

Жизненный цикл веб-токена JSON

Давайте взглянем на классический жизненный цикл JWT – с момента, когда пользователь пытается войти в систему:

На схеме клиент передает свои учетные данные пользователя в виде запроса на сервер. Сервер после выполнения идентификации и проверки возвращает токен JWT в качестве ответа. Отныне клиент будет использовать этот токен JWT для запроса доступа к защищенным конечным точкам.

Как правило, пользователь попытается получить доступ к какой-либо защищенной конечной точке или ресурсу после входа в систему:

На этот раз, однако, клиент передает токен JWT, который он приобрел ранее, с запросом на доступ к защищенным данным. Сервер проанализирует маркер и выполнит аутентификацию и авторизацию без сохранения состояния, а также предоставит доступ к защищенному контенту, который будет отправлен обратно в качестве ответа.

Наконец, как только пользователь завершит работу с приложением, он, как правило, выйдет из системы:

Если пользователь хочет выйти из системы, клиент попросит сервер вывести пользователя из определенного устройства и аннулировать все его активные сеансы. При этом сервер сможет закрыть все пользовательские сеансы , но он не сможет аннулировать токен JWT, поскольку он не имеет состояния и является неизменяемым объектом .

Это может быстро стать проблемой – когда пользователь выходит из системы, токен JWT должен быть признан недействительным для дальнейшего использования. Кроме того, если кто-то попытается получить доступ к ограниченному ресурсу с недействительным токеном, ему не должен быть разрешен доступ с механизмом восстановления из этого исключительного состояния.

Как мы можем аннулировать токены? Мы можем быстро заставить их истекать, внести в черный список истекшие/удаленные токены и/или повернуть их с помощью токена обновления , выпущенного вместе с JWT.

Давайте продолжим и настроим Spring Security для выполнения недействительности токенов JWT в памяти при выходе пользователя из системы.

Пружинная загрузка и Настройка безопасности пружин

Теперь, когда мы разобрались с JWTs и основной проблемой, давайте инициализируем простое приложение Spring Boot и настроим его. Самый простой способ начать со скелетного проекта-это Spring Initializr :

Мы добавили зависимость безопасности Spring, потому что хотели бы включить и использовать модуль для обеспечения безопасности для нас. Мы также включили модули Spring Web и Spring Data JPA, поскольку в конечном итоге мы создаем веб-приложение, имеющее уровень сохраняемости. Использование Lombok необязательно, так как это удобная библиотека, которая помогает нам сократить стандартный код, такой как геттеры, сеттеры и конструкторы, просто аннотируя наши объекты аннотациями на Ломбоке.

Нам также потребуется импортировать несколько дополнительных зависимостей, которые недоступны в инициализаторе Spring. А именно, мы импортируем библиотеку JWT, а также библиотеку карт С истекающим сроком действия . ExpiringMap знакомит нас с высокопроизводительной, потокобезопасной ConcurrentMap реализацией, срок действия которой истекает для записей, которые мы будем использовать для истечения срока действия определенных токенов:



  io.jsonwebtoken
  jjwt
  0.9.1

        


   net.jodah
   expiringmap
   0.5.9

Реализация веб-приложения Spring Boot

Сопоставление устройств с пользователями при входе в систему

Пользователи все чаще заходят в системы с разных устройств. Общий и распространенный сценарий-это вход пользователя через веб-сайт на рабочем столе и смартфон. По умолчанию в обоих случаях серверная часть будет генерировать один и тот же токен JWT для данного электронного письма, поскольку электронное письмо является идентификатором. Как только пользователь выйдет из приложения на своем рабочем столе, он также выйдет из системы с своего телефона .

Способ решить эту проблему, если это не та функциональность, которую вы себе представляли, – передать информацию об устройстве при отправке запроса на вход вместе с именем пользователя и паролем. Чтобы сгенерировать уникальный идентификатор устройства при первой попытке пользователя войти в систему, мы можем использовать Fingerprint.js библиотека из клиентского интерфейса.

Мы захотим сопоставить пользователю несколько устройств, поскольку пользователь может использовать более одного устройства, поэтому нам понадобится механизм сопоставления устройства с сеансом входа пользователя. Мы также захотим сгенерировать токен обновления для поддержания одного и того же сеанса пользователя (обновление срока действия) до тех пор, пока они вошли в систему. Как только они выйдут из системы, мы можем позволить токену JWT истечь и признать его недействительным.

Тем не менее, нам нужно будет сопоставить устройство , а также токен обновления с сеансом пользователя.

Поскольку у нас есть механизм идентификации устройств, давайте реализуем функцию сопоставления пользовательского устройства с сеансом входа пользователя. Нам также нужно будет сгенерировать токен обновления, чтобы поддерживать один и тот же сеанс пользователя на протяжении всего процесса. Поэтому мы также поговорим о том, как мы можем сопоставить токен обновления с пользовательским устройством в сеансе пользователя.

Объекты, Определяющие Модель Предметной области

Давайте начнем с модели домена и сущностей, которые мы будем использовать. А именно, давайте начнем с Пользователя и Пользовательского устройства :

// 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 roles = new HashSet<>();
    
    public void activate() {
		    this.active = true;
	  }
	
	  public void deactivate() {
		    this.active = false;
    }
}

Этот Пользователь будет использовать какое-то устройство для отправки запроса на вход. Давайте также определим Пользовательское устройство модель:

// 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;
}

Наконец, мы также хотели бы иметь токен Обновления для каждого устройства:

// 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;
    }
}
Объекты передачи данных – Определение Полезной нагрузки запроса

Теперь давайте определим Объекты передачи данных для полезной нагрузки входящего запроса API. Нам понадобится Информация об устройстве , которая будет просто содержать Идентификатор устройства и тип устройства для нашего пользовательского устройства модели. У нас также будет Форма входа DTO, которая содержит учетные данные пользователя и Информацию об устройстве КОМУ.

Использование обоих из них позволяет нам отправлять минимально необходимую информацию для аутентификации пользователя на его устройстве и сопоставлять устройство с его сеансом:

// Lombok annotations
public class DeviceInfo {

    // Payload Validators
    private String deviceId;
    private String deviceType;
}
// Lombok annotations
public class LoginForm {

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

Давайте также создадим полезную нагрузку JWT-ответа , содержащую все токены и срок действия. Это сгенерированный ответ сервера клиенту, который используется для проверки клиента и может быть использован в дальнейшем

// Lombok annotations
public class JwtResponse {	 
    private String accessToken;
    private String refreshToken;
    private String tokenType = "Bearer";
    private Long expiryDuration;
}

Поскольку мы определили две новые сущности, Пользовательское устройство и Токен обновления , давайте определим их хранилища, чтобы мы могли выполнять операции CRUD с этими сущностями.

Хранилища, Определяющие Уровень сохраняемости
public interface UserDeviceRepository extends JpaRepository {

    @Override
    Optional findById(Long id);
    Optional findByRefreshToken(RefreshToken refreshToken);
    Optional findByUserId(Long userId);
}
public interface RefreshTokenRepository extends JpaRepository {

    @Override
    Optional findById(Long id);
    Optional findByToken(String token);
}
Службы, Определяющие Уровень обслуживания

Теперь мы захотим, чтобы посреднические службы взаимодействовали с контроллерами, которые позволяют нам использовать репозитории. Давайте создадим уровень сервиса для обработки запросов на операции CRUD для Пользовательского устройства и Токена обновления сущностей:

@Service
public class UserDeviceService {

    // Autowire Repositories

    public Optional 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");
        }
    }
}

Git Essentials

Ознакомьтесь с этим практическим руководством по изучению Git, содержащим лучшие практики и принятые в отрасли стандарты. Прекратите гуглить команды Git и на самом деле изучите это!

@Service
public class RefreshTokenService {

    // Autowire Repositories
    
    public Optional 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);
    }
}

С этими двумя мы можем пойти дальше и сосредоточиться на контроллерах.

Контроллеры

Когда наши сущности определены, их репозитории и службы готовы, а DTO для этих сущностей готовы к передаче данных, мы, наконец, можем создать контроллер для входа. Во время процесса входа в систему мы создадим Пользовательское устройство и Токен обновления для пользователя, а также сопоставим их с сеансом пользователя.

Как только мы сохраним их в базе данных, мы сможем вернуть пользователю ответ Jwt , содержащий эти токены и информацию об истечении срока действия:

@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 !!"));
}

Здесь мы проверили, что пользователь с данным электронным письмом существует, и в противном случае создадим исключение. Если пользователь действительно активен, мы аутентифицируем пользователя с учетом его учетных данных. Затем, используя JwtProvider (см. GitHub, предполагая, что у вас еще нет собственного поставщика JWT), мы генерируем токен JWT для пользователя на основе безопасности Spring Аутентификация .

Если с сеансом пользователя уже связан токен Обновления , он удаляется, поскольку в настоящее время мы формируем новый сеанс.

Наконец, мы создаем пользовательское устройство с помощью UserDeviceService и генерируем новый токен обновления для пользователя, сохраняя оба в базе данных, и возвращаем JwtResponse , содержащий jwtToken , refreshToken и срок действия, используемый для истечения сеанса пользователя. В противном случае мы возвращаем BadRequest () , так как пользователь больше не активен.

Чтобы обновить токен JWT до тех пор, пока пользователь фактически использует приложение, мы будем периодически отправлять запрос на обновление:

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

После отправки мы проверим, существует ли токен в базе данных, и если да, то проверим срок действия и доступность обновления. Если сеанс можно обновить, мы обновим его и в противном случае предложим пользователю снова войти в систему:

@PostMapping("/refresh")
public ResponseEntity refreshJwtToken(@Valid @RequestBody TokenRefreshRequest tokenRefreshRequest) {
    	
  String requestRefreshToken = tokenRefreshRequest.getRefreshToken();
    	
  Optional 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()));
}

Что Произойдет, Когда Мы Выйдем Из Системы?

Теперь мы можем попробовать выйти из системы. Один из самых простых вариантов, который может опробовать клиент, – это удалить токен из локального хранилища браузера или хранилища сеансов, чтобы токен не перенаправлялся на серверные API для запроса доступа. Но будет ли этого достаточно? Хотя пользователь не сможет войти в систему с клиента, этот токен все еще активен и может использоваться для доступа к API. Поэтому нам нужно аннулировать сеанс пользователя из бэкенда.

Помните, что мы сопоставили пользовательское устройство и объект токена обновления для управления сеансом? Мы можем легко удалить эту запись из базы данных, чтобы серверная часть не обнаружила ни одного активного сеанса пользователя.

Теперь мы должны снова задать вопрос Действительно ли этого достаточно? У кого-то все еще может быть JWT, и он может использовать его для аутентификации, так как мы только что аннулировали сеанс. Нам также нужно аннулировать токен JWT, чтобы им нельзя было злоупотреблять. Но подождите, разве JWTS не являются объектами без состояния и неизменяемыми объектами?

Что ж, это доказывает, что вы не можете вручную истечь срок действия токена JWT, который уже был создан. Таким образом , одной из реализаций для аннулирования токена JWT было бы создание хранилища в памяти под названием “черный список” , в котором могут храниться все токены, которые больше недействительны, но срок действия которых еще не истек.

Мы можем использовать хранилище данных, в котором есть параметры TTL (Время жизни) , которые можно установить на количество времени, оставшееся до истечения срока действия токена. Как только срок действия токена истекает, он удаляется из памяти, окончательно аннулируя токен навсегда .

Примечание: Redis или Memcached DB может служить вашей цели, но мы ищем решение, которое может хранить данные в памяти, и не хотим вводить еще одно постоянное хранилище.

Именно поэтому мы добавили ранее зависимость Истекающая карта . Это истекает срок действия записей , и сервер может кэшировать токены с TTL в карту истечения срока действия:

Каждый раз, когда мы пытаемся получить доступ к защищенной конечной точке, фильтр JWTAuthenticationFilter может дополнительно проверять, присутствует ли токен в черном списке/кэшированной карте или нет. Таким образом, мы также можем признать недействительным неизменяемый токен JWT, срок действия которого истекает в ближайшее время, но еще не истек:

Занесение Токенов JWT В Черный Список До Истечения Срока Их Действия

Давайте реализуем логику кэширования каждого токена с истекшим сроком действия при запросе на выход в Карту истечения срока действия , где TTL для каждого токена будет равняться количеству секунд, оставшихся до истечения срока действия. Чтобы предотвратить бесконечное накопление кэша, мы также установим максимальный размер:

@Component
public class LoggedOutJwtTokenCache {

    private ExpiringMap 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);
    }
}

Нам также необходимо определить Объект передачи данных для отправки клиенту, когда он захочет выйти из системы:

// Lombok annotations
public class LogOutRequest {

    private DeviceInfo deviceInfo;
    private String token;
}

Нам также нужно будет определить Прослушиватель событий для прослушивания события выхода из системы, чтобы он мог немедленно пометить маркер, который будет кэширован, в черный список. Итак, давайте определим событие Событие успешного выхода пользователя из системы и прослушиватель событий Прослушиватель событий успешного выхода пользователя :

// 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
}
@Component
public class OnUserLogoutSuccessEventListener implements ApplicationListener {

    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);
        }
    }
}

Наконец, в JWTProvider мы добавим проверку для проверки токена JWT , чтобы выполнить дополнительную проверку, чтобы узнать, присутствует ли входящий токен в черном списке или нет:

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);
    }
}

Запуск в памяти аннулирования токенов JWT

Наконец, после завершения реализации – мы можем взглянуть на цикл сеанса пользователя и посмотреть, что происходит, когда мы входим в систему, а затем выходим из нее – мы зарегистрируемся, войдем в систему, обновим наши токены, а затем выйдем из системы. Наконец, мы попытаемся получить доступ к защищенной конечной точке с помощью ранее сгенерированного токена JWT и посмотрим, что произойдет.

Отныне мы будем использовать Postman для тестирования функциональности нашего API. Если вы не знакомы с Почтальоном – прочитайте наше руководство по началу работы с Почтальоном.

Давайте сначала зарегистрируем нового пользователя, Адама Смита, в качестве администратора нашего приложения:

Крайне важно, чтобы JWT был признан недействительным после выхода администратора из системы, так как злоумышленник может получить разрушительные полномочия над приложением, если он перехватит JWT до истечения срока действия.

Естественно, Адам захочет войти в приложение:

Сервер отвечает Токеном доступа (JWT), токеном обновления и сроком действия . Поскольку Адаму предстоит много работы над приложением, он может захотеть обновить назначенный ему токен JWT в какой-то момент, чтобы расширить свой доступ, пока он все еще в Сети.

Это делается путем передачи Токена доступа сверху в качестве Токена на предъявителя в Авторизации :

Наконец, Адам выходит из приложения, передавая для этого информацию об устройстве и маркер доступа:

После несанкционированного доступа давайте попробуем попасть в конечные точки /users/me с ранее использованным токеном JWT, даже если срок его действия еще не истек, чтобы узнать, можем ли мы получить доступ или нет:

API выдает 401 Несанкционированную ошибку, так как токен JWT теперь находится в кэшированном черном списке.

Вывод

Как вы можете видеть, процесс выхода из системы с использованием веб-токенов JSON не так прост. Мы должны следовать нескольким лучшим практикам, чтобы учесть несколько сценариев:

  • Определите доступное время истечения срока действия токенов. Часто рекомендуется как можно меньше времени истечения срока действия, чтобы не переполнять черный список большим количеством токенов.
  • Удалите маркер, хранящийся в локальном хранилище браузера или в хранилище сеанса.
  • Используйте хранилище в памяти или высокопроизводительное хранилище на основе TTL для кэширования токена, срок действия которого еще не истек.
  • Запрос к маркеру, внесенному в черный список, при каждом авторизованном вызове запроса.

Как упоминалось в начале руководства, вы можете найти полный исходный код в GitHub .