Secured WebSockets with STOMP and Spring Boot/Security

Tue Dec 5, 2023 | 1806 Words | 9 Minute

The image shows the spring logo with a security icon
© SergejDK

Introduction

In the realm of real-time communication, WebSockets have emerged as a revolutionary technology, enabling bi-directional, full-duplex communication between web clients and servers. STOMP, the Simple Text Oriented Message Protocol, further enhances this capability by providing a message-oriented abstraction over WebSockets, facilitating a structured and standardized approach to real-time messaging. Spring Boot, a popular Java framework, simplifies the development of WebSocket applications, making it an ideal choice for building robust and secure real-time communication solutions.

Securing WebSockets with Spring Security

With this newfound power comes the responsibility of safeguarding sensitive data and preventing unauthorized access. Spring Security, a comprehensive framework for securing web applications, extends its reach to WebSocket endpoints, ensuring that only authorized users can establish WebSocket connections and exchange messages.

Why secure websockets?

ecuring WebSockets is paramount for several reasons:

  • Data Confidentiality: WebSockets often transmit sensitive data, such as personal information or financial transactions. Ensuring that only authorized users can access this data is crucial to protect user privacy and prevent unauthorized disclosure.

  • Integrity and Authenticity: Insecure WebSockets are susceptible to man-in-the-middle attacks, where an attacker intercepts and modifies messages exchanged between clients and servers. Securing WebSockets ensures that messages remain unaltered and originate from trusted sources.

  • Access Control: Limiting access to WebSocket endpoints prevents unauthorized users from joining real-time communication sessions, disrupting ongoing discussions and potentially compromising sensitive information.

Spring Security for WebSocket Security

Spring Security provides a robust mechanism for securing WebSocket endpoints, leveraging its authentication and authorization capabilities to control access and protect sensitive data. Spring Security’s WebSocket support integrates seamlessly with its HTTP security features, ensuring consistent security policies across both HTTP and WebSocket communication channels.

Authentication and Authorization

Spring Security offers various authentication and authorization mechanisms for WebSocket endpoints, including:

  • Basic Authentication: Prompts users to enter their credentials upon initiating a WebSocket connection.

  • Form Authentication: Redirects users to a login page when attempting to connect to a secured WebSocket endpoint.

  • OAuth 2.0: Utilizes OAuth 2.0 tokens to authenticate users and manage access to WebSocket resources.

  • Custom Authentication Providers: Enables the integration of custom authentication mechanisms tailored to specific application requirements.

We will focus here on Basic Authentication and OAuth 2.0.

Implementation Strategies

Implementing secure STOMP messaging with Spring Security involves several steps:

  • Enable WebSocket Message Broker: Annotate the application with @EnableWebSocketMessageBroker to enable STOMP support in Spring Boot.

  • Configure Message Broker: Define a message broker, such as RabbitMQ or ActiveMQ, to handle message routing and delivery between STOMP clients.

  • Configure STOMP Endpoints: Specify STOMP endpoints, represented by paths or queues, for message exchange among clients.

  • Define Message Handlers: Implement message handlers to process incoming STOMP messages and handle client interactions.

  • Secure STOMP Endpoints: Utilize Spring Security interceptors to intercept and secure WebSocket handshake requests, applying authentication and authorization mechanisms.

Integrating STOMP with Spring Boot

Spring Boot simplifies the integration of STOMP, the Simple Text Oriented Message Protocol, into WebSocket applications. The framework provides several annotations and configurations that streamline the process of enabling STOMP support and establishing message-oriented communication between clients and servers.

Enabling STOMP Support

To enable STOMP support in a Spring Boot application, simply add the @EnableWebSocketMessageBroker annotation to the main application class. This annotation registers the WebSocket message broker and configures it to handle STOMP messaging.

Configuring Message Broker

A message broker acts as a central hub for routing and delivering messages between STOMP clients. Spring Boot supports various message brokers, including RabbitMQ, ActiveMQ, and Artemis. The type of message broker can be specified using the spring.rabbitmq.host or the spring.activemq.broker-url property in the application.properties file.

