stimulus_reflex icon indicating copy to clipboard operation
stimulus_reflex copied to clipboard

Reject Reflexes with the wrong connection identifier

Open leastbad opened this issue 4 years ago • 0 comments

Type of PR (feature, enhancement, bug fix, etc.)

Failed Experiment

Description

This PR should not be merged in its current form. My goal is to present the problem, what I attempted to do to fix it, and then what actually worked after a few days of struggling.

@RolandStuder and I have been talking about how to handle a wide variety of edge cases that occur when you have multiple tabs or devices open and using an authenticated app. The most obvious concern is the case where a user logs out of one tab but can still use another tab to execute Reflex actions with the original authenticated context. In fairness, this is a major issue with non-WS apps as well, but that's beyond the scope of this PR.

My first idea was to modify SR so that when the ActionCable Connection is first established, SR would send the connection.connection_identifier to the client. It would then be sent to the server as part of the standard payload so that channel.rb could compare it against the connection.connection_identifier. If you're faster than I was, you'll already spot the issue: if SR can receive the message, the connection identifier is going to match. Whoops! So this is a PR instead of an Issue because I suspect that this code could be helpful when development on v4 starts in earnest.


There was a number of things I looked into to address this, and the first was Warden's callbacks: https://github.com/wardencommunity/warden/wiki/Callbacks

Warden has a before_logout callback which could be very helpful if someone wanted to go through all of the active connections in ActionCable.server.remote_connections and nuke all connections for a given user on the server side.


Another thing that I discovered is that ActionCable's connection_identifier is not always in the same order (which is weird!). It appears to randomize the order of identifiers.

Also: the to_gid_param method is great:

User.first.to_gid_param
=> "Z2lkOi8vcHJvZ2VuaXRvci9Vc2VyLzE"

Solution

If you're ever using a script like this:

import CableReady from 'cable_ready'
import consumer from './consumer'

let reconnecting = false

consumer.subscriptions.create('SessionChannel', {
  received (data) {
    if (data.cableReady)
      CableReady.perform(data.operations, {
        emitMissingElementWarnings: false
      })
  },

  connected () {
    reconnecting = false
    document.addEventListener('reconnect', this.reconnect)
  },

  disconnected () {
    document.removeEventListener('reconnect', this.reconnect)
    if (reconnecting) consumer.connect()
  },

  reconnect () {
    reconnecting = true
    consumer.disconnect()
  }
})

so that you can force users to reconnect after they log in or log out like this:

class Users::SessionsController < Devise::SessionsController
  def create
    super do
      cable_ready[SessionChannel].dispatch_event(name: "reconnect").broadcast_to(request.session.id)
    end
  end
  def destroy
    super do
      cable_ready[SessionChannel].dispatch_event(name: "reconnect").broadcast_to(request.session.id)
    end
  end
end

... you're going to find that env["warden"].user gets out-of-sync with the user that just logged in. The solution is to force Warden to check the authentication status before assigning your connection identifiers:

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :session_id, :current_user

    def connect
      env["warden"].authenticated?
      self.current_user = env["warden"].user
      self.session_id = request.session.id
      reject_unauthorized_connection unless current_user || session_id
    end
  end
end

In the end, I found a solution that makes a lot of sense and gets implemented on the application side without any changes to SR:

class ApplicationReflex < StimulusReflex::Reflex
  class IdentityError < RuntimeError; end
  delegate :current_user, :session_id, to: :connection

  before_reflex do
    raise IdentityError.new("invalid user") if connection.env["warden"].user && invalid_user?
  end

  private

  def invalid_user?
    connection.connection_identifier.exclude?(connection.env["warden"].user.to_gid_param) || connection.connection_identifier.exclude?(request.session.id)
  end
end

It should work just fine in SR 3.5! So I'll figure out the best way to convey these ideas in the docs.

Checklist

  • [x] My code follows the style guidelines of this project
  • [x] Checks (StandardRB & Prettier-Standard) are passing
  • [x] This is not a documentation update

leastbad avatar Jun 06 '21 12:06 leastbad