Websocket Authentication and Authorization in Spring

I try a lot to correctly implement Stomp (websocket) Authentication and Authorization using Spring-Security. For posterity, I will answer my question to provide guidance.


Problem

Spring WebSocket documentation (for authentication) looks fuzzy ATM (IMHO). And I could not understand how to properly handle Authentication and Authorization .


What I want

  • Authenticate users with username / password.
  • Prevent anonymous CONNECT users through WebSocket.
  • Add authorization level (user, admin, ...).
  • The availability of Principal available in controllers.


What I do not want

  • Authentication at HTTP negotiation endpoints (since most JavaScript libraries do not send authentication headers along with an HTTP negotiation call).
+16
java spring authentication spring-boot authorization websocket
source share
2 answers

As stated above, the documentation (ATM) is unclear until Spring provides clear documentation, here is an example that will help you not spend two days trying to figure out what the security chain is doing.

Rob Leggett made a really good attempt, but he broke up some classes in Springs and I feel uncomfortable.

What you need to know:

  • The security chain and security configuration for http and WebSocket are completely independent.
  • Spring AuthenticationProvider is not involved in Websocket authentication at all.
  • Authentication will not occur at the endpoint of the HTTP negotiation, because none of the STOMP (websocket) JavaScripts sends authentication headers along with the HTTP request.
  • After setting up the CONNECT request, the user ( simpUser ) will be saved in the websocket session, and authentication will not be required for further messages.

Maven deps

 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-messaging</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-messaging</artifactId> </dependency> 

WebSocket Configuration

The configuration below registers a simple message broker (note that it has nothing to do with authentication or authorization).

 @Configuration @EnableWebSocketMessageBroker public class WebSocketConfig extends WebSocketMessageBrokerConfigurer { @Override public void configureMessageBroker(final MessageBrokerRegistry config) { // These are endpoints the client can subscribes to. config.enableSimpleBroker("/queue/topic"); // Message received with one of those below destinationPrefixes will be automatically router to controllers @MessageMapping config.setApplicationDestinationPrefixes("/app"); } @Override public void registerStompEndpoints(final StompEndpointRegistry registry) { // Handshake endpoint registry.addEndpoint("stomp"); // If you want to you can chain setAllowedOrigins("*") } } 

Spring Security Configuration

Since the Stomp protocol is based on the first HTTP request, we need to authorize an HTTP call for our Stomp handshake endpoint.

 @Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(final HttpSecurity http) throws Exception { // This is not for websocket authorization, and this should most likely not be altered. http .httpBasic().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() .authorizeRequests().antMatchers("/stomp").permitAll() .anyRequest().denyAll(); } } 


Then we will create a service responsible for authenticating users.

 @Component public class WebSocketAuthenticatorService { // This method MUST return a UsernamePasswordAuthenticationToken instance, the spring security chain is testing it with 'instanceof' later on. So don't use a subclass of it or any other class public UsernamePasswordAuthenticationToken getAuthenticatedOrFail(final String username, final String password) throws AuthenticationException { if (username == null || username.trim().isEmpty()) { throw new AuthenticationCredentialsNotFoundException("Username was null or empty."); } if (password == null || password.trim().isEmpty()) { throw new AuthenticationCredentialsNotFoundException("Password was null or empty."); } // Add your own logic for retrieving user in fetchUserFromDb() if (fetchUserFromDb(username, password) == null) { throw new BadCredentialsException("Bad credentials for user " + username); } // null credentials, we do not pass the password along return new UsernamePasswordAuthenticationToken( username, null, Collections.singleton((GrantedAuthority) () -> "USER") // MUST provide at least one role ); } } 

Note that: UsernamePasswordAuthenticationToken MUST have GrantedAuthorities, if you use a different constructor, Spring will automatically set isAuthenticated = false .


Almost there, now we need to create an Interceptor that will set the simpUser header or throw a simpUser AuthenticationException for CONNECT messages.

 @Component public class AuthChannelInterceptorAdapter extends ChannelInterceptor { private static final String USERNAME_HEADER = "login"; private static final String PASSWORD_HEADER = "passcode"; private final WebSocketAuthenticatorService webSocketAuthenticatorService; @Inject public AuthChannelInterceptorAdapter(final WebSocketAuthenticatorService webSocketAuthenticatorService) { this.webSocketAuthenticatorService = webSocketAuthenticatorService; } @Override public Message<?> preSend(final Message<?> message, final MessageChannel channel) throws AuthenticationException { final StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); if (StompCommand.CONNECT == accessor.getCommand()) { final String username = accessor.getFirstNativeHeader(USERNAME_HEADER); final String password = accessor.getFirstNativeHeader(PASSWORD_HEADER); final UsernamePasswordAuthenticationToken user = webSocketAuthenticatorService.getAuthenticatedOrFail(username, password); accessor.setUser(user); } return message; } } 

Note that: preSend() MUST return a UsernamePasswordAuthenticationToken , another element in the Spring security chain checks this. Please note: if your UsernamePasswordAuthenticationToken was created without passing GrantedAuthority , authentication will fail because the constructor without granted authority automatically sets authenticated = false THIS IS AN IMPORTANT DETAILS that is not documented in Spring-Security .


Finally, create two more classes to handle authorization and authentication, respectively.

 @Configuration @Order(Ordered.HIGHEST_PRECEDENCE + 99) public class WebSocketAuthenticationSecurityConfig extends WebSocketMessageBrokerConfigurer { @Inject private AuthChannelInterceptorAdapter authChannelInterceptorAdapter; @Override public void registerStompEndpoints(final StompEndpointRegistry registry) { // Endpoints are already registered on WebSocketConfig, no need to add more. } @Override public void configureClientInboundChannel(final ChannelRegistration registration) { registration.setInterceptors(authChannelInterceptorAdapter); } } 

Please note: @Order is CRUCIAL , do not forget it, it allows our interceptor to be registered first in the security chain.


 @Configuration public class WebSocketAuthorizationSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer { @Override protected void configureInbound(final MessageSecurityMetadataSourceRegistry messages) { // You can customize your authorization mapping here. messages.anyMessage().authenticated(); } // TODO: For test purpose (and simplicity) i disabled CSRF, but you should re-enable this and provide a CRSF endpoint. @Override protected boolean sameOriginDisabled() { return true; } } 

Good luck

+35
source share

for java client side use this example:

 StompHeaders connectHeaders = new StompHeaders(); connectHeaders.add("login", "test1"); connectHeaders.add("passcode", "test"); stompClient.connect(WS_HOST_PORT, new WebSocketHttpHeaders(), connectHeaders, new MySessionHandler); 
+2
source share

All Articles