Skip to content

Commit

Permalink
Ease authorization with configurable Turbo::StreamChannel superclass
Browse files Browse the repository at this point in the history
`ApplicationCable::Connection` provides a simple and intuitive way to
authenticate both custom ActionCable Channels and the
`Turbo::Broadcastable` broadcasts made on `Turbo::StreamsChannel`.

In multi-tenancy applications, authenticating the user is often not
enough as an evicted user could subscribe while being authenticated via
another tenant, given that they kept note of a signed stream name.

We'll allow applications to configure `Turbo::StreamsChannel`'s
superclass with the intention of implementing application-specific
authorization logic, e.g in `ApplicationCable::Channel`. This API is
symmetrical with how authentication can be implemented in
`ApplicationCable::Connection`.

Before this, `Turbo::StreamsChannel` needs to be monkey patched to
implement authorization. With this change, applications can opt-in to an
application-owned `Turbo::StreamsChannel` superclass with:

```rb
config.turbo.base_stream_channel_class = "ApplicationCable::Channel"
```

…and implement authorization with `current_user` from
`ApplicationCable::Connection` and `locate_streamable(s)` from a new
`Turbo::Streams::LocatableName` convenience concern:

```rb
module ApplicationCable
  class Channel < ActionCable::Channel::Base
    def authorized?
      current_user.can_access? locate_streamable
    end
  end
end
```

By default, the superclass is unchanged and `authorized?` returns
`true`, thus causing no compatibility issues when upgrading
`turbo-rails` in existing applications.
  • Loading branch information
ramhoj committed Oct 31, 2024
1 parent 59e1f48 commit 7baed3f
Show file tree
Hide file tree
Showing 7 changed files with 188 additions and 31 deletions.
66 changes: 65 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,70 @@ This gem provides a `turbo_stream_from` helper to create a turbo stream.
<%# Rest of show here %>
```

### Security

#### Signed Stream Names

Turbo stream names are cryptographically signed, which ensures that they cannot be guessed or altered.

Stream names do not expire and are rendered into the HTML. If you're broadcasting private data, additional security measures are recommended.

#### Authentication

It is recommended to authenticate connections in `ApplicationCable::Connection`. Without authentication, a leaked stream name could be used to subscribe without a valid application session.

```rb
# app/channels/application_cable/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-tenant applications, it’s often crucial to authorize subscriptions. Without authorization, someone with prior access could continue to subscribe as another tenant.

```rb
# config/application.rb

config.turbo.base_stream_channel_class = "ApplicationCable::Channel"
```

This allows you to define domain-specific authorization logic that `Turbo::StreamsChannel` and any other channels inheriting from `ApplicationCable::Channel` will use." By default `Turbo::StreamsChannel` inherits from `ActionCable::Channel::Base`.

```rb
# app/channels/application_cable/channel.rb

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

def authorized?
# `current_user` - from `ApplicationCable::Connection.identified_by`
# `locate_streamable` - from `Turbo::StreamsChannel` or by including `Turbo::Streams::LocatableName`
# `can_access?` - is yours to implement according to your domain specific needs.
current_user.can_access? locate_streamable
end
end
end
```

### Testing Turbo Stream Broadcasts

Receiving server-generated Turbo Broadcasts requires a connected Web Socket.
Expand Down Expand Up @@ -182,7 +246,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
17 changes: 17 additions & 0 deletions app/channels/turbo/streams/locatable_name.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# When streaming from a model instance using <tt>turbo_stream_from @post</tt>, it can be useful to locate the instance
# in <tt>config.turbo.base_stream_channel_class</tt>. These helper methods are available as a convenience for applications
# to implement custom logic such as authorization.
module Turbo::Streams::LocatableName
# Locate a single streamable. Useful when subscribing with <tt>turbo_stream_from @post</tt>. It can be used e.g to
# implement application-specific authorization, ex: <tt>current_user.can_access? locate_streamable</tt>
def locate_streamable
@locate_streamable ||= GlobalID::Locator.locate(verified_stream_name_from_params)
end

# Locate multiple streamables. Useful when subscribing with <tt>turbo_stream_from @post1, @post2</tt>. It can be
# used e.g to implement application-specific authorization, ex:
# <tt>locate_streamables.present? && locate_streamables.all? { |streamable| current_user.can_access?(streamable) }</tt>
def locate_streamables
@locate_streamables ||= GlobalID::Locator.locate_many(verified_stream_name_parts_from_params)
end
end
16 changes: 14 additions & 2 deletions app/channels/turbo/streams/stream_name.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
# <tt>Turbo::StreamsChannel</tt>, but each with their own subscription. Since stream names are exposed directly to the user
# via the HTML stream subscription tags, we need to ensure that the name isn't tampered with, so the names are signed
# upon generation and verified upon receipt. All verification happens through the <tt>Turbo.signed_stream_verifier</tt>.
#
# Signed stream names do not expire. To prevent unauthorized access through leaked stream names it is recommended to
# authorize subscriptions and/or authenticate connections based on your needs.
module Turbo::Streams::StreamName
STREAMABLE_SEPARATOR = ":"

