Spring 4 AbstractWebSocketMessageBrokerConfigurer With SockJS Not Negotiating Transport Properly

Bal picture Bal · May 6, 2014 · Viewed 12.1k times · Source

So I must say that all of the websocket tutorials/examples appear to be so easy, but it seems you really have to dig to find really important pieces of information that are left out of the simple examples. I'm stil having quite a few issues with my webapp using the Spring 4 Stomp message broker with SockJS on the front end.

Currently, if I add an endpoint to the StompEndpointRegistry without enabling SockJS(), then declare my socket on the front end using dojo's dojox/socket, Firefox 28 will open a websocket just fine. However, I need support in IE8 and IE9, so I switched to SockJS. Using AbstractAnnotationConfigDispatcherServletInitializer, it took me quite a bit of time to figure out how to ensure all filters and servlets were set to use async (very sparse documentation on the web for this). Once I solved this, I can now get it to work in Firefox, but only using xhr_streaming. With sessionCookieNeeded set to true, IE9 defaults to trying to use iframes for the connection, however, it fails:

LOG: Opening Web Socket... 
LOG: Opening transport: iframe-htmlfile  url:rest/hello/904/ft3apk1g  RTO:1008 
LOG: Closed transport: iframe-htmlfile SimpleEvent(type=close, code=1006, reason=Unable to load an iframe (onload timeout), wasClean=false) 
LOG: Opening transport: iframe-xhr-polling  url:rest/hello/904/bf63eisu  RTO:1008 
LOG: Closed transport: iframe-xhr-polling SimpleEvent(type=close, code=1006, reason=Unable to load an iframe (onload timeout), wasClean=false) 
LOG: Whoops! Lost connection to undefined 

if I set the cookie needed to false, IE will use xdr-streaming and work fine, however, it loses the jsessionid cookie in the requests and in turn I lose the ability to acquire the Principal in the controller which is important for me. I enabled the same origin x frame headers in spring security and I've verified the headers are present in the requests, but it didn't help. So I'd like to be able to figure out how to A) make Spring and SockJS properly negotiate using WebSocket transport in Firefox, and B) get IE8 and 9 to properly use iframe transport so I can keep cookies.

Here's my config/code:

Web app config:

public class WebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {

    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        super.onStartup(servletContext);
        Map<String, ? extends FilterRegistration> registrations = servletContext.getFilterRegistrations();
    }

    @Override
    protected void customizeRegistration(ServletRegistration.Dynamic registration) {
        // this is needed for async support for websockets/sockjs
        registration.setInitParameter("dispatchOptionsRequest", "true");
        registration.setAsyncSupported(true);
    }

    @Override
    protected Class<?>[] getRootConfigClasses() {
        return new Class[]{SecurityConfig.class, Log4jConfig.class, PersistenceConfig.class, ServiceConfig.class};
    }

    @Override
    protected Class<?>[] getServletConfigClasses() {
        // loading the Initializer class from the dispatcher servlet context ensures it only executes once,
        // as the ContextRefreshedEvent fires once from the root context and once from the dispatcher servlet context   
        return new Class[]{SpringMvcConfig.class, WebSocketConfig.class};
    }

    @Override
    protected String[] getServletMappings() {
        return new String[]{
            "/rest/*",
            "/index.html",
            "/login.html",
            "/admin.html",
            "/index/*",
            "/login/*",
            "/admin/*"
        };
    }

    @Override
    protected Filter[] getServletFilters() {
        OpenEntityManagerInViewFilter openEntityManagerInViewFilter = new OpenEntityManagerInViewFilter();
        openEntityManagerInViewFilter.setBeanName("openEntityManagerInViewFilter");
        openEntityManagerInViewFilter.setPersistenceUnitName("HSQL");

        CharacterEncodingFilter encodingFilter = new CharacterEncodingFilter();
        encodingFilter.setEncoding("UTF-8");
        encodingFilter.setForceEncoding(true);

        return new javax.servlet.Filter[]{openEntityManagerInViewFilter, encodingFilter};
    }

}

Spring MVC config:

