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
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.