diff --git a/README.md b/README.md index beeae82e..c0d41be6 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,68 @@ 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 measurements 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 +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." + +```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. @@ -182,7 +244,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 diff --git a/app/channels/turbo/streams/locatable_name.rb b/app/channels/turbo/streams/locatable_name.rb new file mode 100644 index 00000000..1c438aef --- /dev/null +++ b/app/channels/turbo/streams/locatable_name.rb @@ -0,0 +1,17 @@ +# When streaming from a model instance using turbo_stream_from @post, it can be useful to locate the instance +# in config.turbo.base_stream_channel_class. 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 turbo_stream_from @post. It can be used e.g to + # implement application-specific authorization, ex: current_user.can_access? locate_streamable + def locate_streamable + @locate_streamable ||= GlobalID::Locator.locate(verified_stream_name_from_params) + end + + # Locate multiple streamables. Useful when subscribing with turbo_stream_from @post1, @post2. It can be + # used e.g to implement application-specific authorization, ex: + # locate_streamables.present? && locate_streamables.all? { |streamable| current_user.can_access?(streamable) } + def locate_streamables + @locate_streamables ||= GlobalID::Locator.locate_many(verified_stream_name_parts_from_params) + end +end diff --git a/app/channels/turbo/streams/stream_name.rb b/app/channels/turbo/streams/stream_name.rb index 37d80b18..3b3be957 100644 --- a/app/channels/turbo/streams/stream_name.rb +++ b/app/channels/turbo/streams/stream_name.rb @@ -2,7 +2,12 @@ # Turbo::StreamsChannel, 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 Turbo.signed_stream_verifier. +# +# 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 Turbo::StreamsChannel to verify a signed stream name. def verified_stream_name(signed_stream_name) Turbo.signed_stream_verifier.verified signed_stream_name @@ -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 params + # Can be used by config.turbo.base_stream_channel_class or a custom channel to obtain signed stream name + # from params. def verified_stream_name_from_params self.class.verified_stream_name(params[:signed_stream_name]) end + + # Can be used by config.turbo.base_stream_channel_class or a custom channel to obtain signed stream name + # parts from params. + 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 diff --git a/app/channels/turbo/streams_channel.rb b/app/channels/turbo/streams_channel.rb index adb614b4..a58b9a94 100644 --- a/app/channels/turbo/streams_channel.rb +++ b/app/channels/turbo/streams_channel.rb @@ -5,41 +5,30 @@ # using the view helper Turbo::StreamsHelper#turbo_stream_from(*streamables). # 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 Turbo::Streams::StreamName: +# Subscribe to custom channels by passing the :channel option to turbo_stream_from: +# <%= 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 :channel option in -# turbo_stream_from helper: -# -# <%= turbo_stream_from 'room', channel: CustomChannel %> -# -class Turbo::StreamsChannel < ActionCable::Channel::Base +# Any channel that listens to a Turbo::Broadcastable-compatible stream name (e.g., verified_stream_name_from_params) +# can also be subscribed to via Turbo::StreamsChannel. Never use the turbo_stream_from :channel 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 config.turbo.base_stream_channel_class. + # Refer to Turbo::Streams::LocatableName for details on locating streamables. + # + # By default, no authorization is performed. + def authorized? + defined?(super) ? super : true + end end diff --git a/lib/turbo-rails.rb b/lib/turbo-rails.rb index ee81e814..50beca2e 100644 --- a/lib/turbo-rails.rb +++ b/lib/turbo-rails.rb @@ -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 diff --git a/lib/turbo/engine.rb b/lib/turbo/engine.rb index ecfd82e0..19250fe0 100644 --- a/lib/turbo/engine.rb +++ b/lib/turbo/engine.rb @@ -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 diff --git a/test/streams/streams_channel_test.rb b/test/streams/streams_channel_test.rb index 63ae7af2..018ce163 100644 --- a/test/streams/streams_channel_test.rb +++ b/test/streams/streams_channel_test.rb @@ -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