Search code examples
phpsessionlaraveleloquentratchet

Laravel, WebSockets - Verify user on server


I currently ran into the problem of handling the authentification of a user on the server, using Laravel and RachetPHP.

What I tried so far:

  • I changed the driver type of the session to database, giving me an id and payload column. Using \Session::getId() returns a 40 character string. The cookie information, sent by the WebSocket-Connection does contain a XSRF-TOKEN and a laravel_session, both containing > 200 characters string. The database ID of the users session differs from the id, returned by \Session::getId().

  • I am already sending the current CSRF-token via the websocket message, but I have no clue how to verify it (the built-in verifier uses requests - which I don't have in the websocket server scope).

Generic Use case:

A User posts a comment in thread. The payload of the sent object would then be:

  • Something to verify the user (an ID or a token).
  • The comment itself

If you were to send the user ID, anyone could temper the packet and send the message under another ones user.

My use case:

A user can have n-characters. A character has an avatar, an id, a name, etc. The user is only used to:

  • authenticate at the server.
  • access his characters, and thus perform basic CRUD operations on his characters.

I also have a table locations - a "virtual place", a character can be in... so I got a one-to-one relationship between character and location. The user (character) can then send messages in a location via websocket. The message is inserted at the database on the server. At this point, I need to know:

  • If the user is authenticated (csrf-token ?)
  • If the user is the owner of the character (it's very simple to spoof the request with another user's character id)

If you need more information, please let me know.


Solution

  • So this is how I solved this a while ago. In my example, I'm working with Socket.IO, but I'm pretty sure you can easily rewrite the Socket.IO part to get it to work with RachetPHP as well.

    Socket Server

    The socket server depends on the files cookie.js and array.js, and the node modules express, http, socket.io, request and dotenv. I'm not the original author of cookie.js, but there is no author mentioned in the comments, so I'm not able to give any credits for this, sorry.

    This is the server.js file which starts the server. It is a simple socket server that tracks who is currently online. The interesting part however is when the server makes a POST request to socket/auth on the Laravel application:

    var express = require('express');
    var app = express();
    var server = require('http').Server(app)
    var io = require('socket.io')(server);
    var request = require('request');
    var co = require('./cookie.js');
    var array = require('./array.js');
    
    // This loads the Laravel .env file
    require('dotenv').config({path: '../.env'});
    
    server.listen(process.env.SOCKET_SERVER_PORT);
    
    var activeSockets = {};
    var disconnectTimeouts = {};
    
    // When a client connects
    io.on('connection', function(socket)
    {
        console.log('Client connected...');
    
        // Read the laravel_session cookie.
        var cookieManager = new co.cookie(socket.handshake.headers.cookie);
        var sess = cookieManager.get("laravel_session"); // Rename "laravel_session" to whatever you called it
    
        // This is where the socket asks the Laravel app to authenticate the user
        request.post('http://' + process.env.SOCKET_SERVER_HOST + '/socket/auth?s=' + sess, function(error, response, body)
        {
            try {
                // Parse the response from the server
                body = JSON.parse(body);
            }
            catch(e)
            {
                console.log('Error while parsing JSON', e);
                error = true;
            }
    
            if ( ! error && response.statusCode == 200 && body.authenticated)
            {
                // Assign users ID to the socket
                socket.userId = body.user.id;
    
                if ( ! array.contains(activeSockets, socket.userId))
                {
                    // The client is now 'active'
                    activeSockets.push(socket.userId);
    
                    var message = body.user.firstname + ' is now online!';
                    console.log(message);
    
                    // Tell everyone that the user has joined
                    socket.broadcast.emit('userJoined', socket.userId);
                }
                else if (array.hasKey(disconnectTimeouts, 'user_' + socket.userId))
                {
                    clearTimeout(disconnectTimeouts['user_' + socket.userId]);
                    delete disconnectTimeouts['user_id' + socket.userId];
                }
    
                socket.on('disconnect', function()
                {
                    // The client is 'inactive' if he doesn't reastablish the connection within 10 seconds
                    // For a 'who is online' list, this timeout ensures that the client does not disappear and reappear on each page reload
                    disconnectTimeouts['user_' + socket.userId] = setTimeout(function()
                    {
                        delete disconnectTimeouts['user_' + socket.userId];
                        array.remove(activeSockets, socket.userId);
    
                        var message = body.user.firstname + ' is now offline.';
                        console.log(message);
    
                        socket.broadcast.emit('userLeft', socket.userId);
                    }, 10000);
                });
            }
        });
    });
    

    I added some comments to the code, so it should be pretty self-explanatory. Please note that I added SOCKET_SERVER_HOST and SOCKET_SERVER_PORT to my Laravel .env-file in order to be able to change the host and port without editing the code and run the server on different environments.

    SOCKET_SERVER_HOST = localhost
    SOCKET_SERVER_PORT = 1337
    

    Authenticating a user by a session cookie with Laravel

    This is the SocketController which parses the cookie and responds whether the user could be authenticated or not (JSON response). Its the same mechanism that you described in your answer. It's not the best design to handle the cookie parsing in the controller, but it should be OK in this case, because the controller only handles that one thing and its functionality isn't used at another point in the application.

    /app/Http/Controllers/SocketController.php

    <?php namespace App\Http\Controllers;
    
    use App\Http\Requests;
    
    use App\Users\UserRepositoryInterface;
    use Illuminate\Auth\Guard;
    use Illuminate\Database\DatabaseManager;
    use Illuminate\Encryption\Encrypter;
    use Illuminate\Http\Request;
    use Illuminate\Routing\ResponseFactory;
    
    /**
     * Class SocketController
     * @package App\Http\Controllers
     */
    class SocketController extends Controller {
    
        /**
         * @var Encrypter
         */
        private $encrypter;
    
        /**
         * @var DatabaseManager
         */
        private $database;
    
        /**
         * @var UserRepositoryInterface
         */
        private $users;
    
        /**
         * Initialize a new SocketController instance.
         *
         * @param Encrypter $encrypter
         * @param DatabaseManager $database
         * @param UserRepositoryInterface $users
         */
        public function __construct(Encrypter $encrypter, DatabaseManager $database, UserRepositoryInterface $users)
        {
            parent::__construct();
    
            $this->middleware('internal');
    
            $this->encrypter = $encrypter;
            $this->database = $database;
            $this->users = $users;
        }
    
        /**
         * Authorize a user from node.js socket server.
         *
         * @param Request $request
         * @param ResponseFactory $response
         * @param Guard $auth
         * @return \Symfony\Component\HttpFoundation\Response
         */
        public function authenticate(Request $request, ResponseFactory $response, Guard $auth)
        {
            try
            {
                $payload = $this->getPayload($request->get('s'));
            } catch (\Exception $e)
            {
                return $response->json([
                    'authenticated' => false,
                    'message'       => $e->getMessage()
                ]);
            }
    
            $user = $this->users->find($payload->{$auth->getName()});
    
            return $response->json([
                'authenticated' => true,
                'user'          => $user->toArray()
            ]);
        }
    
        /**
         * Get session payload from encrypted laravel session.
         *
         * @param $session
         * @return object
         * @throws \Exception
         */
        private function getPayload($session)
        {
            $sessionId = $this->encrypter->decrypt($session);
            $sessionEntry = $this->getSession($sessionId);
    
            $payload = base64_decode($sessionEntry->payload);
    
            return (object) unserialize($payload);
        }
    
        /**
         * Fetches base64 encoded session string from the database.
         *
         * @param $sessionId
         * @return mixed
         * @throws \Exception
         */
        private function getSession($sessionId)
        {
            $sessionEntry = $this->database->connection()
                ->table('sessions')->select('*')->whereId($sessionId)->first();
    
            if (is_null($sessionEntry))
            {
                throw new \Exception('The session could not be found. [Session ID: ' . $sessionId . ']');
            }
    
            return $sessionEntry;
        }
    
    }
    

    In the constructor you can see that I refer to the internal middleware. I added this middleware to only allow the socket server to make requests to socket/auth.

    This is what the middleware looks like:

    /app/Http/Middleware/InternalMiddleware.php

    <?php namespace App\Http\Middleware;
    
    use Closure;
    use Illuminate\Routing\ResponseFactory;
    
    class InternalMiddleware {
    
        /**
         * @var ResponseFactory
         */
        private $response;
    
        /**
         * @param ResponseFactory $response
         */
        public function __construct(ResponseFactory $response)
        {
            $this->response = $response;
        }
    
        /**
         * Handle an incoming request.
         *
         * @param  \Illuminate\Http\Request  $request
         * @param  \Closure  $next
         * @return mixed
         */
        public function handle($request, Closure $next)
        {
            if (preg_match(env('INTERNAL_MIDDLEWARE_IP'), $request->ip()))
            {
                return $next($request);
            }
    
            return $this->response->make('Unauthorized', 401);
        }
    
    }
    

    To get this middleware to work, register it in the Kernel and add the INTERNAL_MIDDLEWARE_IP property - that is just a regular expression defining which IP addresses are allowed - to your .env-file:

    Local testing (any IP):

    INTERNAL_MIDDLEWARE_IP = /^.*$/
    

    Production env:

    INTERNAL_MIDDLEWARE_IP = /^192\.168\.0\.1$/
    

    I'm sorry I could not help you out with RachetPHP, but I think you get a good idea how this can be solved.