Socket.IO Authentication System With JWT

Socket.IO Authentication System With JWT

There are few resources to explore when it comes to authenticating socket.io connections. I recently finished a programming project that involved authenticating socket connections. During the project, I tried my best to search for resources that explained how to create an authentication system for socket.io connections, but to no avail. They were either not useful for my project's use case or were unclear. With some effort, I was able to build a secure authentication system for the server so that every connecting client will be authenticated before a connection is established.

In this article, I will be taking you through the step-by-step process of building a secure socket.io authentication system with JSON web tokens (JWT). The knowledge can also be transferred to other authentication libraries, like passport.js or other programming languages' authentication libraries, as I will be using NodeJs.

Let's dive in!

Overview

I will be explaining every detail of the example codes to let everyone follow along regardless of the tech stacks they are familiar with.

However, we will build the authentication system using a MongoDB User collection, an http server, an Express app, and a socket.io server instance. Additionally, I will provide a sample client-side code to demonstrate its use case.

The User collection is used to store and retrieve the users' credentials.

The http server is used to listen to HTTP and socket connection requests.

The express app is used to set up function handlers that handle the registration and log-in authentication endpoints. A response containing a JSON web token (JWT) is expected on successful authentication.

The socket.io server instance is responsible for managing socket connection events. A middleware function is utilized to validate the JWT sent by the client, ensuring that only authenticated users make socket connection requests.

The client-side code is used to make HTTP requests to the authentication endpoints and stores the JWT response, which is then used to make web socket connection requests to the socket.io server instance.

The Authentication System

In this section, we build the authentication system. From the database model to the socket.io server instance authentication middleware setup.

Database Model

Let's start by creating the User model schema.

const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
    username: String,
    email: String,
    password: String,
  });

const User = mongoose.model('User', userSchema);

module.exports = User;

We import the mongoose module, which provides the functionality to define and interact with MongoDB schemas and models.

A userSchema is created using the mongoose.Schema constructor, specifying the structure and data types of the user object. The schema includes fields for username, email, and password, each of type String.

The userSchema is then used to create a User model. This model allows for interaction with the 'User' collection in the connected MongoDB database.

Finally, the User model is exported to make it available for use in the authentication system.

Express Authentication Handlers

Here, we'll be creating the handler functions for the registration and log-in endpoints using Express.

First of all, let's import the necessary modules.

const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const User = require("models/user.model");

In the code above, the express, bcrypt, and jsonwebtoken module are all being assigned to their respective variables. Additionally, the User database model is imported to enable the storage and verification of user information.

Next, we create the Express app.

const app = express();
app.use(express.json());

// Register API endpoint
app.post('/auth/register', registerUser);

// Login API endpoint
app.post('/auth/login', loginUser);

We begin by invoking the express() function provided by the express module, which allows us to register endpoints along with their respective handler functions. Additionally, we attach the express.json() middleware to parse JSON request payloads. Two POST methods are subsequently added to the Express app, enabling it to handle client requests at the /auth/register and /auth/login endpoints. These requests are handled by the registerUser and loginUser handler functions, respectively.

Next, we proceed to develop the logic for the registerUser handler function.

// User Registration Handler Function
async function registerUser(req, res) {
  try {
    const { username, email, password } = req.body;

    // Check if the username or email already exists
    const existingUser = await User.findOne().or([{ username }, { email }]);
    if (existingUser) {
      return res.status(400).json({ message: 'Username or email already exists' });
    }

    // Hash the password
    const hashedPassword = await bcrypt.hash(password, 10);

    // Create a new user
    const newUser = new User({
      username,
      email,
      password: hashedPassword,
    });

    // Save the user to the database
    await newUser.save();
    res.status(201).json({ message: 'Registration successful' });
  } catch (error) {
    console.error('Registration error', error);
    res.status(500).json({ message: 'Registration error' });
  }
}

We begin by destructuring the username, email, and password from the req.body object, which contains the request body sent by the client. Next, we perform a check for the uniqueness of the email and username by searching for them in the database. If either of them is found, a 400 bad request response is returned. Otherwise, the function proceeds to hash the password using the bcrypt.hash() function provided by the bcrypt module. The hashed password, along with the destructured username and email properties, are then saved to the database for persistence. In the absence of errors, a 201 created response is returned. However, if any error occurs during the execution of the handler function, a 500 internal server error response is returned.

Next, we proceed to develop the logic for the loginUser handler function.

// ...

