Enforcing a Single Web Socket Connection per User with Node.js, Socket.IO, and Redis

83,465 reads

(“A black-and-white shot of a person working with a MacBook on their lap” by Sergey Zolkin on Unsplash)

reactions

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.

reactions

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.

reactions

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.

reactions

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

(A Socket.IO server in its simplest form)

reactions

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.

reactions

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.

reactions

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

(Sample front-end mark-up with inputs and buttons to connect and disconnect)

reactions

Also, we need to set up some very rudimentary logic to perform the connect/disconnect and hook up our status and token inputs.

reactions

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

(Our basic front-end logic… for now)

reactions

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.

reactions

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

reactions

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.

reactions

We will implement the authentication with

socketio-auth

reactions

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

by Facundo Olano , an Auth module for Socket.IO which allows us to prompt the client for a tokenthey connect. Should the user not provide it within a certain amount of time, we will close the connection from the server.

(Hooking up socketio-auth with a dummy user lookup)

reactions

We hook up

socketAuth

io

authenticate

postAuthenticate

disconnect

authenticate

authentication

socketio-auth

reactions

by passing it ourinstance and configurations options in the form of three events:, and. First, ourevent is triggered after a client connected and emits a subsequent authentication event with a user token payload. Should the client not send thisevent within a configurable amount of time,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

USER_NOT_FOUND

reactions

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

If all goes well, we invoke the callback and mark the socket as authenticated or return

UNAUTHORIZED

reactions

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

reactions

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

function as follows:

(Modified front-end code to emit the user authentication token upon connection)

reactions

We added two things: soc

ket.emit('authentication', { token })

socket.on('unauthorized')

reactions

to tell the server who we are and an event listenerto 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.

reactions

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

reactions

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.

reactions

Enter distributed locks with Redis.

reactions

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.

reactions

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

reactions

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); // dummy user verification ...

adapter provided by Socket.IO.

(We use the Socket.IO Redis adapter to enable pub/sub)

reactions

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.

reactions

Let’s create our Redis client as a separate module and promisify the methods so we can use

async

await

reactions

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;

(A sample Redis client module)

reactions

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.

reactions

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

NX

NX

null

reactions

method withand an expiration (more on the expiration later).will make sure that we only set the key if it does not already exist. If it does, the command returns. We can use this setup to determine if a session already exists and abort if it does.

We modify our

authenticate

reactions

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' }); } },

function as follows:

(Modified authenticate event handler with Redis lock)

reactions

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

EX 30

reactions

the key, it means that it did not previously exist. We also addedto 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

ping

reactions

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

event of our socket connection to interceptpackages. These are received every 25 seconds by default. If a package is not received by then, Socket.IO will terminate the connection.

(Hooking into the internal “packet” event of Socket.IO)

reactions

We’re using the

postAuthenticate

packet

socket.auth

ping

SET

XX

NX

XX

reactions

event to register ourevent handler. Our handler then checks if the socket is authenticated viaand if the packet is of type. To renew the lock, we will again use Redis’command, this time withinstead ofstates 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.

reactions

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

reactions

disconnect: async (socket) => { console .log( `Socket ${socket.id} disconnected.` ); if (socket.user) { await redis.delAsync( `users: ${socket.user.id} ` ); } },

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.

(Removing the session lock when a user disconnects)

reactions

In our

disconnect

DEL

reactions

event, we check whether or not the socket was authenticated and then remove the lock from Redis via thecommand. 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

reactions

Conclusion

on the latter. Exactly what we wanted. Time to sit back and relax. 😅

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.

reactions

To get even better session control and fail over, I suggest delving deeper into distributed locks with Redis and reading about the redlock algorithm.

reactions

Thank you for taking the time to read through my article. If this article was helpful to you, feel free to share it!

reactions

For more from me, be sure to follow me on Twitter, on Medium, or check out my website!

reactions

References

Tags