Próbuję napisać komunikację po WebSocket w Springu.
Na przykładzie czatu:
- pokoje ogólnodostępne - tylko użytkownicy, którzy mają do nich dostęp, mogą otrzymywać wiadomości
- komunikacja prywatna - tylko użytkownicy z danej konwersacji mogą otrzymywać wiadomości
- będzie odpalonych wiele instancji serwera (wymagany mechanizm synchronizacji)
Spring mocno lobbuje za protokołem STOMP. Czy w tym przypadku to dobry pomysł? Mechanizm subskrypcji dużo rzeczy ułatwia, ale mam obawy o bezpieczeństwo, ponieważ każdy może zasubskrybować wszystko. Jak poprawnie zaimplementować kontrolę dostępu?
Cały ruch idzie przez proxy, który weryfikuje token JWT, a następnie dodaje nagłówek "User-Id". Po tym nagłówku aplikacja rozpoznaje zalogowanego użytkownika.
W przypadku WebSocketów trzeba odczytać ten nagłówek na etapie handshake i Spring nam to załatwia.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketConfigurer, WebSocketMessageBrokerConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry
.addHandler(new TextWebSocketHandler())
.addInterceptors(new HttpSessionHandshakeInterceptor()); // ten interceptor kopiuje nagłówki HTTP do atrybutów WebSocket
}
}
No ale jak załatwić kontrolę dostępu? Trzeba zweryfikować, czy użytkownik, co chce zasubskrybować /app/chat-room/name
ma tam dostęp. Tu pojawia się kolejny problem. Teoretycznie użytkownicy mogą subskrybować /app/...
ale to jest prefix do przechwytywania komunikatów przez serwer. Prefix dla użytkownika to /queue/user
i tu jest kolejny problem, gdyż polega na Principal ze Spring Security (a i tak można zasubskrybować kolejkę dowolnego użytkownika).
To by pewnie wystarczyło, gdyż aplikacja wysyłałaby komunikaty do tych użytkowników, którzy mają dostęp do danych zasobów.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketConfigurer, WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config
.setApplicationDestinationPrefixes("/app")
.setUserDestinationPrefix("/user")
.enableSimpleBroker("/topic", "/queue");
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry
.addHandler(new TextWebSocketHandler())
.addInterceptors(new HttpSessionHandshakeInterceptor())
.setHandshakeHandler(handshakeHandler()); // tworzymy własny handler
}
@Bean
public HandshakeHandler handshakeHandler() {
return new WsHandshakeHandler(); // niestety Spring nie korzysta z tego handlera - dlaczego???
}
}
public class WsHandshakeHandler extends DefaultHandshakeHandler {
@Override
public Principal determineUser(@NonNull ServerHttpRequest request,
@NonNull WebSocketHandler wsHandler,
@NonNull Map<String, Object> attributes) {
var userIds = request.getHeaders().get("X-USER-ID");
if (userIds != null && userIds.size() == 1) {
return new WsPrincipal(UUID.fromString(userIds.get(0)));
}
return null;
}
}
Po stronie klienta jest biblioteka stompjs, która dba także po ponowne łączenie z serwerem i ponowne zasubskrybowanie istniejących subskrypcji.
Czy STOMP to dobry pomysł w tym przypadku?