The use-case
I wanted to be able to have my API server push updates to my web-based angular app as interesting things took place. The most common use is that someone else posted new messages in a chat thread - but it can also happen when someone else updates a shared resource or other collaboration. Or perhaps even to populate new items into a news feed without needing a page refresh.
To "push" data to my angular app, I knew I was going to need sockets. Grails 3 has updated spring support that allows for the use of spring websockets - which use the STOMP protocol & SockJS. SockJS is a library that abstracts away a lot of the lower level websocket management in browsers and allows fallbacks to long polling if needed. STOMP makes it easy to create per-user topics and other utility methods.
The websocket pattern integrates really nicely with grails reactor integration. As controllers recieve updates from other clients, events can be raised to push notifications to interested listeners.
Starting Out
Understanding the basics of web sockets are outside the scope of this post - but spring has made integrating web sockets over STOMP easy in recent versions.
There are a couple good sources for starting out. There is an up-to-date grails 3 plugin that allows you to use socket topics easily from within existing controllers.
Defining a controller that takes incoming chat requests over socket is easy.
class ChatController { def springSecurityService def messagingService def index = {} @ControllerMethod
@MessageMapping("/incchat") @PreAuthorize("hasRole('ROLE_USER')") @SendToUser("/queue/incchat") /** * Take a JSON input and parse from object
* id: threadid, text: messageText */
String doChat(String jsonIn, Principal principal) {
...
Defining a listener to updates from other requests as a service is easy.
@Consumer
class WebChatService { SimpMessagingTemplate brokerMessagingTemplate @Selector('message.received.chat') void sendChatToUser(WebChatTarget target) { String data = [message: target.message, thread: target.messageThread] as JSON brokerMessagingTemplate.convertAndSendToUser(target.username, "/queue/chat", data) } }
SOCKJS allows http websocket upgrades - so make sure to use a http:// base url if your sockets are using sockjs. Use ws:// otherwise. Make sure to allow origins that would allow your angular app to connect if its running on a different host.
@Override
public void registerStompEndpoints(StompEndpointRegistry stompEndpointRegistry) { stompEndpointRegistry.addEndpoint("/stomp") .setAllowedOrigins("*") .withSockJS() //SOCKJS requires http connection, other use ws://
.setSessionCookieNeeded(false); }
Then it gets tricky.
Everything works amazingly up to this point if you are using session based authentication. I'm not doing that in the Yolobe stack. Our api is secured via stateless JWT to make mobile integration easier -you just need a couple services and an http interceptor to have angular send the correct headers every time. But what about Web sockets - can you send the right headers to have JWT work on websocket? Kind of.
Spring sockets was recently updated to allow JWT type tokens. The provided example will allow basic JWT authentication to work. But user-based topics and queues will stop functionining as the principal user is missing from the session. Youll have to inject it on every call.
A full solution
You'll need to create a pretty extensive configuration bean to integrate spring websocket and spring security rest:
Your configuration bean
webSocketConfig(YolobeWebSocketConfigurationBean) { jwtService = ref("jwtService") }
Will need to parse JWT tokens on each request (inside configureClientInboundChannel )- assign the principal to the session/message, then forward the request to a new principal-aware user registry.
Configuration Bean
@Configuration
@EnableWebSocketMessageBroker
@Order(HIGHEST_PRECEDENCE) public class YolobeWebSocketConfigurationBean extends AbstractWebSocketMessageBrokerConfigurer { public DefaultSimpUserRegistry _userRegistry = new DefaultSimpUserRegistry(); public DefaultUserDestinationResolver _resolver = new DefaultUserDestinationResolver(_userRegistry) JwtService jwtService
MessageBrokerRegistry messageBrokerRegistry
@Bean
@Primary
public SimpUserRegistry userRegistry() { return _userRegistry; } @Bean
@Primary
public UserDestinationResolver userDestinationResolver() { return _resolver; } def tokenPattern = ~/Bearer\s(?<token>\S+)/
private Principal getUser(StompHeaderAccessor accessor) { String header = accessor.getNativeHeader("Authorization")[0] def matcher = (header =~ tokenPattern) if (matcher.matches()) { try { JWT jwt = jwtService.parse(matcher.group('token')) return new SocketPrincipal(username:jwt.payload.toJSONObject()["sub"]); } catch (JOSEException formatError) { throw new Exception("Bad Authorization: Bearer - Token") } } throw new Exception("Bad Authorization: Bearer - Token") } @Override
public void configureClientInboundChannel(ChannelRegistration registration) { registration.setInterceptors(new ChannelInterceptorAdapter() { //authenticate user on connection request
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) { StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); Principal yourAuth = getUser(accessor) if (accessor.messageType == SimpMessageType.CONNECT) { YolobeWebSocketConfigurationBean.this._userRegistry.onApplicationEvent(new SessionConnectedEvent(this, message, yourAuth)); } else if (accessor.messageType == SimpMessageType.SUBSCRIBE) { YolobeWebSocketConfigurationBean.this._userRegistry.onApplicationEvent(new SessionSubscribeEvent(this, message, yourAuth)); } else if (accessor.messageType == SimpMessageType.UNSUBSCRIBE) { YolobeWebSocketConfigurationBean.this._userRegistry.onApplicationEvent(new SessionUnsubscribeEvent(this, message, yourAuth)); } else if (accessor.messageType == SimpMessageType.DISCONNECT) { YolobeWebSocketConfigurationBean.this._userRegistry.onApplicationEvent(new SessionDisconnectEvent(this, message, accessor.sessionId, CloseStatus.NORMAL)); } accessor.setUser(yourAuth); accessor.setLeaveMutable(true); return MessageBuilder.createMessage(message.payload, accessor.messageHeaders) } }); } @Override
public void configureMessageBroker(MessageBrokerRegistry messageBrokerRegistry) { messageBrokerRegistry.enableSimpleBroker("/queue", "/topic"); messageBrokerRegistry.setUserDestinationPrefix("/user") messageBrokerRegistry.setApplicationDestinationPrefixes("/app"); this.messageBrokerRegistry = messageBrokerRegistry; } @Override
public void registerStompEndpoints(StompEndpointRegistry stompEndpointRegistry) { stompEndpointRegistry.addEndpoint("/stomp") .setAllowedOrigins("*") .withSockJS() //SOCKJS requires http connection, other use ws:// .setSessionCookieNeeded(false); } @Bean
public GrailsSimpAnnotationMethodMessageHandler grailsSimpAnnotationMethodMessageHandler(SubscribableChannel clientInboundChannel, MessageChannel clientOutboundChannel, SimpMessagingTemplate brokerMessagingTemplate) { GrailsSimpAnnotationMethodMessageHandler handler = new GrailsSimpAnnotationMethodMessageHandler(clientInboundChannel, clientOutboundChannel, brokerMessagingTemplate); handler.setDestinationPrefixes(["/app","/queue","/topic"]); return ((GrailsSimpAnnotationMethodMessageHandler) (handler)); } }
Client side
I used this fork of ng-stomp that allows multiple socket connections and disconnection notifications. here is some ugly sample code that shows what is possible. Credentials.decorate() is an angular service that holds stored credentials (persisted to localstorage) and creates a header map.
decorate : function() { return { 'Authorization': this.getType() + " " + this.getToken() };
}
$stomp .connect("messaging", platformApiEndpoint.host+'/stomp', Credentials.decorate() ) .then(function (frame) { console.log(frame); // notify callback function
var showResponse = function (res) { console.log("RESPONSE! OMG", JSON.stringify(JSON.parse(res.body)));
};
$stomp.subscribe("messaging", '/user/queue/chat', Credentials.decorate()).then(null, null, showResponse); $timeout(function() { //example sending a message to topic
$stomp.send("messaging", '/app/incchat', { id: $scope.threadId, text: "connected VIA SOCK"
}, Credentials.decorate() ); }, 1000);