Enforcing a single web socket connection per user with Node.js, Socket.IO, and Redis



Recently, I have been working on a real-time multi-player browser game and ran into the “single-session” problem. Essentially, I wanted to prevent a user from connecting more than once via web sockets. This is important because being logged on to the same account multiple times could create unfair scenarios and makes the server logic more complex. Since web socket connections are long lived, I needed to find a way to prevent this.

Wish list

  • A user can only be connected once, no matter how many browser tabs they have open. A user can be identified via their authentication token.
  • The system must work in a clustered environment. Individual server nodes should be able to go down without affecting the rest of the system.
  • Authorization tokens should not be passed via query parameters, instead via a dedicated authentication event after the connection is established.
For this project we will use Node.js, Socket.IO, and Redis.

Humble Beginnings

Let’s set up our project and get this show on the road. You can check out the full GitHub repo here. First, we will set up our Socket.IO server to accept connections from the front-end.

const http = require('http');
const io = require('socket.io')();

const PORT = process.env.PORT || 9000;
const server = http.createServer();

io.attach(server);

io.on('connection', (socket) => {
  console.log(`Socket ${socket.id} connected.`);

  socket.on('disconnect', () => {
    console.log(`Socket ${socket.id} disconnected.`);
  });
});

server.listen(PORT);

By default, the server will listen on port 9000 and echo the connection status of each client to the console. Socket.IO provides a built-in mechanism to generate a unique socket id which we will use to identify our client’s socket connection.
Next, we create a sample page to connect to our server. This page consists of a status display, an input box for our secret token (we will use it for authentication down the road) and buttons to connect and disconnect.

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Single User Websocket</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.1.1/socket.io.js"></script>
  <script src="index.js"></script>
</head>
<body>
  <h1>Single User Websocket Demo</h1>
  <p>
    <label for="status">Status: </label>
    <input type="text" id="status"
      name="status" value="Disconnected"
      readonly="readonly" style="width: 300px;"
    />
  </p>
  <p>
    <label for="token">My Token: </label>
    <input type="text" id="token" name="token" value="secret token" />
  </p>
  <p>
    <button id="connect" onclick="connect()">
      Connect
    </button>
    <button id="disconnect" onclick="disconnect()" disabled>
      Disconnect
    </button>
  </p>
</body>
</html>
Also, we need to set up some very rudimentary logic to perform the connect/disconnect and hook up our status and token inputs.

const socketUrl = 'http://localhost:9000';

let connectButton;
let disconnectButton;
let socket;
let statusInput;
let tokenInput;

const connect = () => {
  socket = io(socketUrl, {
    autoConnect: false,
  });

  socket.on('connect', () => {
    console.log('Connected');
    statusInput.value = 'Connected';
    connectButton.disabled = true;
    disconnectButton.disabled = false;
  });

  socket.on('disconnect', (reason) => {
    console.log(`Disconnected: ${reason}`);
    statusInput.value = `Disconnected: ${reason}`;
    connectButton.disabled = false;
    disconnectButton.disabled = true;
  })

  socket.open();
};

const disconnect = () => {
  socket.disconnect();
}

document.addEventListener('DOMContentLoaded', () => {
  connectButton = document.getElementById('connect');
  disconnectButton = document.getElementById('disconnect');
  statusInput = document.getElementById('status');
  tokenInput = document.getElementById('token');
});
This is everything you need to set up a basic web socket client and server. At this moment, we can connect, disconnect, and log the connection status to the user. And all of this in vanilla JavaScript too! 🍻 Next up: authenticating users.

Authentication

Letting users connect without knowing who they are is of little use to us. Let’s add basic token authentication to the connection. We assume that the connection uses SSL/TLS once deployed. Never use an unencrypted connection. Ever. 😶
At this point we have a few options: a) append a user’s token to the query string when they are connecting, or b) let any user connect and require them to send an authentication message after they connect. The Web Socket protocol specification (RFC 6455) does not prescribe a particular way for authentication and it does not allow for custom headers, and since query parameters could be logged by the server, I chose option b) for this example.
We will implement the authentication with socketio-auth by Facundo Olano, an Auth module for Socket.IO which allows us to prompt the client for a token after they connect. Should the user not provide it within a certain amount of time, we will close the connection from the server.

const http = require('http');
const io = require('socket.io')();
const socketAuth = require('socketio-auth');

const PORT = process.env.PORT || 9000;
const server = http.createServer();