// User Login Handler Function
async function loginUser(req, res) {
  try {
    const { username, password } = req.body;

    // Check if the username exists
    const user = await User.findOne({ username });
    if (!user) {
      return res.status(400).json({ message: 'Invalid username or password' });
    }

    // Compare the password
    const isPasswordValid = await bcrypt.compare(password, user.password);
    if (!isPasswordValid) {
      return res.status(400).json({ message: 'Invalid username or password' });
    }

    // Generate a JWT
    const token = jwt.sign({ userId: user._id }, process.env.SECRET_KEY);

    res.json({ token, message: 'Login successful' });
  } catch (error) {
    console.error('Login error', error);
    res.status(500).json({ message: 'Login error' });
  }
}

// ...

We first verify if the destructured username exists in the database. If it does not exist, a 400 bad request response is returned. Also, if the destructured password does not match the user-stored password in the database, a 400 bad response is returned. However, if the passwords match, we proceed to generate a JWT token using the jsonwebtoken module. This token is generated by setting the userId key to the value of the _id property of the User, which is automatically assigned by MongoDB to each saved user information. Assuming no errors occur, a response containing the generated token is returned.

Next, we export the Express app.

// ...
module.exports = app;

The complete code for the authentication endpoints can be found below.

const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const User = require("models/user.model");

const app = express();
app.use(express.json());

// Register API endpoint
app.post('/auth/register', registerUser);

// Login API endpoint
app.post('/auth/login', loginUser);

// User Registration Handler Function
async function registerUser(req, res) {
  try {
    const { username, email, password } = req.body;

    // Check if the username or email already exists
    const existingUser = await User.findOne().or([{ username }, { email }]);
    if (existingUser) {
      return res.status(400).json({ message: 'Username or email already exists' });
    }

    // Hash the password
    const hashedPassword = await bcrypt.hash(password, 10);

    // Create a new user
    const newUser = new User({
      username,
      email,
      password: hashedPassword,
    });

    // Save the user to the database
    await newUser.save();
    res.json({ message: 'Registration successful' });
  } catch (error) {
    console.error('Registration error', error);
    res.status(500).json({ message: 'Registration error' });
  }
}

// User Login Handler Function
async function loginUser(req, res) {
  try {
    const { username, password } = req.body;

    // Check if the username exists
    const user = await User.findOne({ username });
    if (!user) {
      return res.status(400).json({ message: 'Invalid username or password' });
    }

    // Compare the password
    const isPasswordValid = await bcrypt.compare(password, user.password);
    if (!isPasswordValid) {
      return res.status(400).json({ message: 'Invalid username or password' });
    }

    // Generate a JWT
    const token = jwt.sign({ userId: user._id }, process.env.SECRET_KEY);

    res.json({ token, message: 'Login successful' });
  } catch (error) {
    console.error('Login error', error);
    res.status(500).json({ message: 'Login error' });
  }
}

module.exports = app;

Our Express app is now ready to be used.

Socket Server Setup

Now, let's set up the http and socket.io server instances for authentication.

const http = require('http');
const ioServer = require('socket.io');
const app = require('./app');

We begin by importing the http and socket.io modules, as well as our Express app.

Next, we proceed to create instances of the http and socket.io servers.

// Create server from express app
const server = http.createServer(app);

// Create the socket server instance
const io = ioServer(server);

We use the createServer() function provided by the http module to create an HTTP server instance. The Express app is then passed to the function to handle requests to the authentication endpoints. The http server is then used to create a socket.io server instance.

Next, we proceed to add the authentication middleware.