@Configuration
@EnableWebMvc
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
@ComponentScan(basePackages = "x.controllers")  // Only scan for controllers.  Other classes are scanned in the parent's root context
public class SpringMvcConfig extends WebMvcConfigurerAdapter {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/css/**").addResourceLocations("/css/").setCachePeriod(31556926);
        registry.addResourceHandler("/img/**").addResourceLocations("/img/").setCachePeriod(31556926);
        registry.addResourceHandler("/js/**").addResourceLocations("/js/").setCachePeriod(31556926);
    }

    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        converters.add(mappingJacksonHttpMessageConverter());
        converters.add(marshallingMessageConverter());
        super.configureMessageConverters(converters);
    }

    @Bean
    public InternalResourceViewResolver setupViewResolver() {
        InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
        viewResolver.setViewClass(JstlView.class);
        viewResolver.setPrefix("/WEB-INF/jsp/");
        viewResolver.setSuffix(".jsp");
        return viewResolver;
    }

    @Bean
    public JacksonAnnotationIntrospector jacksonAnnotationIntrospector() {
        return new JacksonAnnotationIntrospector();
    }

    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.setAnnotationIntrospector(jacksonAnnotationIntrospector());
        mapper.registerModule(new JodaModule());
        mapper.registerModule(new Hibernate4Module());
        return mapper;
    }

    @Bean
    public MappingJackson2HttpMessageConverter mappingJacksonHttpMessageConverter() {
        MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
        messageConverter.setObjectMapper(objectMapper());
        return messageConverter;
    }

    @Bean(name = "marshaller")
    public Jaxb2Marshaller jaxb2Marshaller() {
        Jaxb2Marshaller marshaller = new Jaxb2Marshaller();
        marshaller.setContextPath("com.x);
        return marshaller;
    }

    @Bean
    public MarshallingHttpMessageConverter marshallingMessageConverter() {
        return new MarshallingHttpMessageConverter(
                jaxb2Marshaller(),
                jaxb2Marshaller()
        );
    }
}

Spring root context config:

@Configuration
@EnableTransactionManagement
@ComponentScan(basePackages = {"com.x.services"},   // scan for all annotated classes for the root context OTHER than controllers -- those are in the child web context. also don't rescan these config files
        excludeFilters = {
            @ComponentScan.Filter(type = FilterType.ANNOTATION, value = Controller.class),
            @ComponentScan.Filter(type = FilterType.ANNOTATION, value = Configuration.class)
        }
)
public class ServiceConfig {

    @Bean
    public DefaultAnnotationHandlerMapping defaultAnnotationHandlerMapping() {
        DefaultAnnotationHandlerMapping handlerMapping = new DefaultAnnotationHandlerMapping();
        handlerMapping.setAlwaysUseFullPath(true);
        handlerMapping.setDetectHandlersInAncestorContexts(true);
        return handlerMapping;
    }

    @Bean
    public DefaultConversionService defaultConversionService() {
        return new DefaultConversionService();
    }

    @Bean(name = "kmlContext")
    public JAXBContext kmlContext() throws JAXBException {
        return JAXBContext.newInstance("net.opengis.kml");
    }

    @Bean(name = "ogcContext")
    public JAXBContext ogcContext() throws JAXBException {
        return JAXBContext.newInstance("net.x");
    }
}

Spring security:

@Configuration
@EnableWebMvcSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private CustomUserDetailsService userDetailsService;
    @Autowired
    private CustomAuthenticationProvider customAuthenticationProvider;

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        AuthenticationProvider rememberMeAuthenticationProvider = rememberMeAuthenticationProvider();
        TokenBasedRememberMeServices tokenBasedRememberMeServices = tokenBasedRememberMeServices();

        List<AuthenticationProvider> authenticationProviders = new ArrayList<AuthenticationProvider>(2);
        authenticationProviders.add(rememberMeAuthenticationProvider);
        authenticationProviders.add(customAuthenticationProvider);
        AuthenticationManager authenticationManager = authenticationManager(authenticationProviders);

        http
                .csrf().disable()
                //.headers().disable()
                .headers().addHeaderWriter(new XFrameOptionsHeaderWriter(XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN))
                .and()
                .authenticationProvider(customAuthenticationProvider)
                .addFilter(new RememberMeAuthenticationFilter(authenticationManager, tokenBasedRememberMeServices))
                .rememberMe().rememberMeServices(tokenBasedRememberMeServices)
                .and()
                .authorizeRequests()
                .antMatchers("/js/**", "/css/**", "/img/**", "/login", "/processLogin").permitAll()
                .antMatchers("/index.jsp", "/index.html", "/index").hasRole("USER")
                .antMatchers("/admin", "/admin.html", "/admin.jsp", "/js/saic/jswe/admin/**").hasRole("ADMIN")
                .and()
                .formLogin().loginProcessingUrl("/processLogin").loginPage("/login").usernameParameter("username").passwordParameter("password").permitAll()
                .and()
                .exceptionHandling().accessDeniedPage("/login")
                .and()
                .logout().permitAll();
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/js/**", "/css/**", "/img/**");
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(List<AuthenticationProvider> authenticationProviders) {
        return new ProviderManager(authenticationProviders);
    }

    @Bean
    public TokenBasedRememberMeServices tokenBasedRememberMeServices() {
        return new TokenBasedRememberMeServices("testKey", userDetailsService);
    }

    @Bean
    public AuthenticationProvider rememberMeAuthenticationProvider() {
        return new org.springframework.security.authentication.RememberMeAuthenticationProvider("testKey");
    }

    protected void registerAuthentication(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
    }
}

WebSocket message broker config:

@Configuration
@EnableWebSocketMessageBroker
@EnableScheduling
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {

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

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
            SockJsServiceRegistration registration = registry.addEndpoint("/hello").withSockJS().setClientLibraryUrl("http://localhost:8084/swtc/js/sockjs-0.3.4.min.js");
            registration.setWebSocketEnabled(true);
            //registration.setSessionCookieNeeded(false);

    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.taskExecutor().corePoolSize(4).maxPoolSize(8);
    }

    @Override
    public void configureClientOutboundChannel(ChannelRegistration registration) {
        registration.taskExecutor().corePoolSize(4).maxPoolSize(8);
    }

}

WebSocket controller:

@Controller
public class WebSocketController {
    @MessageMapping({"/hello", "/hello/**"})
    @SendTo("/topic/greetings")
    // in order to get principal, you must set cookiesNeeded in WebSocketConfig, which forces IE to use iframes, which doesn't seem to work
    public AjaxResponse<String> greeting(@Payload PointRadiusRequest prr, Principal principal) throws Exception {
        Thread.sleep(3000); // simulated delay
        AjaxResponse<String> ajaxResponse = new AjaxResponse<String>();
        ajaxResponse.setValue(principal.getName());
        ajaxResponse.setSuccess(true);
        return ajaxResponse;
    } 
}

And finally, the javascript in my html I'm using to test:

<script>
            // test/prototype websocket code
            stompClient = null;

            window.connect = function() {

                var options = {protocols_whitelist: ["websocket", "xhr-streaming", "xdr-streaming", "xhr-polling", "xdr-polling", "iframe-htmlfile", "iframe-eventsource", "iframe-xhr-polling"], debug: true}; 
                wsSocket = new SockJS('rest/hello', undefined, options);

                stompClient = Stomp.over(wsSocket);
                stompClient.connect({}, function(frame) {
                    console.log('Connected: ' + frame);
                    stompClient.subscribe('/topic/greetings', function(message) {
                        console.info("response: ", JSON.parse(message.body));
                    });
                });
            };

            window.disconnect = function() {
                stompClient.disconnect();
                console.log("Disconnected");
            };

            window.sendName = function() {
                stompClient.send("/app/hello", {}, JSON.stringify({'latitude': 12, 'longitude': 123.2, radius: 3.14}));
            };
        </script>

When I connect in Firefox, this is what I see in the console:

>>> connect()
connecting
/swtc/ (line 109)
Opening Web Socket...
stomp.js (line 130)
undefined
GET http://localhost:8084/swtc/rest/hello/info

200 OK
        202ms   
sockjs....min.js (line 27)
Opening transport: websocket url:rest/hello/007/xkc17fkt RTO:912
sockjs....min.js (line 27)
SyntaxError: An invalid or illegal string was specified


...3,reason:"All transports failed",wasClean:!1,last_event:g})}f.readyState=y.CLOSE...

sockjs....min.js (line 27)
Closed transport: websocket SimpleEvent(type=close, code=2007, reason=Transport timeouted, wasClean=false)
sockjs....min.js (line 27)
Opening transport: xhr-streaming url:rest/hello/007/8xz79yip RTO:912
sockjs....min.js (line 27)
POST http://localhost:8084/swtc/rest/hello/007/8xz79yip/xhr_streaming

200 OK
        353ms   
sockjs....min.js (line 27)
Web Socket Opened...

>>> CONNECT
accept-version:1.1,1.0
heart-beat:10000,10000

�

stomp.js (line 130)
POST http://localhost:8084/swtc/rest/hello/007/8xz79yip/xhr_send

204 No Content
        63ms    

<<< CONNECTED
user-name:first.mi.last
heart-beat:0,0
version:1.1

�

stomp.js (line 130)
connected to server undefined
stomp.js (line 130)

Connected: CONNECTED
version:1.1
heart-beat:0,0
user-name:xxx

>>> SUBSCRIBE
id:sub-0
destination:/topic/greetings

�

stomp.js (line 130)
POST http://localhost:8084/swtc/rest/hello/007/8xz79yip/xhr_send

204 No Content
        57ms

The /info response is:

{"entropy":441118013,"origins":["*:*"],"cookie_needed":true,"websocket":true}

Note the weird string error when it tries to make the websocket connection. I'm guessing that's the source of my problems, but I'm not doing anything funny and I have no idea what's causing it.

In IE, here is the network traffic. The iframe.html files seem to be built properly, but it just can't make the connection to the back-end.

URL Method  Result  Type    Received    Taken   Initiator   Wait‎‎  Start‎‎ Request‎‎   Response‎‎  Cache read‎‎    Gap‎‎
/swtc/rest/hello/info?t=1399328502157   GET 200 application/json    411 B   328 ms      0   47  281 0   0   2199
/swtc/rest/hello/iframe.html    GET 200 text/html   0.97 KB 156 ms  frame navigate  328 0   156 0   0   2043
/swtc/js/sockjs-0.3.4.min.js    GET 304 application/javascript  157 B   < 1 ms  <script>    484 0   0   0   0   2043
/swtc/rest/hello/iframe.html    GET 304 text/html   191 B   < 1 ms  frame navigate  2527    0   0   0   0   0
/swtc/js/sockjs-0.3.4.min.js    GET 304 application/javascript  157 B   < 1 ms  <script>    2527    0   0   0   0   0

The info response looks like this:

{"entropy":-475136625,"origins":["*:*"],"cookie_needed":true,"websocket":true}

If anybody wants to see the request or response headers, just let me know.

UPDATE 1:

Rossen, thanks for the response. Everything I know about Spring 4 I learned from you :)

Firefox isn't actually working (completely), I can't get a websocket session, it downgrades to xhr-streaming. With xhr-streaming, there are no issues, but I'd like to have a true websocket session.

With IE, I'm not sure what removing the headers will confirm? I thought the x frame header only affected the iframe session, which doesn't work at all. IE uses xdr-streaming (and works, albeit without the ability to fetch the Principal) when I disable require cookies. Once I enable cookies, IE properly ATTEMPTS to use iframes. But even with the headers in place, all attempts fail:

    http://localhost:8084/swtc/rest/hello/info?t=1399328502157

        Key Value
        Response    HTTP/1.1 200 OK
        Server  Apache-Coyote/1.1
        X-Frame-Options SAMEORIGIN
        Access-Control-Allow-Origin http://localhost:8084
        Access-Control-Allow-Credentials    true
        Cache-Control   no-store, no-cache, must-revalidate, max-age=0
        Content-Type    application/json;charset=UTF-8
        Content-Length  78
        Date    Mon, 05 May 2014 22:21:42 GMT

LOG: Opening Web Socket... 
LOG: Opening transport: iframe-htmlfile  url:rest/hello/904/ft3apk1g  RTO:1008 
LOG: Closed transport: iframe-htmlfile SimpleEvent(type=close, code=1006, reason=Unable to load an iframe (onload timeout), wasClean=false) 
LOG: Opening transport: iframe-xhr-polling  url:rest/hello/904/bf63eisu  RTO:1008 
LOG: Closed transport: iframe-xhr-polling SimpleEvent(type=close, code=1006, reason=Unable to load an iframe (onload timeout), wasClean=false) 
LOG: Whoops! Lost connection to undefined 

Both iframe-htmlfile and iframe-xhr-polling fail. I do indeed clear cache with each refresh in IE and I do have debug mode enabled in SockJS. I would be fine living with xdr-streaming in IE, but I really need the jsessionid cookie.

Any thoughts?

On a side note, it would be really nice if the client library code supported relative paths (it actually does build the html file with the relative path and should work, but still produces errors in the log), ie:

SockJsServiceRegistration registration = registry.addEndpoint("/hello").withSockJS().setClientLibraryUrl("js/sockjs-0.3.4.min.js");

That would make deploying to production less painful.

UPDATE 2:

Quick summary: there was no change.

Here is my attempt to connect in IE9 with .headers().and() in my security config:

LOG: Opening Web Socket... 
LOG: Opening transport: iframe-htmlfile  url:rest/hello/924/1ztfjm7z  RTO:330 
LOG: Closed transport: iframe-htmlfile SimpleEvent(type=close, code=2007, reason=Transport timeouted, wasClean=false) 
LOG: Opening transport: iframe-xhr-polling  url:rest/hello/924/cgq8_s5j  RTO:330 
LOG: Closed transport: iframe-xhr-polling SimpleEvent(type=close, code=2007, reason=Transport timeouted, wasClean=false) 
LOG: Whoops! Lost connection to undefined 

The request headers for /info:

Key Value
Request GET /swtc/rest/hello/info?t=1399404419358 HTTP/1.1
Accept  */*
Origin  http://localhost:8084
Accept-Language en-US
UA-CPU  AMD64
Accept-Encoding gzip, deflate
User-Agent  Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)
Host    localhost:8084
Connection  Keep-Alive
Cache-Control   no-cache