# Used by <tt>Turbo::StreamsChannel</tt> to verify a signed stream name.
def verified_stream_name(signed_stream_name)
Turbo.signed_stream_verifier.verified signed_stream_name
Expand All @@ -14,16 +19,23 @@ def signed_stream_name(streamables)
end

module ClassMethods
# Can be used by custom turbo stream channels to obtain signed stream name from <tt>params</tt>
# Can be used by <tt>config.turbo.base_stream_channel_class</tt> or a custom channel to obtain signed stream name
# from <tt>params</tt>.
def verified_stream_name_from_params
self.class.verified_stream_name(params[:signed_stream_name])
end

# Can be used by <tt>config.turbo.base_stream_channel_class</tt> or a custom channel to obtain signed stream name
# parts from <tt>params</tt>.
def verified_stream_name_parts_from_params
verified_stream_name_from_params.split STREAMABLE_SEPARATOR
end
end

private
def stream_name_from(streamables)
if streamables.is_a?(Array)
streamables.map { |streamable| stream_name_from(streamable) }.join(":")
streamables.map { |streamable| stream_name_from(streamable) }.join(STREAMABLE_SEPARATOR)
else
streamables.then { |streamable| streamable.try(:to_gid_param) || streamable.to_param }
end
Expand Down
45 changes: 17 additions & 28 deletions app/channels/turbo/streams_channel.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,30 @@
# 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>:
# Subscribe to custom channels by passing the <tt>:channel</tt> option to <tt>turbo_stream_from</tt>:
# <%= turbo_stream_from "room", channel: CustomChannel %>
#
# 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
# 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 %>
#
class Turbo::StreamsChannel < ActionCable::Channel::Base
# 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>. Never use the <tt>turbo_stream_from</tt> <tt>:channel</tt> option
# to implement authorization. Authorizing subscriptions is often recommended, see the README for details.
class Turbo::StreamsChannel < Turbo.base_stream_channel_class.constantize
extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
include Turbo::Streams::StreamName::ClassMethods
include Turbo::Streams::LocatableName, Turbo::Streams::StreamName::ClassMethods

def subscribed
if stream_name = verified_stream_name_from_params
if (stream_name = verified_stream_name_from_params) && authorized?
stream_from stream_name
else
reject
end
end

private
# Override this method to define custom authorization rules in <tt>config.turbo.base_stream_channel_class</tt>.
# Refer to <tt>Turbo::Streams::LocatableName</tt> for details on locating streamables.
#
# By default, no authorization is performed.
def authorized?
defined?(super) ? super : true
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
6 changes: 6 additions & 0 deletions lib/turbo/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ class Engine < Rails::Engine
end
end

initializer "turbo.configure" do |app|
if base_class = app.config.turbo&.base_stream_channel_class
Turbo.base_stream_channel_class = base_class
end
end

initializer "turbo.helpers", before: :load_config_initializers do
ActiveSupport.on_load(:action_controller_base) do
include Turbo::Streams::TurboStreamsTagBuilder, Turbo::Frames::FrameRequest, Turbo::Native::Navigation
Expand Down
68 changes: 68 additions & 0 deletions test/streams/streams_channel_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -337,4 +337,72 @@ class Turbo::StreamsChannelTest < ActionCable::Channel::TestCase
end
end
end

test "locates single streamable" do
record = Message.create!
subscribe signed_stream_name: signed_stream_name(record)

assert_equal record, subscription.locate_streamable
end

test "raises if streamable can't be found" do
record = Message.create!
subscribe signed_stream_name: signed_stream_name(record)
record.destroy

assert_raises(ActiveRecord::RecordNotFound) { subscription.locate_streamable }
end

test "locates multiple streamables" do
record1, record2 = Message.create!, Message.create!
subscribe signed_stream_name: signed_stream_name([ record1, record2 ])

assert_equal [ record1, record2 ], subscription.locate_streamables
end

test "raises unless all streamables can be found" do
record1, record2 = Message.create!, Message.create!
subscribe signed_stream_name: signed_stream_name([ record1, record2 ])
record1.destroy

assert_raises(ActiveRecord::RecordNotFound) { subscription.locate_streamables }
end

test "confirms subscription when unauthenticated by default" do
subscribe signed_stream_name: Turbo.signed_stream_verifier.generate("stream")

assert subscription.confirmed?
assert_has_stream "stream"
end

test "confirms subscription when succeeding authorization" do
authorizing do |record|
Turbo::StreamsChannel.define_method(:authorized?) { locate_streamable == record }
subscribe signed_stream_name: signed_stream_name(record)

assert subscription.confirmed?
assert_has_stream record.to_gid_param
end
end

test "rejects subscription when failing authorization" do
authorizing do |record|
Turbo::StreamsChannel.define_method(:authorized?) { locate_streamable != record }
subscribe signed_stream_name: signed_stream_name(record)

assert subscription.rejected?
assert_no_streams
end
end

private
def authorizing
original_authorized = Turbo::StreamsChannel.instance_method(:authorized?)
Turbo::StreamsChannel.remove_method :authorized?

yield Message.create!
ensure
Turbo::StreamsChannel.remove_method :authorized?
Turbo::StreamsChannel.define_method :authorized?, original_authorized
end
end

0 comments on commit 7baed3f

Please sign in to comment.