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
# frozen_string_literal: true
# Where the Sign In recipe's forms post: sign in and sign out. Like the
# generator's SessionsController, one message for every failure, so it
# never says whether the name has an account.
class Recipes::Auth::SessionsController < App::BaseController
include Recipes::Auth::Signing
rate_limit to: 10, within: 3.minutes, only: :create,
with: -> { redirect_to app_recipe_path("signin"), status: :see_other, alert: "Too many tries. Wait a few minutes." }
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
redirect_to app_recipe_path("signin"), status: :see_other, alert: "Try another username or password."
end
end
def destroy
sign_out
redirect_to app_recipe_path("signin"), status: :see_other, notice: "Signed out."
end
end
# frozen_string_literal: true
# Signing a demo account in and out, the way the Rails 8 authentication
# generator does it for `User`: a session row per sign-in, its id in a
# signed, httponly cookie. Only the demo's own controllers include it, so
# no other page asks who is signed in.
module Recipes::Auth::Signing
Session = Recipes::Auth::Session
private
def demo_session = defined?(@demo_session) ? @demo_session : (@demo_session = Session.from(cookies))
# A new row each time, so a cookie from before the sign-in is worth
# nothing after it.
def sign_in(account)
demo_session&.destroy
@demo_session = Session.start(account, user_agent: request.user_agent)
cookies.signed.permanent[Session::COOKIE] = { value: @demo_session.id, httponly: true, same_site: :lax }
end
def sign_out
demo_session&.destroy
@demo_session = nil
cookies.delete(Session::COOKIE)
end
# Renders a recipe page from another controller, as App::RecipesController
# would, for a form that comes back with errors.
def render_recipe(slug, status:)
@recipe = App::Recipe.find(slug)
@recipe.category.lineage.each { |category| add_breadcrumb category.title }
add_breadcrumb @recipe.title
render "app/recipes/show", status:
end
end
# frozen_string_literal: true
# A signed-in browser of a demo account. The browser holds the row's id in
# a signed cookie, and the row is what makes the cookie worth anything:
# delete it and that browser is signed out, wherever it is.
#
# It keeps no IP address and no user agent, only a label such as "Firefox
# on macOS", which is enough to tell your own browsers apart.
class Recipes::Auth::Session < ApplicationRecord
self.table_name = "recipes_auth_sessions"
# A line of the sessions list: a plain copy of a row.
Row = Data.define(:id, :label, :signed_in_at)
COOKIE = :demo_session_id
# How many sessions an account keeps. The oldest go when it signs in again.
KEEP = 10
belongs_to :account, class_name: "Recipes::Auth::Account", inverse_of: :sessions
after_commit { self.class.announce(account_id) }
# Each socket this session opened reconnects, as a guest now, so every
# open tab of it learns it was signed out.
after_destroy_commit { ActionCable.server.remote_connections.where(demo_session: self).disconnect }
# The session the browser's cookie names, if it is still there. The
# controllers, the views and the cable connection all ask this.
def self.from(cookies)
id = cookies.signed[COOKIE]
includes(:account).find_by(id:) if id
end
def self.start(account, user_agent:)
transaction do
create!(account:, label: label_for(user_agent)).tap do
account.sessions.where.not(id: account.sessions.order(id: :desc).limit(KEEP).select(:id)).destroy_all
end
end
end
# Each open sessions list of one account listens on its own stream.
def self.stream(account_id) = "recipes:auth:sessions:#{account_id}"
def self.announce(account_id) = ActionCable.server.broadcast(stream(account_id), {})
def self.rows(account_id) = where(account_id:).order(id: :desc).map(&:row)
def self.label_for(user_agent)
agent = user_agent.to_s
browser = { "Edg/" => "Edge", "OPR/" => "Opera", "Firefox/" => "Firefox", "Chrome/" => "Chrome", "Safari/" => "Safari" }
.find { |mark, _| agent.include?(mark) }&.last
system = { "iPhone" => "iOS", "iPad" => "iPadOS", "Android" => "Android", "CrOS" => "ChromeOS",
"Mac OS X" => "macOS", "Windows" => "Windows", "Linux" => "Linux" }
.find { |mark, _| agent.include?(mark) }&.last
[ browser || "A browser", system ].compact.join(" on ")
end
def row = Row.new(id:, label:, signed_in_at: created_at)
end
# frozen_string_literal: true
module ApplicationCable
class Connection < ActionCable::Connection::Base
# The demo account's session from the Sign Up, Sign In and guild
# recipes, or nil for a guest. Every other page works without one, so a
# socket with no session is not turned away. Signing in or out reloads
# the page, which opens a new socket that reads the cookie again; a
# session deleted elsewhere disconnects the sockets it opened.
identified_by :demo_session
def connect
self.demo_session = Recipes::Auth::Session.from(cookies)
end
# For channels that count a visitor's requests by address. It is never
# stored.
def remote_ip = request.remote_ip
end
end
<%# The sign-in form and the sign-out button are part of the page, never
of a channel render: a channel renders without a request, so a form it
drew would carry no CSRF token. The channel draws only the status. %>
<% session = demo_session %>
<% account = session&.account %>
<% shared = Recipes::Auth::Account.seeds["shared"] %>
<%= island Recipes::Signin::SigninChannel, params: { as: account&.username }.compact,
class: "group/signin panel mx-auto w-full max-w-xl p-4 sm:p-5" do %>
<header class="flex items-baseline justify-between gap-2">
<h2 class="text-xl font-bold">The gatehouse</h2>
<span class="loading loading-spinner loading-xs hidden in-data-hibiki-busy:inline-block" aria-hidden="true"></span>
</header>
<%= render "app/recipes/signin/status", shown: account&.username, account:, current: session&.id,
rows: account ? (account.shared? ? [ session.row ] : Recipes::Auth::Session.rows(account.id)) : [] %>
<% if account %>
<%= button_to "Sign out", recipes_auth_session_path, method: :delete, id: "signin_out", class: "btn btn-sm",
form: { data: { turbo: false }, class: "mt-3 group-has-[[data-signed-out]]/signin:hidden" } %>
<% else %>
<p class="mt-2 text-sm">
No account? <%= link_to "Make one", app_recipe_path("signup"), class: "link" %>, or use the shared one:
<b><%= shared["username"] %></b> with the password <code><%= shared["password"] %></code>.
</p>
<%# The button waits for both fields with CSS alone: they are required,
and the form has :invalid until both are filled. No round trip, and
the password never goes over the socket. %>
<%= form_with url: recipes_auth_session_path, id: "signin_form", class: "group/form mt-3 flex flex-col gap-3",
data: { turbo: false } do %>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">Username</span>
<%= tag.input type: "text", name: "username", required: true, maxlength: 20,
autocomplete: "username", autocapitalize: "none", spellcheck: false, class: "input w-full" %>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">Password</span>
<%= tag.input type: "password", name: "password", required: true,
autocomplete: "current-password", class: "input w-full" %>
</label>
<div class="flex items-center gap-3 border-t-2 border-base-300 pt-3">
<%= tag.button "Sign in", type: "submit", id: "signin_submit",
class: "btn btn-primary btn-sm group-has-[:invalid]/form:btn-disabled" %>
<p class="text-sm opacity-70 group-has-[:invalid]/form:block hidden">The button waits for both fields.</p>
</div>
<% end %>
<% end %>
<% end %>
<%# locals: (shown:, account:, current:, rows:) -%>
<%# Morphed by the channel. `shown` is the name the page was rendered for
and `account` the one the socket is signed in as now; they differ once
this browser was signed out or in somewhere else. %>
<% now = account&.username %>
<div id="signin_status" class="mt-2" <%= "data-signed-out" if shown && shown != now %>>
<% if shown && shown != now %>
<div role="status" class="alert alert-warning alert-soft text-sm">
<span>
<%= now ? "This browser is now signed in as #{now}, from another tab." : "This browser was signed out, in another tab or on another device." %>
<%= link_to "Reload the page", app_recipe_path("signin"), class: "link", data: { turbo: false } %>
</span>
</div>
<% elsif account %>
<p>Signed in as <b><%= now %></b>.</p>
<h3 class="mt-3 text-sm font-semibold">Where you are signed in</h3>
<ul id="signin_sessions" class="mt-1 flex flex-col divide-y divide-base-300 text-sm">
<% rows.each do |row| %>
<li id="signin_session_<%= row.id %>" class="flex items-center justify-between gap-2 py-1.5">
<span><%= row.label %><span class="opacity-70">, signed in <%= time_ago_in_words(row.signed_in_at) %> ago</span></span>
<% if row.id == current %>
<span class="badge badge-sm badge-primary">this browser</span>
<% else %>
<%= tag.button "Sign out", type: "button", class: "btn btn-xs", **on(:end_session, with: { id: row.id }) %>
<% end %>
</li>
<% end %>
</ul>
<% if account.shared? %>
<p class="mt-2 text-sm opacity-70">
Everyone shares this account, so its other sessions are not yours to see or end.
</p>
<% elsif rows.size > 1 %>
<%= tag.button "Sign out everywhere else", type: "button", id: "signin_end_others", class: "btn btn-sm mt-2",
**on(:end_others, confirm: "Sign out every other browser of this account?") %>
<% end %>
<% elsif now %>
<%# A page rendered for a guest, in a browser that has since signed in. %>
<div role="status" class="alert alert-info alert-soft text-sm">
<span>
This browser is now signed in as <%= now %>.
<%= link_to "Reload the page", app_recipe_path("signin"), class: "link", data: { turbo: false } %>
</span>
</div>
<% end %>
</div>
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:
- The controller makes a session row, and the row pings the account's stream.
-
The channel in the first browser hears it and adds one to
@sessions. -
rowsread@sessions, so it asks the database again and finds the new row. -
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.