and the response headers:

Key Value
Response    HTTP/1.1 200 OK
Server  Apache-Coyote/1.1
X-Content-Type-Options  nosniff
X-XSS-Protection    1; mode=block
Cache-Control   no-cache, no-store, max-age=0, must-revalidate
Pragma  no-cache
Expires 0
X-Frame-Options DENY
Access-Control-Allow-Origin http://localhost:8084
Access-Control-Allow-Credentials    true
Cache-Control   no-store, no-cache, must-revalidate, max-age=0
Content-Type    application/json;charset=UTF-8
Content-Length  78
Date    Tue, 06 May 2014 19:26:59 GMT

There was no difference in Firefox. I get the same weird string error when it tries to open the websocket, then falls back to xhr-streaming:

Opening transport: websocket url:rest/hello/849/fy_06t1v RTO:342
SyntaxError: An invalid or illegal string was specified
Closed transport: websocket SimpleEvent(type=close, code=2007, reason=Transport timeouted, wasClean=false)
Opening transport: xhr-streaming url:rest/hello/849/2r0raiz8 RTO:342
http://localhost:8084/swtc/rest/hello/849/2r0raiz8/xhr_streaming
Web Socket Opened...
>>> CONNECT
accept-version:1.1,1.0
heart-beat:10000,10000

Answer

Bal picture Bal · May 7, 2014

Since SockJS was producing a weird string error when attempting the WebSocket connection, then fell back to xhr_streaming, I decided to load up the non-minified version of the .js file and debug it in Firebug to see what was going on. Turns out, SockJS does not like relative URLs, which kind of stinks.

For most of my REST/AJAX services, I have /rest/* mapped to my dispatcher servlet, typically have an @RequestMapping on each controller, and another @RequestMapping on each controller method. Using Dojo, I make AJAX calls by specifying the url "rest/<controller>/<method>".

I was attempting the same thing with SockJS. I was just pointing to "rest/hello". I changed this to the fully qualified URL "http://localhost:8084/swtc/rest/hello" and suddenly firefox could build the websocket transport layer just fine. I hopped over to IE for a quick test and sure enough, it built the iframe session and also worked just fine.

Such a silly little problem. I hate having to specify non-relative urls anywhere, as this code base is shared between multiple developers, all who deploy to different servers for testing, and deploy to production. I suppose on the front-end I can dynamically build the URL using window.doc.URL, but it'll be a bit more tricky to get the AbstractWebSocketMessageBrokerConfigurer to automatically work across deployments when specifying setClientLibraryUrl.

Either way kids, don't use relative paths with SockJS.