io.attach(server);

// dummy user verification
async function verifyUser (token) {
  return new Promise((resolve, reject) => {
    // setTimeout to mock a cache or database call
    setTimeout(() => {
      // this information should come from your cache or database
      const users = [
        {
          id: 1,
          name: 'mariotacke',
          token: 'secret token',
        },
      ];

      const user = users.find((user) => user.token === token);

      if (!user) {
        return reject('USER_NOT_FOUND');
      }

      return resolve(user);
    }, 200);
  });
}

socketAuth(io, {
  authenticate: async (socket, data, callback) => {
    const { token } = data;

    try {
      const user = await verifyUser(token);

      socket.user = user;

      return callback(null, true);
    } catch (e) {
      console.log(`Socket ${socket.id} unauthorized.`);
      return callback({ message: 'UNAUTHORIZED' });
    }
  },
  postAuthenticate: (socket) => {
    console.log(`Socket ${socket.id} authenticated.`);
  },
  disconnect: (socket) => {
    console.log(`Socket ${socket.id} disconnected.`);
  },
})

server.listen(PORT);
We hook up socketAuth by passing it our io instance and configurations options in the form of three events: authenticatepostAuthenticate, and disconnect. First, our authenticate event is triggered after a client connected and emits a subsequent authentication event with a user token payload. Should the client not send this authentication event within a configurable amount of time, socketio-auth will terminate the connection.
Once the user has sent their token, we verify it against our known users in a database. For example purposes, I created an async verifyUser method that mimics a real database or cache lookup. If the user is found, it will be returned, otherwise the promise is rejected with reason USER_NOT_FOUND.
If all goes well, we invoke the callback and mark the socket as authenticated or return UNAUTHORIZED if the token is invalid.
We have to adapt our front-end code to send us the user’s token upon connection. We modify our connect function as follows:

const connect = () => {
  let error = null;

  socket = io(socketUrl, {
    autoConnect: false,
  });

  socket.on('connect', () => {
    console.log('Connected');
    statusInput.value = 'Connected';
    connectButton.disabled = true;
    disconnectButton.disabled = false;

    socket.emit('authentication', {
      token: tokenInput.value,
    });
  });

  socket.on('unauthorized', (reason) => {
    console.log('Unauthorized:', reason);

    error = reason.message;

    socket.disconnect();
  });

  socket.on('disconnect', (reason) => {
    console.log(`Disconnected: ${error || reason}`);
    statusInput.value = `Disconnected: ${error || reason}`;
    connectButton.disabled = false;
    disconnectButton.disabled = true;
    error = null;
  });

  socket.open();
};
We added two things: socket.emit('authentication', { token }) to tell the server who we are and an event listener socket.on('unauthorized') to react to rejections from our server.
Now we have a system in place that let’s us authenticate users and optionally kick them out should they not provide us a token after they initially connect.
This however still does not prevent a user from connecting twice with the same token. Open a separate window and try it out. To force a single session, our server has to smarten up. 💡

Preventing Multiple Connections

Making sure that a user is only connected once is simple enough on a single server since all connections sit in memory. We can simply iterate through all connected clients and compare their ids with the new client. This approach breaks down when we talk about clusters however. There is no easy way to determine if a particular user is connected or not without issuing a query across all nodes. With many users connecting, this creates a bottleneck. Surely there has to be a better way.
Enter distributed locks with Redis.
We will use Redis to lock and unlock resources, in our case: user sessions. Distributed locks are hard and you can read all about them here. For our use case, we will implement a resource lock on a single Redis node. Let’s get started.
The first thing we will do is connect Socket.IO to Redis to enable pub/sub across multiple Socket.IO servers. We will use the socket.io-redis adapter provided by Socket.IO.

const http = require('http');
const io = require('socket.io')();
const socketAuth = require('socketio-auth');
const adapter = require('socket.io-redis');

const PORT = process.env.PORT || 9000;
const server = http.createServer();

const redisAdapter = adapter({
  host: process.env.REDIS_HOST || 'localhost',
  port: process.env.REDIS_PORT || 6379,
  password: process.env.REDIS_PASS || 'password',
});

io.attach(server);
io.adapter(redisAdapter);
This Redis server is used for its pub/sub functionality to coordinate events across multiple Socket.IO instances such as new sockets joining, exchanging messages, or disconnects. In our example, we will reuse the same server for our resource locks, though it could use a different Redis server as well.
Let’s create our Redis client as a separate module and promisify the methods so we can use async / await .

