We are Borg
We are Borg

Reputation: 5313

Spring-websockets : Spring security authorization not working inside websockets

I am working on a Spring-MVC application in which we have Spring-security for authentication and authorization. We are working on migrating to Spring websockets, but we are having an issue with getting the authenticated user inside a websocket connection. The security context simply doesn't exist in the websocket connection, but works fine with regular HTTP. What are we doing wrong?

WebsocketConfig :

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/app").withSockJS();
    }
}

In the controller below, we are trying to get the currently authenticated user and it's always null

@Controller
public class OnlineStatusController extends MasterController{

    @MessageMapping("/onlinestatus")
    public void onlineStatus(String status) {
        Person user = this.personService.getCurrentlyAuthenticatedUser();
        if(user!=null){
            this.chatService.setOnlineStatus(status, user.getId());
        }
    }
}

security-applicationContext.xml :

  <security:http pattern="/resources/**" security="none"/>
    <security:http pattern="/org/**" security="none"/>
    <security:http pattern="/jquery/**" security="none"/>
    <security:http create-session="ifRequired" use-expressions="true" auto-config="false" disable-url-rewriting="true">
        <security:form-login login-page="/login" username-parameter="j_username" password-parameter="j_password"
                             login-processing-url="/j_spring_security_check" default-target-url="/canvaslisting"
                             always-use-default-target="false" authentication-failure-url="/login?error=auth"/>
        <security:remember-me key="_spring_security_remember_me" user-service-ref="userDetailsService"
                              token-validity-seconds="1209600" data-source-ref="dataSource"/>
        <security:logout delete-cookies="JSESSIONID" invalidate-session="true" logout-url="/j_spring_security_logout"/>
        <security:csrf disabled="true"/>
        <security:intercept-url pattern="/cometd/**" access="permitAll" />
        <security:intercept-url pattern="/app/**" access="hasAnyRole('ROLE_ADMIN','ROLE_USER')" />
<!--        <security:intercept-url pattern="/**" requires-channel="https"/>-->
        <security:port-mappings>
            <security:port-mapping http="80" https="443"/>
        </security:port-mappings>
        <security:logout logout-url="/logout" logout-success-url="/" success-handler-ref="myLogoutHandler"/>
        <security:session-management session-fixation-protection="newSession">
            <security:concurrency-control session-registry-ref="sessionReg" max-sessions="5" expired-url="/login"/>
        </security:session-management>
    </security:http>

Upvotes: 4

Views: 4475

Answers (3)

asgarov1
asgarov1

Reputation: 4171

I spent a day dealing with this - so here is my updated answer - hopefully it helps someone:

1. Define ChannelInterceptor:

public final class WebsocketContextChannelInterceptor implements ChannelInterceptor {
    
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        if (message instanceof GenericMessage<?> genericMessage &&
                genericMessage.getHeaders().get("simpUser") instanceof PreAuthenticatedAuthenticationToken token) {
            SecurityContextHolder.getContext().setAuthentication(token);
        }        
        return message;
    }
}

2. Add it to your WebsocketConfig:

