Skip to content

Commit

Permalink
Allow the application to configure Turbo::StreamChannel’s inheritance
Browse files Browse the repository at this point in the history
`ApplicationCable::Connection` allows the application to apply
authentication to all streams method, including those from
`Turbo::Broadcastable`.

By allowing `Turbo::StreamsChannel` to inherit from
`ApplicationCable::Channel` we open up a symmetrical path for
authorization.

In the spirit of being secure by default we should be moving towards
making `Turbo.base_stream_channel_class` default to
`"ApplicationCable::Channel"` but doing so without warning would break
applications relying on `ApplicationCable::Connection#authorized?` for
non-turbo broadcastable streams only.

Once we deem it safe we can remove the awkward initialiser and hard code
`ApplicationCable::Channel` as the super class. If people needs
different super classes for `Turbo::StreamsChannel` and custom channels
they can simply add another super class to inherit from in their
application.


To-dos:
- [ ] Generate the initializer on rails new
- [ ] Add a commented out method in the rails new / channel generator 
      to encourage authorization.
- [ ] Decide on a roll out plan for making inheriting from
      `ApplicationCable::Channel` the default.
- [ ] Add tests.
  • Loading branch information
ramhoj committed Oct 22, 2024
1 parent 52727cb commit 1b1f27e
Show file tree
Hide file tree
Showing 3 changed files with 87 additions and 25 deletions.
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,58 @@ This gem provides a `turbo_stream_from` helper to create a turbo stream.
<%# Rest of show here %>
```

### Authentication and Authorization

#### Authentication

Stream names generated with `turbo_stream_from` are cryptographically signed. While unguessable, they could still end up in the wrong hands. It's generally recommended to authenticate connections. You can do so in `ApplicationCable::Connection`:

```rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user

def connect
self.current_user = find_verified_user
end

private
def find_verified_user
if verified_session = Session.find_by(id: cookies.signed[:session_id])
verified_session.user
else
reject_unauthorized_connection
end
end
end
end
```

#### Authorization

In multi-tenancy apps it can be important to also authorize your subscriptions:

```rb
# config/initializers/turbo.rb
Rails.application.config.to_prepare do
Turbo.base_stream_channel_class = "ApplicationCable::Channel"
end
```

You can now implement your domain specific authorization logic in your `base_stream_channel_class`.

```rb
module ApplicationCable
class Channel < ActionCable::Channel::Base
private

def authorized?
current_user.can_access? streamable
end
end
end
```

### Testing Turbo Stream Broadcasts

Receiving server-generated Turbo Broadcasts requires a connected Web Socket.
Expand Down Expand Up @@ -182,7 +234,7 @@ import "@hotwired/turbo-rails"

You can watch [the video introduction to Hotwire](https://hotwired.dev/#screencast), which focuses extensively on demonstrating Turbo in a Rails demo. Then you should familiarize yourself with [Turbo handbook](https://turbo.hotwired.dev/handbook/introduction) to understand Drive, Frames, and Streams in-depth. Finally, dive into the code documentation by starting with [`Turbo::FramesHelper`](https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/frames_helper.rb), [`Turbo::StreamsHelper`](https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/streams_helper.rb), [`Turbo::Streams::TagBuilder`](https://github.com/hotwired/turbo-rails/blob/main/app/models/turbo/streams/tag_builder.rb), and [`Turbo::Broadcastable`](https://github.com/hotwired/turbo-rails/blob/main/app/models/concerns/turbo/broadcastable.rb).

Note that in development, the default Action Cable adapter is the single-process `async` adapter. This means that turbo updates are only broadcast within that same process. So you can't start `bin/rails console` and trigger Turbo broadcasts and expect them to show up in a browser connected to a server running in a separate `bin/dev` or `bin/rails server` process. Instead, you should use the web-console when needing to manaually trigger Turbo broadcasts inside the same process. Add "console" to any action or "<%= console %>" in any view to make the web console appear.
Note that in development, the default Action Cable adapter is the single-process `async` adapter. This means that turbo updates are only broadcast within that same process. So you can't start `bin/rails console` and trigger Turbo broadcasts and expect them to show up in a browser connected to a server running in a separate `bin/dev` or `bin/rails server` process. Instead, you should use the web-console when needing to manaually trigger Turbo broadcasts inside the same process. Add "console" to any action or "<%= console %>" in any view to make the web console appear.

### RubyDoc Documentation

Expand Down
57 changes: 33 additions & 24 deletions app/channels/turbo/streams_channel.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,50 @@
# using the view helper <tt>Turbo::StreamsHelper#turbo_stream_from(*streamables)</tt>.
# If the signed stream name cannot be verified, the subscription is rejected.
#
# In case if custom behavior is desired, one can create their own channel and re-use some of the primitives from
# helper modules like <tt>Turbo::Streams::StreamName</tt>:
# It's important to understand that while stream names are signed, <tt>Turbo::StreamsChannel</tt> doesn't authenticate connections or
# authorize subscriptions. You can configure <tt>Turbo::StreamChannel</tt> to use e.g your <tt>ApplicationCable::Channel</tt> to
# implement authorization:
#
# class CustomChannel < ActionCable::Channel::Base
# extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
# include Turbo::Streams::StreamName::ClassMethods
#
# def subscribed
# if (stream_name = verified_stream_name_from_params).present? &&
# subscription_allowed?
# stream_from stream_name
# else
# reject
# end
# end
#
# def subscription_allowed?
# # ...
# end
# # config/initializers/turbo.rb
# Rails.application.config.to_prepare do
# Turbo.base_stream_channel_class = "ApplicationCable::Channel"
# end
#
# This channel can be connected to a web page using <tt>:channel</tt> option in
# <tt>turbo_stream_from</tt> helper:
#
# <%= turbo_stream_from 'room', channel: CustomChannel %>
# You can also choose which channel to use via:
# <%= turbo_stream_from "room", channel: CustomChannel %>
#
class Turbo::StreamsChannel < ActionCable::Channel::Base
# Note that any channel that listens to a <tt>Turbo::Broadcastable</tt> compatible stream name
# (e.g <tt>verified_stream_name_from_params</tt>) can also be subscribed to via <tt>Turbo::StreamsChannel</tt>. Meaning that you should
# never use the <tt>turbo_stream_from</tt> <tt>:channel</tt> option to implement authorization.
class Turbo::StreamsChannel < Turbo.base_stream_channel_class.constantize
extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
include Turbo::Streams::StreamName::ClassMethods

def subscribed
if stream_name = verified_stream_name_from_params
if subscription_allowed?
stream_from stream_name
else
reject
end
end

private
def subscription_allowed?
stream_name && authorized?
end

# Override this method to match your authorization rules in <tt>Turbo.base_stream_channel_class</tt> e.g:
# <tt>current_user.can_access? streamable</tt>. <tt>current_user<tt> should match your
# <tt>ApplicationCable::Connection</tt> <tt>identified_by</tt>.
def authorized?
defined?(super) ? super : true
end

def streamable
@streamable ||= GlobalID::Locator.locate(stream_name)
end

def stream_name
@stream_name ||= verified_stream_name_from_params
end
end
1 change: 1 addition & 0 deletions lib/turbo-rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ module Turbo
extend ActiveSupport::Autoload

mattr_accessor :draw_routes, default: true
mattr_accessor :base_stream_channel_class, default: "ActionCable::Channel::Base"

thread_mattr_accessor :current_request_id

Expand Down

0 comments on commit 1b1f27e

Please sign in to comment.