Токены доступа

В STOMP-протоколе аутентификация и авторизация не являются частью базовой спецификации WebSocket или самого STOMP-фреймворка, но почти всегда реализуются поверх него. Токен доступа становится основным механизмом идентификации клиента при установлении STOMP-соединения и дальнейшей маршрутизации сообщений на стороне брокера.

В контексте STOMP.js токен используется на этапе CONNECT и далее может участвовать в обновлении соединения, переподключении и контроле доступа к подпискам и отправке сообщений.


Передача токена при установлении соединения

STOMP.js передаёт данные аутентификации через заголовки STOMP-фрейма CONNECT. В отличие от HTTP-запросов, WebSocket не всегда позволяет модифицировать HTTP headers в браузере, поэтому STOMP-уровень становится основным местом передачи токена.

Пример передачи JWT:

import { Client } from "@stomp/stompjs";

const token = localStorage.getItem("access_token");

const client = new Client({
  brokerURL: "wss://example.com/ws",
  connectHeaders: {
    Authorization: `Bearer ${token}`
  },
  debug: (str) => {
    console.log(str);
  },
  reconnectDelay: 5000
});

client.activate();

На сервере этот заголовок извлекается из STOMP CONNECT frame и используется для валидации пользователя.


Ограничения WebSocket и альтернативные способы передачи токена

В браузере невозможно надёжно задать произвольные HTTP-заголовки при WebSocket handshake. Это формирует несколько распространённых стратегий передачи токена:

Передача через STOMP CONNECT headers

Наиболее корректный вариант при использовании STOMP.js. Токен передаётся уже после установления WebSocket соединения, но до подписок.

Передача через query string

const token = localStorage.getItem("access_token");

const client = new Client({
  brokerURL: `wss://example.com/ws?token=${encodeURIComponent(token)}`
});

Этот способ используется, когда серверная реализация извлекает токен из URL handshake-запроса. Минус — токен попадает в логи и историю прокси.

Использование SockJS

При использовании SockJS токен часто передаётся через параметры:

const client = new Client({
  webSocketFactory: () =>
    new SockJS("https://example.com/ws?token=" + token)
});

SockJS даёт больше гибкости, но усложняет модель маршрутизации и отладки.


Серверная обработка токена в STOMP CONNECT

После установления WebSocket соединения сервер получает STOMP frame:

CONNECT
accept-version:1.2
host:example.com
Authorization:Bearer eyJhbGciOi...

На стороне сервера (например, Spring Boot + WebSocket MessageBroker) обработка выполняется через интерцептор канала сообщений.

Пример логики:

public class AuthChannelInterceptor implements ChannelInterceptor {

  @Override
  public Message<?> preSend(Message<?> message, MessageChannel channel) {
    StompHeaderAccessor accessor =
        MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

    if (StompCommand.CONNECT.equals(accessor.getCommand())) {
      String auth = accessor.getFirstNativeHeader("Authorization");

      Authentication user = authenticationService.authenticate(auth);
      accessor.setUser(user);
    }

    return message;
  }
}

После успешной аутентификации пользователь привязывается к сессии STOMP.


Влияние токена на подписки и маршрутизацию

После установления соединения токен обычно не передаётся повторно, но его содержимое влияет на:

  • доступ к destination (/topic, /queue, /user)
  • фильтрацию сообщений брокером
  • назначение пользовательских очередей
  • multi-tenant маршрутизацию

Например, пользователь может иметь доступ только к определённому пространству:

/app/{tenantId}/messages
/topic/{tenantId}/events

Где tenantId извлекается из JWT claims.


Обновление токена и переподключение

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

STOMP.js не управляет обновлением токена автоматически, поэтому применяется стратегия пересоздания соединения:

function createClient(token) {
  return new Client({
    brokerURL: "wss://example.com/ws",
    connectHeaders: {
      Authorization: `Bearer ${token}`
    },
    reconnectDelay: 5000
  });
}

