Sign In

Signing in and out with the Rails 8 auth generator pattern, plus a little extra flavor: your other tabs notice a sign-out, and you can see and end the sessions you have open.

The gatehouse

No account? Make one, or use the shared one: wanderer with the password cabbage patch by the old mill.

# frozen_string_literal: true

# The Sign In recipe's live half: who this browser is signed in as, and the
# sessions of that account. Signing in and out are plain requests
# (Recipes::Auth::SessionsController), because they set and clear a cookie.
#
# The connection is identified by its session when the socket opens, and a
# deleted session disconnects every socket it opened. So when this browser
# is signed out from another tab or another device, this tab's socket
# reconnects as a guest, the graph is built again, and the panel says so.
class Recipes::Signin::SigninChannel < ApplicationCable::Channel
  include Hibiki::Rails::Channel

  Session = Recipes::Auth::Session
  VIEWS = "app/recipes/signin"

  def build_graph
    @sessions = Hibiki::State.new(0)
    account = demo_session&.account

    # The page was rendered for this name; the socket knows who it is now.
    shown = params[:as].to_s.first(20).presence
    rows = Hibiki::Derived.new do
      @sessions.value
      visible_rows(account)
    end

    Hibiki::Effect.new do
      broadcast_morph target: "signin_status", partial: "#{VIEWS}/status",
                      locals: { shown:, account:, current: demo_session&.id, rows: rows.value }
    end
  end

  # Signs out one of this account's other browsers.
  def end_session(data)
    others&.find_by(id: data["id"])&.destroy
  end

  # Signs out every other browser of this account.
  def end_others
    others&.destroy_all
  end

  private

  def subscribed
    super
    return if subscription_rejected? || demo_session.nil?

    # A sign-in or sign-out of this account, from anywhere. The stream is
    # named from the connection's own session, never from a param.
    stream_from Session.stream(demo_session.account_id) do
      graph_actor&.post { hear }
    end
  end

  def hear = @sessions.value += 1

  # The shared account is everyone's, so its other sessions are not this
  # visitor's to see or end.
  def visible_rows(account)
    return [] unless account
    return [ demo_session.row ] if account.shared?

    Session.rows(account.id)
  end

  def others
    account = demo_session&.account
    account.sessions.where.not(id: demo_session.id) if account && !account.shared?
  end
end

The form above signs you in and out the way the Rails 8 authentication generator does, with a little twist. Try signing in as wanderer. Once you are in, you can see and end the sessions you have open as a list. If you open another tab with this page and sign out from there, your other tabs will notice a sign-out within a few seconds.

How it fits together

Signing in and out are plain requests to SessionsController. They set and clear a cookie, which only an HTTP response can do. A session is a row in the database, and the cookie holds the row's id, signed. The row keeps a label such as "Firefox on macOS".

The status under the name lives in a channel: the server side of an Action Cable connection, an object that stays alive while the page is open and answers messages from it. A state holds a value and remembers who read it. Here @sessions is a state that goes up by one each time a session of this account is made or deleted, in any browser. A derived is a value worked out from other values, and worked out again when one of them changes. Here rows reads @sessions, then asks the database for the account's sessions. An effect is a block of code that runs once, and runs again whenever a value it read has changed. The channel's one effect reads rows, renders the status partial and sends it with broadcast_morph, which updates the status in place. When you sign in from a second browser:

  1. The controller makes a session row, and the row pings the account's stream.
  2. The channel in the first browser hears it and adds one to @sessions.
  3. rows read @sessions, so it asks the database again and finds the new row.
  4. The effect read rows, so it renders the status again and sends it. The list gains a line, with a Sign out button.

That button sends end_session with the row's id. The channel's public methods are its actions, the methods the page may call. This one deletes the row. The row pings the stream on its way out, and the list loses the line. The sign-in form and the Sign out button come with the page; the channel draws only the status.

How the other tab finds out

Action Cable decides who a connection is once, when the socket opens. Here that is the session read from the cookie, or nothing for a guest. When a session row is deleted, the model disconnects every socket that session opened. Each reconnects a few seconds later, reads the cookie again, and finds no session. The channel builds its graph again as a guest. The status compares the name the page was rendered for with the one the socket has now. They differ, so the status says the browser was signed out. The Sign out button is hidden with it.

# app/channels/application_cable/connection.rb
identified_by :demo_session           # decided once, when the socket opens

def connect
  self.demo_session = Recipes::Auth::Session.from(cookies)   # nil for a guest, never refused
end

# app/models/recipes/auth/session.rb
after_destroy_commit { ActionCable.server.remote_connections.where(demo_session: self).disconnect }

# app/channels/recipes/signin/signin_channel.rb
shown = params[:as]                   # who the page was rendered for
account = demo_session&.account       # who the socket is now

What signing in must not reveal

A sign-in form is the easiest place to ask "does this account exist?", so this one answers every failure the same way. The message is "Try another username or password", whether the name has no account or the password was wrong. It takes the same time either way, since authenticate_by runs bcrypt even when the name is not found. Nothing is checked as you type. The button waits for both fields with CSS alone, so the password never goes over the socket. rate_limit lets one address try ten times in three minutes.

# app/controllers/recipes/auth/sessions_controller.rb
rate_limit to: 10, within: 3.minutes, only: :create

def create
  if (account = Recipes::Auth::Account.authenticate_by(params.permit(:username, :password)))
    sign_in account
    redirect_to app_recipe_path("signin"), status: :see_other, notice: "Signed in as #{account.username}."
  else
    # One message, whether the name is wrong or the password is.
    redirect_to app_recipe_path("signin"), status: :see_other, alert: "Try another username or password."
  end
end

# app/controllers/concerns/recipes/auth/signing.rb
def sign_in(account)
  demo_session&.destroy                                             # the old cookie is now worth nothing
  @demo_session = Session.start(account, user_agent: request.user_agent)  # keeps only "Firefox on macOS"
  cookies.signed.permanent[Session::COOKIE] = { value: @demo_session.id, httponly: true, same_site: :lax }
end

The shared account

wanderer's password is printed on this page, so everyone who uses it shares one account. Its list shows only your own browser, and there is no button to sign the others out. Otherwise one visitor could keep signing everyone else out. Accounts made on Sign Up are cleared every night.

Signing in and out stay plain Rails. One state in the channel is enough for the status to follow every sign-in and sign-out of the account. The Ranks and Permissions recipe uses these accounts to decide what each visitor may do.