← Назад к вопросам
Как обеспечить безопасность управления сессиями через 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) | Быстро, простой logout | Redis зависимость, масштабирование | Небольшое кол-во пользователей |
| Таблица сессий (БД) | Полный контроль, аудит, multi-device | Задержка БД при каждом запросе | Production системы |
| Refresh Token | Безопасность (short-lived access token) | Сложнее реализовать | Мобильные приложения |
| Token Versioning | Простой logout, без черного списка | Требует дополнительный запрос к БД | Средние системы |
Best Practices
- НИКОГДА не храни сам токен в БД — только хеш
- Всегда проверяй сессию на каждом запросе (кэшируй результат)
- Используй HTTPS для передачи токенов
- Добавь HTTP-only cookies вместо localStorage
- Логируй логины и логауты для аудита безопасности
- Устанавливай максимальную длину жизни токена (даже с refresh token)
- Реализуй "Logout from all devices" для закрытия компрометированных сессий
- Проверяй User-Agent и IP при повторном использовании refresh token
Сочетание таблицы сессий в БД + Redis кэша для быстрой проверки — оптимальное решение для production систем.