let client = createClient(getToken());

client.onStompEr ror = (frame) => {
  if (frame.headers["message"] === "Token expired") {
    refreshToken().then(newToken => {
      client.deactivate();
      client = createClient(newToken);
      client.activate();
    });
  }
};

Обработка истечения токена

Типовые сценарии:

  1. Сервер возвращает ERROR STOMP frame
  2. Соединение разрывается с кодом закрытия WebSocket
  3. Подписки становятся невалидными

Пример обработки ERROR:

client.onStompEr ror = function (frame) {
  console.error("Broker error:", frame.headers["message"]);
  console.error("Details:", frame.body);
};

При использовании JWT сервер может проверять срок действия на каждом критическом действии, включая SUBSCRIBE и SEND.


Безопасность хранения токена

В STOMP.js токен часто хранится в браузере, что требует учёта рисков:

  • localStorage уязвим к XSS
  • sessionStorage ограничивает время жизни, но не защищает от XSS
  • in-memory хранение снижает риск, но требует повторной авторизации при перезагрузке

Пример in-memory хранения:

let accessToken = null;

export function setToken(token) {
  accessToken = token;
}

export function getToken() {
  return accessToken;
}

Интерцепция соединения и динамическая подстановка токена

STOMP.js позволяет обновлять connectHeaders перед подключением, что важно при refresh token сценариях:

const client = new Client({
  brokerURL: "wss://example.com/ws",
  connectHeaders: () => ({
    Authorization: `Bearer ${getToken()}`
  })
});

В некоторых версиях используется beforeConnect:

client.beforeConnect = () => {
  client.connectHeaders.Authorization = `Bearer ${getToken()}`;
};

Это позволяет гарантировать актуальность токена при каждом переподключении.


Интеграция с Spring Security и JWT

Типичная серверная архитектура предполагает:

  • WebSocket endpoint /ws
  • STOMP broker /topic, /queue
  • Spring Security фильтр для HTTP handshake
  • ChannelInterceptor для STOMP frames

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

@MessageMapping("/chat")
@SendTo("/topic/messages")
public Message handle(Message message, Principal user) {
    return service.process(user.getName(), message);
}

Таким образом токен определяет контекст безопасности на всём жизненном цикле сообщения.


Роль токена в user-specific destinations

STOMP поддерживает концепцию пользовательских очередей:

/user/queue/notifications

Сервер сопоставляет токен с конкретным пользователем и направляет сообщения в приватные каналы.

Пример отправки:

messagingTemplate.convertAndSendToUser(
  userId,
  "/queue/notifications",
  payload
);

Здесь токен фактически становится ключом связывания соединения и пользователя.


Переподключение с сохранением токена и состояния

При разрыве соединения важно учитывать:

  • необходимость повторной аутентификации
  • восстановление подписок
  • повторную отправку CONNECT headers

STOMP.js автоматически восстанавливает соединение при reconnectDelay, но не сохраняет state подписок в сложных сценариях.

Расширенная логика:

client.onConn ect = () => {
  client.subscribe("/topic/events", handler);
};

client.onWebSocketCl ose = () => {
  console.log("Connection closed, waiting reconnect");
};

Ошибки, связанные с токенами

Типовые проблемы:

  • 401 Unauthorized на CONNECT
  • отсутствие Authorization header на сервере
  • рассинхронизация времени (JWT exp)
  • неправильный формат Bearer токена
  • использование устаревшего refresh token без обновления STOMP соединения

Каждая из этих проблем проявляется либо в STOMP ERROR frame, либо в закрытии WebSocket соединения.


Практическая модель жизненного цикла токена в STOMP.js

  1. Получение access token через HTTP login
  2. Инициализация STOMP Client
  3. Передача токена в CONNECT headers
  4. Серверная валидация и привязка Principal
  5. Работа через SUBSCRIBE / SEND
  6. Обнаружение истечения токена
  7. Переподключение с новым токеном
  8. Повторная инициализация подписок