Register STOMP Destinations

Use the registerStompEndpoints method to add the specific endpoints for your application. I am going to show that in the implementation part of that blog post.

Implementing Secured STOMP Messaging

Now lets go into the more interesting part - the development of that topic.

Setup

I have a frontend application build with angular which uses rx-stomp for the websocket connection to the backend I was implementing. That endpoint has to be secured and therefore I used spring-security. To make a normal websocket connection secure you only have to include one annotation @EnableWebsocketSecurity and most of the things should work as expected. That is a story for a different blog post.

Get the focus back to the problem - The STOMP protocol does not allow that or the rx-stomp library as no authorization headers will be send in the initial handshake which happens between Backend and Frontend. So there has to be another way and that is what I am going to show you. I use keycloak for the identitymanagement and will use the rolehandling from a library. Still you should be able to adjust the code appropriatly to your needs.

I won’t go into detail on how to setup spring security in itself with http secured. This should be definetly be another topic to cover another time. The only thing you should know beforehand - disable the same origin policy for websocket connections as we will handle the authorization ourself.

The real implementation

  1. Create a configurationclass for the websocketconfig which should also implement WebSocketMessageBrokerConfigurer.
@Configuration
@EnableWebSocketMessageBroker
public class AuthEnabledWebSocketConfig implements WebSocketMessageBrokerConfigurer {}
  1. Register the endpoints.
// I will use those to endpoints for showing both possobilities and
// how I managed to make them work
private final static String ENDPOINT_WS_OAUTH = "/api/websocket";
private final static String ENDPOINT_WS_BASICAUTH = "/api/websocketbasic";

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint(ENDPOINT_WS_OAUTH).setAllowedOriginPatterns("*")
            .addInterceptors(new EndpointHttpSessionHandshakeInterceptor(ENDPOINT_WS_UI));
    registry.addEndpoint(ENDPOINT_WS_BASICAUTH).setAllowedOriginPatterns("*")
            .addInterceptors(new EndpointHttpSessionHandshakeInterceptor(ENDPOINT_WS_KIOSK));

    // If you want to handle custom errors this is the right place
    registry.setErrorHandler(new StompSubProtocolErrorHandler() {
        @Override
        public Message<byte[]> handleClientMessageProcessingError(Message<byte[]> clientMessage, Throwable e)
        {
            var exception = e;
            if (exception instanceof MessageDeliveryException) {
                var cause = e.getCause();
                if (cause instanceof AccessDeniedException) {
                    // send the causing exception from preSend method to client
                    exception = cause;
                }
            }
            return super.handleClientMessageProcessingError(clientMessage, exception);
        }
    });
}
  1. Configure the messagebroker
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
    // Configure one or more prefixes to filter destinations targeting application annotated methods
    registry.setApplicationDestinationPrefixes("/application");
    // enable a simple memory-based message broker to carry the messages
    // back to the client on destinations prefixed with /topic
    registry.enableSimpleBroker("/topic");
}
  1. Special case for me was - I need to know the endpoint inside the configureClientInboundChannel which we will see in the next part. Therefore I needed the endpoint in the request and to achieve that I used a HandshakeInterceptor.
@AllArgsConstructor // Lombok annotation
class EndpointHttpSessionHandshakeInterceptor extends HttpSessionHandshakeInterceptor {
    private final String endpoint;
    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
                                   WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
        if(request instanceof ServletServerHttpRequest servletRequest) {
            var session = servletRequest.getServletRequest().getSession();
            session.setAttribute("endpoint", this.endpoint);
        }
        return super.beforeHandshake(request, response, wsHandler, attributes);
    }
}
  1. Now we have to handle the incoming messages. We will do this in the method configureClientInboundChannel.
