← Назад к вопросам

Как обеспечить безопасность управления сессиями через JWT, чтобы пользователь мог закрывать свои сессии

3.0 Senior🔥 121 комментариев
#REST API и микросервисы#Spring Boot и Spring Data#Безопасность

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

# Безопасное управление сессиями через JWT с возможностью закрытия

JWT по своей природе stateless, но это создаёт проблему: как запретить использование токена после logout? Решение требует комбинации нескольких подходов.

Проблема JWT

// ❌ Проблема: JWT не хранится на сервере
// Если выдали токен на час, пользователь не может "отозвать" его раньше
String token = Jwts.builder()
    .subject(userId)
    .issuedAt(new Date())
    .expiration(new Date(System.currentTimeMillis() + 3600000))  // 1 час
    .signWith(secretKey)
    .compact();

// Даже после logout пользователя, этот токен действует 1 час!

Решение 1: Token Blacklist (Redis)

Хранить отозванные токены в быстром хранилище (Redis):

@Configuration
public class SecurityConfig {
    // Хранилище отозванных токенов
    @Bean
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
        return new RedisTemplate<String, String>() {{
            setConnectionFactory(factory);
        }};
    }
}

@Service
public class TokenBlacklistService {
    private final RedisTemplate<String, String> redisTemplate;
    private final static String BLACKLIST_PREFIX = "jwt_blacklist:";

    @Autowired
    public TokenBlacklistService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    // Добавить токен в чёрный список при logout
    public void blacklistToken(String token, long expirationTime) {
        long ttlSeconds = (expirationTime - System.currentTimeMillis()) / 1000;
        if (ttlSeconds > 0) {
            String key = BLACKLIST_PREFIX + token;
            redisTemplate.opsForValue().set(key, "revoked", 
                Duration.ofSeconds(ttlSeconds));
        }
    }

    // Проверить, есть ли токен в чёрном списке
    public boolean isTokenBlacklisted(String token) {
        return Boolean.TRUE.equals(
            redisTemplate.hasKey(BLACKLIST_PREFIX + token)
        );
    }
}

@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
    @Autowired
    private TokenBlacklistService blacklistService;

    @PostMapping("/logout")
    public ResponseEntity<String> logout(
            @RequestHeader("Authorization") String authHeader) {
        String token = authHeader.replace("Bearer ", "");
        
        // Парсим токен для получения времени истечения
        Date expirationDate = Jwts.parserBuilder()
            .setSigningKey(secretKey)
            .build()
            .parseClaimsJws(token)
            .getBody()
            .getExpiration();

        // Добавляем в чёрный список
        blacklistService.blacklistToken(token, expirationDate.getTime());
        
        return ResponseEntity.ok("Logout successful");
    }
}

Решение 2: Таблица сессий в БД

Хранить активные сессии в базе, проверять перед каждым запросом:

@Entity
@Table(name = "user_sessions")
public class UserSession {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private String sessionId;

    @Column(nullable = false)
    private Long userId;

    @Column(nullable = false, length = 1000)
    private String token;  // Хеш токена, не сам токен!

    @Column(nullable = false)
    private LocalDateTime createdAt;

    @Column(nullable = false)
    private LocalDateTime expiresAt;

    @Column(nullable = false)
    private boolean active = true;

    @Column
    private String deviceInfo;  // User-Agent, IP, тип девайса

    @Column
    private LocalDateTime revokedAt;  // Когда закрыта
}

@Repository
public interface UserSessionRepository extends JpaRepository<UserSession, String> {
    List<UserSession> findByUserIdAndActiveTrue(Long userId);
    Optional<UserSession> findByTokenHashAndActiveTrue(String tokenHash);
    void deleteByUserIdAndActiveFalse(Long userId);
}

@Service
public class SessionManagementService {
    @Autowired
    private UserSessionRepository sessionRepository;

    // Создать сессию при login
    public UserSession createSession(Long userId, String token, String deviceInfo) {
        String tokenHash = hashToken(token);  // ВАЖНО: хешируем!
        
        UserSession session = new UserSession();
        session.setUserId(userId);
        session.setToken(tokenHash);
        session.setCreatedAt(LocalDateTime.now(ZoneId.of("UTC")));
        session.setExpiresAt(LocalDateTime.now(ZoneId.of("UTC")).plusHours(24));
        session.setDeviceInfo(deviceInfo);
        session.setActive(true);

        return sessionRepository.save(session);
    }

    // Проверить валидность токена
    public boolean isSessionValid(Long userId, String token) {
        String tokenHash = hashToken(token);
        Optional<UserSession> session = 
            sessionRepository.findByTokenHashAndActiveTrue(tokenHash);
        
        if (session.isEmpty()) {
            return false;
        }

        UserSession s = session.get();
        // Проверяем:
        // 1. Принадлежит правильному пользователю
        // 2. Ещё не истекла
        // 3. Активна (не закрыта)
        return s.getUserId().equals(userId) && 
               s.getExpiresAt().isAfter(LocalDateTime.now(ZoneId.of("UTC"))) &&
               s.isActive();
    }

    // Закрыть одну сессию (logout)
    public void revokeSession(Long userId, String token) {
        String tokenHash = hashToken(token);
        Optional<UserSession> session = 
            sessionRepository.findByTokenHashAndActiveTrue(tokenHash);
        
        if (session.isPresent() && session.get().getUserId().equals(userId)) {
            UserSession s = session.get();
            s.setActive(false);
            s.setRevokedAt(LocalDateTime.now(ZoneId.of("UTC")));
            sessionRepository.save(s);
        }
    }

