Tuesday, December 20, 2016

Grails 3 Push Notifications: Spring Web Sockets, AngularJS, JWT.

Apparantly its not very common to want to combine these sets of technologies. But I did, and I wanted to share some of the tips I found, and hopefully combine the knowledge into one place. There are a bunch of different sources that will help you solve several problems you may encounter.

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);

No comments:

Post a Comment