private static final String ERROR_AUTHORIZATION = "Unauthorized";
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
    registration.interceptors(new ChannelInterceptor() {
        @Override
        public Message<?> preSend(Message<?> message, MessageChannel channel) {
            StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
            if(Objects.nonNull(accessor) && Objects.nonNull(accessor.getSessionAttributes())
                    && Objects.equals(StompCommand.CONNECT, accessor.getCommand())) {

                var endpoint = (String)accessor.getSessionAttributes().get("endpoint");
                switch (endpoint) {
                    case ENDPOINT_WS_OAUTH -> {
                        setJwtAuthenticationSecurityContext(accessor);
                        if (!authenticationFacade.hasRole(AuthEnabledSecurityConfig.ROLE_UI_VIEW)) {
                            log.info("configureClientInboundChannel: Insufficient user permissions");
                            throw new AccessDeniedException(ERROR_AUTHORIZATION);
                        }
                    }
                    case ENDPOINT_WS_BASICAUTH -> {
                        if (StringUtils.isNotBlank(kioskUserName) &&
                                !validBasicAuthAndHasRole(accessor, AuthEnabledSecurityConfig.ROLE_KIOSK)) {
                            log.info("configureClientInboundChannel: Insufficient user permissions");
                            throw new AccessDeniedException(ERROR_AUTHORIZATION);
                        }
                    }
                    default -> {
                        throw new AccessDeniedException("Unknown endpoint");
                    }
                }
            }
            return message;
        }
    });
}
  1. Helper functions for the previous step.
// Oatuh2 handling
private void setJwtAuthenticationSecurityContext(StompHeaderAccessor accessor) {
    String authHeader = accessor.getFirstNativeHeader("Authentication");
    if(Objects.isNull(authHeader)) { // check if the authentication header is available
        log.info("setJwtAuthenticationSecurityContext: Missing authentication header");
        throw new AccessDeniedException(ERROR_AUTHORIZATION);
    }
    if(!authHeader.startsWith(BEARER_PREFIX)) { // is bearer token available
        log.info("setJwtAuthenticationSecurityContext: Invalid bearer authentication header");
        throw new AccessDeniedException(ERROR_AUTHORIZATION);
    }
    String token = authHeader.substring(BEARER_PREFIX.length());

    Jwt jwt;
    try {
        jwt = jwtDecoder.decode(token);
    } catch (JwtException ex) {
        log.info("setJwtAuthenticationSecurityContext: Corrupted Java Web Token (JWT)");
        throw new AccessDeniedException(ERROR_AUTHORIZATION);
    }
    // the following is needed to handle the keycloak jwt but you probably should use what is correct
    // in your instance if you use another identitymanagement tool
    var abstractAuthenticationToken = keycloakJwtConverter.convert(jwt);
    var authentication = new JwtAuthenticationToken(jwt, abstractAuthenticationToken.getAuthorities());
    // using createEmptyContext to avoid race conditions across multiple threads
    SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
    securityContext.setAuthentication(authentication);
    SecurityContextHolder.setContext(securityContext);
    accessor.setUser(authentication);
}
// basic auth handling
private boolean validBasicAuthAndHasRole(StompHeaderAccessor accessor, String role) {
    System.out.println("validBasicAuthAndHasRole");
    String authHeader = accessor.getFirstNativeHeader("Authentication");
    // check if the authentication header is available
    if(Objects.isNull(authHeader)) {
        log.info("validBasicAuthAndHasRole: Missing authentication header");
        throw new AccessDeniedException(ERROR_AUTHORIZATION);
    }
    // Available BasicAuth Prefix?
    if(!authHeader.startsWith(BASICAUTH_PREFIX)) {
        log.info("validBasicAuthAndHasRole: Invalid basic authentication header");
        throw new AccessDeniedException(ERROR_AUTHORIZATION);
    }
    final String decodedCredentials;
    try {
        String authData = authHeader.substring(BASICAUTH_PREFIX.length());
        decodedCredentials = new String(Base64.getDecoder().decode(authData), StandardCharsets.UTF_8);
    } catch (IllegalArgumentException e) {
        log.info("validBasicAuthAndHasRole: Corrupted authentication data");
        throw new AccessDeniedException(ERROR_AUTHORIZATION);
    }
    // the format is always username:password
    var credentials = decodedCredentials.split(":", 2);
    if (credentials.length < 2) {
        log.info("validBasicAuthAndHasRole: Corrupted decoded authentication data");
        throw new AccessDeniedException(ERROR_AUTHORIZATION);
    }
    var userName = credentials[0];
    var password = credentials[1];
    final UserDetails userDetails;
    try {
        // Check if user exists
        userDetails = this.userService.loadUserByUsername(userName);
    } catch (UsernameNotFoundException e) {
        log.info("validBasicAuthAndHasRole: Invalid user name {}", userName);
        throw new AccessDeniedException(ERROR_AUTHORIZATION);
    }
    // check how the password is safed
    if (userDetails.getPassword().startsWith("{noop}")) {
        String passwordWithoutPrefix = userDetails.getPassword().substring("{noop}".length());
        if (!Objects.equals(password, passwordWithoutPrefix)) { // wrong password
            log.info("validBasicAuthAndHasRole: Invalid {noop} password for user name {}", userName);
            throw new AccessDeniedException(ERROR_AUTHORIZATION);
        }
    } else if (userDetails.getPassword().startsWith("{bcrypt}")) {
        String passwordWithoutPrefix = userDetails.getPassword().substring("{bcrypt}".length());
        if (!this.passwordEncoder.matches(password, passwordWithoutPrefix)) { // wrong password
            log.info("validBasicAuthAndHasRole: Invalid {bcrypt} password for user name {}", userName);
            throw new AccessDeniedException(ERROR_AUTHORIZATION);
        }
    } else {
        log.info("validBasicAuthAndHasRole: Invalid password prefix for user name {}", userName);
        throw new AccessDeniedException(ERROR_AUTHORIZATION);
    }

    // check if role exists for user
    return userDetails.getAuthorities().stream().anyMatch(authorities ->
            Objects.equals(authorities.getAuthority(), "ROLE_" + role));
}
  1. Now we move to the frontend part as the backend is finished. Adding the following packages to your package.json.
    "@stomp/rx-stomp": "^2.0.0",
    "@stomp/stompjs": "^7.0.0",
    "@types/stompjs": "^2.3.5",
  1. Then extend the RxStompConfig class like this: export const myRxStompConfig: RxStompConfig = {brokerUrl: 'http://yourserver:port/path/api/'};

  2. Create a rxStompServiceFactory with the needed dependencies. The service handles the initial connection and errorsubscription. It could look something like this:

