Secure Real-Time WebSockets With RabbitMQ

I wanted to add real-time notification support to my website.   Well not just my website but all my other native applications (Desktop/iPhone/Android).  I also needed to secure it with user authentication and authorization.

I started with a single Ruby on Rails application.  Rails has convenient built-in WebSocket support via Action Cable.  One problem with Action Cable is that it doesn’t scale.  There is a good article describing the pros and cons of Action Cable here:  https://www.ably.io/blog/rails-actioncable-the-good-and-the-bad

At LayerKeep we are using a micro-service architecture where we have different services written in different languages. Currently our inter-service communication is done via RabbitMQ.

I think RabbitMQ is awesome and when it comes to routing messages, it’s the best.  It’s also built on top of Erlang so it’s very fast. It has a plugin architecture that allows you to enable many different features.  And it’s very simple to use.  Since we are already using RabbitMQ for our inter-service communication I thought it would be nice to be able to use the same backend for handling our WebSocket connections.   

Rabbit has a nice plugin just for that using Web STOMP  (https://www.rabbitmq.com/web-stomp.html)

(* You can also use MQTT protocol instead of STOMP using https://www.rabbitmq.com/web-mqtt.html) .

Since this plugin ships in the core distribution, simply enable it with:

rabbitmq-plugins enable rabbitmq_web_stomp

Depending on how you configure stomp you could connect directly to it.  I personally like to proxy my requests through nginx.  If you are using nginx then something as simple as a location path like this should work.

location /ws {
  proxy_set_header Host $http_host;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_pass     http://your_rabbitmq_host:15674;
}

Then for you website you can find a nice javascript client to connect with stomp.  I personally use https://www.npmjs.com/package/webstomp-client.  Seems to work pretty well.  I’m also using React so I wrapped it in another component to handle all the connection and subscription events.   The nice thing about having the proxy is I don’t have to worry about changing the url or port.

import webstomp from 'webstomp-client';
setupWebSocket() {
   var protocol = document.location.protocol == "https:" ? 'wss' : 'ws';
   var wspath = `${protocol}://${document.location.hostname}/ws`
   var client = webstomp.client(wspath, {debug: false});   
   client.ws.onopen = this.onOpen
   this.setState( { client: client })
}

If you just want to receive transient notifications while connected, you can connect using a temporary queue.  As soon as the connection is disconnected the queue will be removed.  This means that if a notification is sent when a user refreshes the page the message wont be sent to the user.

You can also make the queue permanent so if messages come in while the user isn’t connected they will be persisted and as soon as the user connects they will receive all the previous messages.

What about Authentication/Authorization?

I really liked the idea of using Rabbit but needed to figure out how to handle authentication and authorization.  

RabbitMQ has a default internal user/password authentication that uses its own internal user database.  We don’t want to have to manage all ours users in multiple places just for queues so we need something dynamic.  Luckily there is a RabbitMQ plugin for handling auth using http (https://github.com/rabbitmq/rabbitmq-auth-backend-http).

When you try to connect to Rabbit with a username/password it will make a request to your backend server to authenticate that username/password.  This is great but I don’t want the user to have to enter their username and password again.  I figured authenticating using an OAuth token could work well.

RabbitMQ has auth_backends that it will try in order.  For example:

auth_backends.1.authn = internal
auth_backends.1.authz = rabbit_auth_backend_ip_range
auth_backends.2       = rabbit_auth_backend_http
auth_http.http_method   = post
auth_http.user_path     = http://auth_api/auth/user
auth_http.vhost_path    = http://auth_api/auth/vhost
auth_http.resource_path = http://auth_api/auth/resource
auth_http.topic_path    = http://auth_api/auth/topic
This configuration says that when a user tries to connect it will lookup the username and password in its internal database first.  If the user exists and is authenticated it will then check the IP address to see if the request is coming from an authorized IP address.  (That part isn’t needed but you can find it here: https://github.com/gotthardp/rabbitmq-auth-backend-ip-range)
If the user isn’t authed via auth_backends.1,  it will go to auth_backends.2.
By only specifying auth_backends.2, that means it will go to http backend for both authentication and authorization.
Now that we have the configured our backend, we need to go add those routes.
Depending on how complicated your authorization logic is, you might want a separate route for all them or you can add a single route.  In Rails it would be something like this:
post '/auth/:kind', to: 'rabbit#auth'
Then add your handler which should always return a 200 status with the permission (“allow” or “deny”)
def auth
  // Get the user

  user = User.find_by(username: params["username"])
  render status: :ok, json: "deny" and return unless user

  // First call will be to /auth/user

  if params["kind"] == "user"
    token = user.access_tokens.where(token: params["password"]).first
    if token.nil? || !token.accessible?
      permission = "deny"
    end
  else
    /***
     *** Handle all the other authorizations your app
     *** requires something like
     ***/

    if params["resource"] == "exchange" and params["permission"] != "read"
      permission = "deny"
    end
  end
  render status: :ok, json: permission and return
end

Look at the http backend plugin for all the other parameters.

You can find a docker image for RabbitMQ with http auth config here:  https://github.com/frenzylabs/rabbitmq.