Here the issue I had is that apparently, interceptor sets it in a parent thread, and controller that then might want to read the Principal is in child thread. Therefore I had to set SecurityContext to be inheritable (with SecurityContextHolder.MODE_INHERITABLETHREADLOCAL)

Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    /**
     * Required so that Controller threads (child threads) can access SecurityContext that WebSocket set for them (parent thread)
     */
    @PostConstruct
    void setGlobalSecurityContext() {
        SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
    }

    /**
     * Interceptor that adds SecurityContext from the message header
     */
    @Bean
    public ChannelInterceptor websocketContextChannelInterceptor() {
        return new WebsocketContextChannelInterceptor();
    }

    /**
     * Setting the interceptor {{@link #websocketContextChannelInterceptor()}}
     */
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(websocketContextChannelInterceptor());
    }

    /**
     * Adding websocket endpoint
     * @param registry
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/websocket").setAllowedOrigins("*");
    }
}

Upvotes: 0

AlgorithmFromHell
AlgorithmFromHell

Reputation: 370

I remember stumbling across the very same problem in a project I was working on. As I could not figure out the solution using the Spring documentation - and other answers on Stack Overflow were not working for me - I ended up creating a workaround.

The trick is essentially to force the application to authenticate the user on a WebSocket connection request. To do that, you need a class which intercepts such events and then once you have control of that, you can call your authentication logic.

Create a class which implements Spring's ChannelInterceptorAdapter. Inside this class, you can inject any beans you need to perform the actual authentication. My example uses basic auth:

@Component
public class WebSocketAuthInterceptorAdapter extends ChannelInterceptorAdapter {

    @Autowired
    private DaoAuthenticationProvider userAuthenticationProvider;

    @Override
    public Message<?> preSend(final Message<?> message, final MessageChannel channel) throws AuthenticationException {

        final StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
        StompCommand cmd = accessor.getCommand();

        if (StompCommand.CONNECT == cmd || StompCommand.SEND == cmd) {
            Authentication authenticatedUser = null;
            String authorization = accessor.getFirstNativeHeader("Authorization:");
            String credentialsToDecode = authorization.split("\\s")[1];
            String credentialsDecoded = StringUtils.newStringUtf8(Base64.decodeBase64(credentialsToDecode));
            String[] credentialsDecodedSplit = credentialsDecoded.split(":");
            final String username = credentialsDecodedSplit[0];
            final String password = credentialsDecodedSplit[1];
            authenticatedUser = userAuthenticationProvider.authenticate(new UsernamePasswordAuthenticationToken(username, password));
            if (authenticatedUser == null) {
                throw new AccessDeniedException();
            } 
            SecurityContextHolder.getContext().setAuthentication(authenticatedUser);
            accessor.setUser(authenticatedUser);    
        }
        return message;
    }
}

Then, in your WebSocketConfig class, you need to register your interceptor. Add the above class as a bean and register it. After these changes, your class would look like this:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {

    @Autowired
    private WebSocketAuthInterceptorAdapter authInterceptorAdapter;
    

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/app").withSockJS();
    }
    
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.setInterceptors(authInterceptorAdapter);
        super.configureClientInboundChannel(registration);
    }
}

Obviously, the details of the authentication logic are up to you. You can call a JWT service or whatever you are using.

Upvotes: 5

RukaDo
RukaDo

Reputation: 165

If you are using SockJS + Stomp and configured your security correctly, you should be able to connect via regular username/pw authenticator like @AlgorithmFromHell and do

accessor.setUser(authentication.getPrincipal()) // stomp header accessor
   

You can also connect via http://{END_POINT}/access_token={ACCESS_TOKEN}. Spring security should be able to pick it and do loadAuthentication(access_token) via ResourceServerTokenServices. When this is done, you can get your principal by adding this to your impl of AbstractSessionWebSocketMessageBrokerConfigurer or WebSocketMessageBrokerConfigurer. When doing this, for some reason, the loaded Pricipal is saved in "simpUser" header instead.

@Override
  public void configureClientInboundChannel(ChannelRegistration registration) {
    registration.interceptors(new ChannelInterceptor() {
      @Override
      public Message<?> preSend(final Message<?> message, final MessageChannel channel) {
        StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
        if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
          if (message.getHeaders().get("simpUser") != null && message.getHeaders().get("simpUser") instanceof OAuth2Authentication) { // or Authentication depending on your impl of security
            OAuth2Authentication authentication = (OAuth2Authentication) message.getHeaders().get("simpUser");
            accessor.setUser(authentication != null ? (UserDetails) authentication.getPrincipal() : null);
          }

        }
        return message;
      }
    });
  }

Upvotes: 1

Related Questions