export function rxStompServiceFactory(
  authenticationService: AuthenticationService,
  router: Router
) {
  const rxStomp = new RxStompService();
  const token = authenticationService.getToken();
  let connectHeaders = {};
  if (token) {
    connectHeaders = {
      connectHeaders: { Authentication: "Bearer " + authService.getToken() },
    };
  }
  rxStomp.configure({ ...myRxStompConfig, ...connectHeaders });
  rxStomp.stompErrors$.subscribe((frame) => {
    console.error("WebSocket error message:", frame.headers["message"]);
    rxStomp
      .deactivate()
      .then(() => {
        router.navigate(["applicationerror"]);
      })
      .catch((error) => {
        console.error(error);
      });
  });
  rxStomp.activate();
  return rxStomp;
}
  1. After that I had to include it in the app.module.ts like the following:
    {
      provide: RxStompConfig,
      useValue: myRxStompConfig,
    },
    {
      provide: RxStompService,
      useFactory: rxStompServiceFactory,
      deps: [AuthenticationService],
    },

Conclusion

Securing WebSockets with STOMP using Spring Boot empowers developers to build robust and secure real-time communication applications. By leveraging Spring Security’s comprehensive authentication and authorization capabilities, STOMP endpoints can be protected from unauthorized access, ensuring data integrity and confidentiality.

The integration of STOMP with Spring Boot simplifies the process of enabling message-oriented communication, while Spring Security provides a unified framework for securing both HTTP and WebSocket endpoints. This combination offers a powerful and flexible approach to building secure and scalable real-time communication solutions.

comments powered by Disqus