0

I would like to use websockets to track the time when a user was last seen, along with their current online status (if they are currently authenticated and making use of the application).

I have the a policy that applies to certain parts of the application, so the user will sign in then be directed to an overview where the policy applies.

If the user is not authenticated or if they are but the session information conflicts with that of the database then the overview will respond with notFound.

If these simple checks pass, then the user exists so two event handlers should be created:

  1. If there is no activity until the session expires, notify the client to return to the auth page
  2. When the client is disconnecting, update the time when they were last seen and alter the online state

Here is the policy code:

const schedule = require('node-schedule')
var sheduledJob

module.exports = async function isSignedIn(req, res, next) {

  // If the user has not signed in, pretend the page does not exist
  if (!req.session.app || !req.session.app.userId) return res.notFound()

  // Store the users id
  let userId = req.session.app.userId

  try {
    // Update the user
    var users = await User.update({
      id: userId
    })
    .set({
      last_accessed: new Date().getTime(),
      online: true
    })
    .meta({
      fetch:true
    })
  } catch (err) {
    // Handle errors
    return res.serverError(err.message)
  }

  // If the user does not exist
  if (!users || users.length !== 1) {
    console.log('policies/isSignedIn: no matching user '+userId)
    // Sign the user out
    delete(req.session.app)
    // Pretend the page does not exist
    return res.notFound()
  }

  // When the user exists
  else {
    // When there is a new client connection
    var io = sails.io
    io.on('connection', socket => {
      console.log("==> Socket Connection Established")
      console.log(socket.id, socket.request.headers.cookie.replace('sails.sid=',''))

      // Cancel an existing job if one exists
      if (sheduledJob) sheduledJob.cancel()
      // Shedule a job to notify the client when the session has expired
      var d = req.session.cookie._expires
      sheduledJob = schedule.scheduleJob(d, () => {
        console.log('The cookie has expired')
        // The client should return to the auth page (will fire disconnecting when doing so)
        req.socket.emit('logout', true)
      })

      // When the client is disconnecting
      socket.on('disconnecting', async reason => {
        console.log('Client disconnecting')
        // As of Sails v1.0.0-46 reason is undefined when the application is lowering
        if (reason) {
          try {
            // Update the user
            var users = await User.update({
              id: userId
            })
            .set({
              last_accessed: new Date().getTime(),
              online: false
            })
          } catch (err) {
            // Handle errors
            return res.serverError(err.message)
          }
        }
      })
    })

    // Proceed as the user exists and we can handle the disconnecting event
    return next()
  }

};

The problem that I have is that this will work when the page loads initially but if I reload the overview page then I end up with duplicate event handlers:

Loading the page once (bearing in mind that the sessions age is 10 seconds for testing):

==> Socket Connection Established
<socketId> <sessionId>
The cookie has expired
Client disconnecting

But if I reload the page:

==> Socket Connection Established
<socketId> <sessionId>
Client disconnecting
==> Socket Connection Established
<socketId> <sessionId>
==> Socket Connection Established
<socketId> <sessionId>
The cookie has expired
Client disconnecting
Client disconnecting

So I thought okay fine if that is the case then maybe I can just create a named function for the event listener and then remove the event listener initially:

const schedule = require('node-schedule')
var sheduledJob
function connectionHandler(req, res, socket) {
  console.log("==> Socket Connection Established")
  console.log(socket.id, socket.request.headers.cookie.replace('sails.sid=',''))
  ...same as before...
}

module.exports = async function isSignedIn(req, res, next) {

 ...

 // When the user exists
 else {
   // When there is a new client connection
   var io = sails.io
   var nsp = io.of('/')
   nsp.removeListener('connection', connectionHandler)
   io.on('connection', socket => connectionHandler(req, res, socket))

   // Proceed as the user exists and we can handle the disconnecting event
   return next()
 }

};

But that results in the same duplicate handlers, so I removed all of the req/res code from the handler:

function connectionHandler(socket) {
  console.log("==> Socket Connection Established")
  console.log(socket.id, socket.request.headers.cookie.replace('sails.sid=',''))
  socket.on('disconnecting', async reason => {
    console.log('Client disconnecting')
  })
}

And amended when the event is created:

io.on('connection', connectionHandler)

The result works as I had intended, without any duplicate event handlers being created when reloading the page:

==> Socket Connection Established
<socketId> <sessionId>
Client disconnecting
==> Socket Connection Established
<socketId> <sessionId>

Could someone please explain to me where I am going wrong here, I really do not understand why:

io.on('connection', socket => connectionHandler(req, res, socket))

Results in duplicated event handlers whereas:

io.on('connection', connectionHandler)

Does not?

If anyone could offer any suggestion as to where I am going wrong here or how I could better reach the desired result then that would be greatly appreciated, many thanks in advance!