const bluebird = require('bluebird');
const redis = require('redis');

bluebird.promisifyAll(redis);

const client = redis.createClient({
  host: process.env.REDIS_HOST || 'localhost',
  port: process.env.REDIS_PORT || 6379,
  password: process.env.REDIS_PASS || 'password',
});

module.exports = client;
Let’s talk theory for a moment. What is it exactly we are trying to achieve? We want to prevent users from having more than one concurrent web socket connection to us at any given time. For an online game this is important because we want to avoid users using their account for multiple games at the same time. Also, if we can guarantee that only a single user session per user exists, our server logic is simplified.
To make this work, we must keep track of each connection, acquire a lock, and terminate other connections should the same user try to connect again. To acquire a lock, we use Redis’ SET method with NX and an expiration (more on the expiration later). NX will make sure that we only set the key if it does not already exist. If it does, the command returns null . We can use this setup to determine if a session already exists and abort if it does.
We modify our authenticate function as follows:

authenticate: async (socket, data, callback) => {
  const { token } = data;

  try {
    const user = await verifyUser(token);
    const canConnect = await redis
      .setAsync(`users:${user.id}`, socket.id, 'NX', 'EX', 30);

    if (!canConnect) {
      return callback({ message: 'ALREADY_LOGGED_IN' });
    }

    socket.user = user;

    return callback(null, true);
  } catch (e) {
    console.log(`Socket ${socket.id} unauthorized.`);
    return callback({ message: 'UNAUTHORIZED' });
  }
},
Once we have verified that a user has a valid token, we attempt to acquire a lock for their session (line 6). If Redis can SET the key, it means that it did not previously exist. We also added EX 30 to the command to auto-expire the lock after 30 seconds. This is important because our server or Redis might crash and we don’t want to lock out our users forever. The reason I chose 30 seconds is because Socket.IO has a default ping of 25 seconds, that is, every 25 seconds it will probe connected users to see if they are still connected. In the next section, we will make use of this to renew the lock.
To renew the lock, we’re going to hook into the packet event of our socket connection to intercept ping packages. These are received every 25 seconds by default. If a package is not received by then, Socket.IO will terminate the connection.

postAuthenticate: async (socket) => {
  console.log(`Socket ${socket.id} authenticated.`);

  socket.conn.on('packet', async (packet) => {
    if (socket.auth && packet.type === 'ping') {
      await redis.setAsync(`users:${socket.user.id}`, socket.id, 'XX', 'EX', 30);
    }
  });
},
We’re using the postAuthenticate event to register our packet event handler. Our handler then checks if the socket is authenticated via socket.auth and if the packet is of type ping. To renew the lock, we will again use Redis’ SETcommand, this time with XX instead of NXXX states that it will only be set if it already exists. We use this mechanism to refresh the expiration time on the key every 25 seconds.
We can now authenticate users, acquire a lock per user id, and prevent multiple sessions from being created. Our locks will remain in effect as long as the clients report back to our servers every 25 seconds.
Yet, there is one use case we have overlooked: if a user closes their browser with an active connection and attempts to reconnect, they will erroneously receive an ALREADY_LOGGED_IN message. This is because the previous lock is still in effect. To properly release the lock when a user intentionally leaves our site, we must remove the lock from Redis upon disconnect.

disconnect: async (socket) => {
  console.log(`Socket ${socket.id} disconnected.`);

  if (socket.user) {
    await redis.delAsync(`users:${socket.user.id}`);
  }
},
In our disconnect event, we check whether or not the socket was authenticated and then remove the lock from Redis via the DEL command. This cleans up the user session lock and prepares it for the next connection.
That’s all there is to it! To see our connection flow in action, open two browser windows and click Connect in each of them with the same token; you will receive a status of Disconnected: ALREADY_LOGGED_IN on the latter. Exactly what we wanted. Time to sit back and relax. 😅

Conclusion

In this article I described a way to authenticate web socket connections and prevent multiple user sessions through the use of Node.js, Socket.IO, and Redis. This mechanism is stateless and works in a clustered server environment.
To get even better session control and fail over, I suggest delving deeper into distributed locks with Redis and reading about the redlock algorithm.

Thank you for taking the time to read through my article. If you enjoyed it, please hit the Clap button a few times 👏! If this article was helpful to you, feel free to share it!

Comments

Popular Posts