io.use(async (socket, next) => {
        try {
            const token = socket.handshake.auth.token;

            // Verify and decode the JWT
            const decoded = jwt.verify(token, process.env.SECRET_KEY);

            // Get the user information from the database
            const user = await User.findById(decoded.userId);
            if (!user) {
                throw new Error('User not found');
            }

            // Attach the user object to the socket
            socket.user = user;
            next();
        } catch (error) {
            console.error('Authentication error', error);
            next(new Error('Authentication error'));
        }
    });


    io.on('connection', (socket) => {
        // Handle Events after authentication
    }

In the above code, when a client connects to the server, the middleware function io.use(), provided by the socket.io library, is invoked. Inside the function, we first retrieve the JWT token from the socket.handshake.auth.token property sent by the client during the handshake (connection). It then verifies and decodes the token using a secret key stored in an environment variable.

If the token is valid, the middleware retrieves the user information from the database based on the user ID extracted from the token. If the user is found, the user object is attached to the socket for future reference.

If any errors occur during the authentication process, such as an invalid token or user not found, an error is thrown, and the middleware calls the next() function with an error argument.

After successful authentication, the io.on('connection') event handler is triggered, allowing for further event handling and communication with the authenticated user.

The authentication system is now complete. But to listen to connections, the code below can be added or customized to your liking.

server.listen(PORT, () => {
    console.log(` Server started running at ${PORT}`);
});

Where PORT is the preferred port to listen to the connections. Also, don't forget to connect to your MongoDB collection before starting the server.

The complete code for the server setup can be found below.

const http = require('http');
const ioServer = require('socket.io');
const app = require('./app');
// Create server from express app
const server = http.createServer(app);

// Create the socket server instance
const io = ioServer(server);

io.use(async (socket, next) => {
        try {
            const token = socket.handshake.auth.token;

            // Verify and decode the JWT
            const decoded = jwt.verify(token, process.env.SECRET_KEY);

            // Get the user information from the database
            const user = await User.findById(decoded.userId);
            if (!user) {
                throw new Error('User not found');
            }

            // Attach the user object to the socket
            socket.user = user;
            next();
        } catch (error) {
            console.error('Authentication error', error);
            next(new Error('Authentication error'));
        }
    });


    io.on('connection', (socket) => {
        // Handle Events after authentication
    }

    server.listen(PORT, () => {
        console.log(` Server started running at ${PORT}`);
    });

Client-Side Connection

To demonstrate how the client-side can be set up to use the authentication system, we'll be using Axios to make requests to the authentication endpoints and use the retrieved token to connect to the socket.io server instance.

Let's start by registering a user.

const axios = require('axios');
const io = require('socket.io-client');

const username = 'HayatsCodes';
const email = 'hayatscodes@gmail.com';
const password = 123456;
let token;


try {
    const response = await axios.post(`http://localhost:${PORT}/auth/register`, {
      username,
      email,
      password,
    });
    console.log(response.data.message); // Registration successful
  } catch (error) {
      console.error(error);
  }

Firstly, the axios and socket.io-client libraries are imported.

Then, we are registering a user by making a POST request to the /auth/register endpoint of the server. The server URL is constructed using the PORT variable, which should contain the port number.

The user's username, email, and password are provided as the request payload. We use the await keyword to make the request and store the response in the response variable.

If the registration is successful, the messsage from the response is logged to the console.

If an error occurs during the registration process, the catch block is executed, and the error is logged to the console using console.error.

Now, let's sign in the registered user.

try {
      const response = await axios.post(`http://localhost:${PORT}/auth/login`, {
        username,
        password,
      });
      token = response.data.token;
      console.log(response.data.message); // login successful
    } catch (error) {
        console.error(error.response.data); 
    }
};

We're signing in the registered user by making a POST request to the /auth/login endpoint of the server while including the username and password in the request payload.

If the login is successful, the token is extracted from the response.data.token property, and the message is logged to the console.

The token is then assigned to the token variable for use in the socket.io client connection.

If an error occurs during the registration process, the catch block is executed, and the error is logged to the console using console.error.

Now let's connect to the socket.io server.

const client = io(`http://localhost:${PORT}`, {
      auth: {
        token
      }
});
// handle events
client.on('connect', () => { console.log('connected!') });
// Additional event handling can follow

In the above code snippet, we're establishing a client-side connection to the socket.io server using the io function provided by the socket.io-client library.

The io function is called with the socket server URL and an object as an argument, which includes the auth property. This property specifies the authentication token that will be sent to the server during the handshake process. The value of the earlier saved token variable from the login request is provided as the token value.

Once the connection is established, the client.on('connect') event handler is set up to listen for the 'connect' event. When the client successfully connects to the server, the callback function is executed, logging 'connected!' to the console.

Additional event handling and communication with the server can be added within the appropriate event handlers.

Conclusion

Building a secure authentication system for socket.io connections can be a challenging task due to the limited resources available. However, by leveraging JSON web tokens (JWT), it is possible to create a robust authentication system. In this article, we have covered the step-by-step process of building such a system, including setting up the database model, creating authentication endpoints with Express, implementing authentication middleware with socket.io, and demonstrating client-side connections using axios and the socket.io-client library. By following the provided code examples and explanations, developers can build their own secure authentication system for socket.io connections, allowing only authenticated users to establish connections and interact with the server.