Here are some of the references that I used to get to this point:

  1. https://gist.github.com/mikermcneil/6598661
  2. https://github.com/balderdashy/sails-docs/blob/1.0/concepts/Sessions/sessions.md#when-does-the-sailssid-change
  3. how to disconnect socket on session expire
  4. https://stackoverflow.com/a/5422730/2110294
  5. https://github.com/expressjs/session/issues/204#issuecomment-141473499
  6. https://stackoverflow.com/a/33308406/2110294
Craig van Tonder
  • 7,497
  • 18
  • 64
  • 109
  • I feel like you shouldn't be adding global event listeners in a policy... the ideal would be to start a global event listener once at lift time, then in the callback somehow figure out the `userId` of the connecting or disconnection user. – arbuthnott Feb 27 '18 at 18:54
  • I'm not posting an answer though, because I'm not sure if that's even possible. Would want something like `io.on('connect', function(socket) { var userId = socket.userId;`... to run in `config/bootstrap.js` or something. – arbuthnott Feb 27 '18 at 18:57
  • @arbuthnott Problem with that would be that I cannot get the request object (and the users information that's stored in the session) within bootstrap.js. There was some minor issues with my logic that I have since resolved in the answer that I just posted, any reason why this would not be a sane thing to do? :) – Craig van Tonder Feb 27 '18 at 21:18
  • I think there is a problem when multiple different users are involved - I'll comment on the answer below. – arbuthnott Feb 28 '18 at 12:06

1 Answers1

0

I made a few changes which seemed to have resolved things:

const schedule = require('node-schedule')
var sheduledJob;

module.exports = async function isSignedIn(req, res, next) {

  // If the user has not signed in, pretend the page does not exist
  if (!req.session.app || !req.session.app.userId) return res.notFound()

  // Store the users id and ip
  let userId = req.session.app.userId
  let userIp = req.headers['x-real-ip']

  try {
    // Update the user
    var users = await User.update({
     id: userId
    })
    .set({
     last_accessed: new Date().getTime(),
     last_accessed_ip: userIp,
     online: true
    })
    .meta({
     fetch:true
    })
  } catch (err) {
    // Handle errors
    return res.serverError(err.message)
  }

  // If the user does not exist
  if (users.length !== 1) {
    console.log('policies/isSignedIn: no matching user '+userId)
    // Sign the user out
    delete(req.session.app)
    // Pretend the page does not exist
    return res.notFound()
  }

  // When the user exists
  else {
    // When there is a new client connection
    var io = sails.io

    io.on('connection', function connectionHandler(socket) {
      // console.log("==> Socket Connection Established")
      // console.log(socket.id, socket.request.headers.cookie.replace('sails.sid=',''))

      // Cancel the existing job if one exists
      if (sheduledJob) sheduledJob.cancel()
      // Shedule a job to notify the client when the session has expired
      var d = req.session.cookie._expires
      sheduledJob = schedule.scheduleJob(d, () => {
        //console.log('The cookie has expired')
        // Sign the user out
        delete req.session.app
        // The client should return to the auth page
        socket.emit('session-expired', 'Your session has expired!')
      })

      // When the client is disconnecting
      socket.on('disconnecting', async function disconnectingHandler(reason) {
        //console.log('Client disconnecting: '+reason)
        // Cancel the existing job if one exists
        if (sheduledJob) sheduledJob.cancel()
        // Remove any event handlers that were created
        io.sockets.removeAllListeners('connection')
        io.sockets.removeAllListeners('disconnecting')
        // As of Sails v1.0.0-46 reason is undefined when the application is lowering
        if (reason) {
          try {
            // Update the user
            var users = await User.update({
              id: userId
            })
            .set({
              last_accessed: new Date().getTime(),
              last_accessed_ip: userIp,
              online: false
            })
          } catch (err) {
            // Handle errors
            return res.serverError(err.message)
          }
        }
      })
    })

    // Proceed as the user exists and we can handle the disconnecting event
    return next()
  }

};

The main problem here I think was that I was not removing the event listeners correctly, this needed to happen when the disconnecting event was fired.

As per https://github.com/expressjs/session#rolling, adding rolling: true to config.sessions was required for the sessions for refresh on each request, the expiry date is determined by config.session.cookie.maxAge.

Craig van Tonder
  • 7,497
  • 18
  • 64
  • 109
  • This solution adds a new `.on` event handler every time a logged in user passes this policy. the `.on('connection')` would fire for any user, but the event handler added uses the current user's id. I think if one user goes to this policy, then another, the first event handler will fire, making updates inappropriately for the first user. Your `scheduledJob` will also be overwritten, and only work for the most recent user, being lost for previous users. – arbuthnott Feb 28 '18 at 12:14
  • @arbuthnott Too true and i'm sort of stuck on that part because I'm unsure of how I could create and remove a connection handler per socket in such a way. I thought of moving the scheduled jobs to an array which works in theory but it's not scalable past a single server. I am going to give this some more thought when I have the time to play around with ideas, thanks for your input! – Craig van Tonder Mar 11 '18 at 23:45