diff --git a/README.md b/README.md index beeae82e..dc4c15b7 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/app/channels/turbo/streams_channel.rb b/app/channels/turbo/streams_channel.rb index adb614b4..5c624955 100644 --- a/app/channels/turbo/streams_channel.rb +++ b/app/channels/turbo/streams_channel.rb @@ -5,41 +5,50 @@ # 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: +# It's important to understand that while stream names are signed, Turbo::StreamsChannel doesn't authenticate connections or +# authorize subscriptions. You can configure Turbo::StreamChannel to use e.g your ApplicationCable::Channel 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 :channel option in -# turbo_stream_from 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 Turbo::Broadcastable compatible stream name +# (e.g verified_stream_name_from_params) can also be subscribed to via Turbo::StreamsChannel. Meaning that you should +# never use the turbo_stream_from :channel 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 Turbo.base_stream_channel_class e.g: + # current_user.can_access? streamable. current_user should match your + # ApplicationCable::Connection identified_by. + 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 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