    // Закрыть ВСЕ сессии пользователя (logout from all devices)
    public void revokeAllUserSessions(Long userId) {
        List<UserSession> sessions = sessionRepository.findByUserIdAndActiveTrue(userId);
        sessions.forEach(session -> {
            session.setActive(false);
            session.setRevokedAt(LocalDateTime.now(ZoneId.of("UTC")));
            sessionRepository.save(session);
        });
    }

    // Получить все активные сессии пользователя
    public List<UserSession> getActiveSessions(Long userId) {
        return sessionRepository.findByUserIdAndActiveTrue(userId);
    }

    private String hashToken(String token) {
        return DigestUtils.sha256Hex(token);
    }
}

// Фильтр для проверки в каждом запросе
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Autowired
    private SessionManagementService sessionService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
            HttpServletResponse response, 
            FilterChain filterChain) throws ServletException, IOException {
        
        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            
            try {
                // 1. Парсим JWT
                Claims claims = Jwts.parserBuilder()
                    .setSigningKey(secretKey)
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
                
                Long userId = claims.get("userId", Long.class);
                
                // 2. ✅ Проверяем в БД, активна ли сессия
                if (sessionService.isSessionValid(userId, token)) {
                    // Сессия валидна, продолжаем
                    request.setAttribute("userId", userId);
                } else {
                    // Сессия закрыта или не найдена
                    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                    return;
                }
            } catch (JwtException e) {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                return;
            }
        }
        
        filterChain.doFilter(request, response);
    }
}

Решение 3: Refresh Token паттерн

Использовать короткоживущий access token + долгоживущий refresh token:

public class TokenPair {
    private String accessToken;      // 15 минут
    private String refreshToken;    // 7 дней
    private long accessTokenExpiry;  // timestamp

    // getters, setters...
}

@Service
public class TokenService {
    private final static long ACCESS_TOKEN_VALIDITY = 900;      // 15 мин
    private final static long REFRESH_TOKEN_VALIDITY = 604800;  // 7 дней

    public TokenPair generateTokenPair(Long userId) {
        String accessToken = generateAccessToken(userId, ACCESS_TOKEN_VALIDITY);
        String refreshToken = generateRefreshToken(userId, REFRESH_TOKEN_VALIDITY);
        
        // Сохраняем refresh token в БД (может быть отозван)
        saveRefreshToken(userId, refreshToken);
        
        return new TokenPair(accessToken, refreshToken, 
            System.currentTimeMillis() + (ACCESS_TOKEN_VALIDITY * 1000));
    }

    @PostMapping("/auth/logout")
    public ResponseEntity<String> logout(
            @RequestAttribute Long userId,
            @RequestBody RefreshTokenRequest request) {
        // Отзываем refresh token — он больше не сможет обновить access token
        revokeRefreshToken(userId, request.getRefreshToken());
        return ResponseEntity.ok("Logged out");
    }
}

Решение 4: Token Versioning

Добавить версию токена в claims, инкрементировать при logout:

@Entity
public class UserTokenVersion {
    @Id
    private Long userId;
    
    private Long tokenVersion = 1L;  // Инкрементируется при logout
}

// При создании токена
String token = Jwts.builder()
    .subject(userId)
    .claim("tokenVersion", userTokenVersion.getTokenVersion())
    .issuedAt(new Date())
    .expiration(new Date(System.currentTimeMillis() + 3600000))
    .signWith(secretKey)
    .compact();

// При logout
public void logout(Long userId) {
    UserTokenVersion version = userTokenVersionRepository.findById(userId).get();
    version.setTokenVersion(version.getTokenVersion() + 1);
    userTokenVersionRepository.save(version);
}

// При проверке токена
Long tokenVersion = claims.get("tokenVersion", Long.class);
Long currentVersion = userTokenVersion.getTokenVersion();
if (!tokenVersion.equals(currentVersion)) {
    throw new JwtException("Token version mismatch - token revoked");
}

Рекомендации

ПодходПлюсыМинусыКогда использовать
Blacklist (Redis)Быстро, простой logoutRedis зависимость, масштабированиеНебольшое кол-во пользователей
Таблица сессий (БД)Полный контроль, аудит, multi-deviceЗадержка БД при каждом запросеProduction системы
Refresh TokenБезопасность (short-lived access token)Сложнее реализоватьМобильные приложения
Token VersioningПростой logout, без черного спискаТребует дополнительный запрос к БДСредние системы

Best Practices

  1. НИКОГДА не храни сам токен в БД — только хеш
  2. Всегда проверяй сессию на каждом запросе (кэшируй результат)
  3. Используй HTTPS для передачи токенов
  4. Добавь HTTP-only cookies вместо localStorage
  5. Логируй логины и логауты для аудита безопасности
  6. Устанавливай максимальную длину жизни токена (даже с refresh token)
  7. Реализуй "Logout from all devices" для закрытия компрометированных сессий
  8. Проверяй User-Agent и IP при повторном использовании refresh token

Сочетание таблицы сессий в БД + Redis кэша для быстрой проверки — оптимальное решение для production систем.

Как обеспечить безопасность управления сессиями через JWT, чтобы пользователь мог закрывать свои сессии | PrepBro