Files
prism/lib/prism/authenticatable.rb
T

166 lines
8.9 KiB
Ruby

module Prism
# Session-based authentication for a Controller, the request-cycle
# counterpart to Orm::SecurePassword: `authenticate_with model: User` wires
# up `current_user`/`user_signed_in?`/`sign_in`/`sign_out`, and
# `require_authentication` (attach it with `before_action`) redirects
# anonymous requests while remembering where they were headed, like
# Rails 8's generated authentication.
module Authenticatable
# ~20 years, the same "forever" horizon Rails' cookies.permanent uses -
# long enough that in practice a remember-me cookie only ever ends
# because of an explicit sign_out (which rotates/destroys the token) or
# the user clearing cookies, not calendar expiry.
REMEMBER_COOKIE_MAX_AGE = 20 * 365 * 24 * 60 * 60
# Fixed cookie name for the `remember_sessions:` mechanism - unlike
# `remember_cookie:` (whose cookie is named after the column it lives
# in on the user itself), a login-session token does not live on the
# user at all, so there is no column name to borrow.
REMEMBER_SESSION_COOKIE = :remember_session_token
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
attr_writer :authenticatable_model, :authenticatable_session_key, :unauthenticated_redirect_path,
:authenticatable_remember_cookie, :authenticatable_remember_sessions_model,
:authenticatable_remember_sessions_foreign_key, :authenticatable_max_remember_sessions
# Falls back to the superclass's configuration, the same way
# Controller#layout/#views_root do, so an ApplicationController can
# call `authenticate_with` once and have every subclass see it -
# otherwise a plain `PostsController < ApplicationController` would
# silently see unconfigured (nil) settings and fall through to a bare
# 401 instead of the configured redirect.
%i[authenticatable_model authenticatable_session_key unauthenticated_redirect_path
authenticatable_remember_cookie authenticatable_remember_sessions_model
authenticatable_remember_sessions_foreign_key authenticatable_max_remember_sessions].each do |setting|
define_method(setting) do
ivar = "@#{setting}"
next instance_variable_get(ivar) if instance_variable_defined?(ivar)
superclass.respond_to?(setting) ? superclass.public_send(setting) : nil
end
end
# `remember_cookie:` names the has_secure_token column (see
# Orm::SecureToken) that backs "remember me" - nil (the default)
# keeps sign_in/sign_out session-only, matching every controller
# written before this option existed. It is a SINGLE token per user:
# every browser that ever checked "remember me" shares the same
# column, so `sign_out` from any one of them rotates the token and
# silently signs the user out everywhere else too.
#
# `remember_sessions:` is the multi-device alternative - a model
# (e.g. `LoginSession`) with its own `has_secure_token :token` column
# and a foreign key back to the user (`remember_sessions_foreign_key:`,
# default `:user_id`). Each `sign_in(remember: true)` creates its own
# row instead of overwriting a shared column, so signing out of one
# browser leaves every other remembered browser's session intact -
# see `other_login_sessions`/`sign_out_all_other_sessions`/
# `revoke_login_session!` below for managing them. Mutually exclusive
# with `remember_cookie:`.
#
# `max_remember_sessions:` (only meaningful with `remember_sessions:`)
# caps how many rows a single user may accumulate - without it,
# `remember_sessions:` grows without bound: every "remember me" sign-in
# on every browser/device ever used adds a row that nothing ever prunes,
# forever, even for a browser the user will never open again. When set,
# each successful `remember` sign-in destroys the OLDEST sessions for
# that user (by id, i.e. creation order) beyond the limit, so the newly
# created one and the `limit - 1` most recent others survive - a device
# pruned this way finds out the same way a `revoke_login_session!`'d one
# does: its remember-cookie token no longer matches any row.
def authenticate_with(model:, session_key: :user_id, unauthenticated_redirect_path: nil,
remember_cookie: nil, remember_sessions: nil, remember_sessions_foreign_key: :user_id,
max_remember_sessions: nil)
if remember_cookie && remember_sessions
raise ArgumentError, 'authenticate_with accepts either remember_cookie: or remember_sessions:, not both'
end
if max_remember_sessions && !remember_sessions
raise ArgumentError, 'max_remember_sessions: requires remember_sessions: to also be given'
end
self.authenticatable_model = model
self.authenticatable_session_key = session_key
self.unauthenticated_redirect_path = unauthenticated_redirect_path
self.authenticatable_remember_cookie = remember_cookie
self.authenticatable_remember_sessions_model = remember_sessions
self.authenticatable_remember_sessions_foreign_key = remember_sessions_foreign_key
self.authenticatable_max_remember_sessions = max_remember_sessions
end
end
# Session first (cheap, no cookie-signature verification), then the
# persistent remember-me cookie - and if the cookie is what answered,
# the session is repopulated so the *next* request on this same
# browser is back to the fast path instead of re-checking the cookie
# every time.
def current_user
return @current_user if defined?(@current_user)
id = session[authenticatable_session_key]
@current_user = id ? authenticatable_model.find_by(id: id) : user_from_remember_cookie
end
def user_signed_in?
!current_user.nil?
end
# The `remember_sessions:` row this request is authenticated through -
# nil if that mechanism is not configured, or if this request is not
# (and never has been, on this browser) remembered at all. Resolves via
# `session[:login_session_id]`, which is set both by a fresh
# `sign_in(remember: true)` and by the remember-cookie fallback in
# `current_user`, so it stays correct across requests on the fast path
# (plain session cookie) as well as the cold-start one (only the
# persistent cookie survived).
def current_login_session
return @current_login_session if defined?(@current_login_session)
@current_login_session = nil
return @current_login_session unless authenticatable_remember_sessions_model
current_user
id = session[:login_session_id]
@current_login_session = id ? authenticatable_remember_sessions_model.find_by(id: id) : nil
end
# Every OTHER remembered login for the current user - the list a
# "manage your devices" screen shows, deliberately excluding whichever
# one answered for the current request.
def other_login_sessions
return authenticatable_remember_sessions_model.none unless authenticatable_remember_sessions_model
return authenticatable_remember_sessions_model.none unless current_user
scope = authenticatable_remember_sessions_model.where(authenticatable_remember_sessions_foreign_key => current_user.id)
here = current_login_session
here ? scope.not(id: here.id) : scope
end
def sign_out_all_other_sessions
other_login_sessions.each(&:destroy)
end
# Destroys one login session by id, but only if it belongs to the
# current user - a device-management screen calls this from a button
# per row, and this is the one place that has to check ownership
# itself (there is no controller-level `authorize` call for a plain
# id posted from a form). Returns false (no-op) for a foreign or
# already-gone id instead of raising, since "revoke" on something
# that is not there anymore is not an error from the caller's view.
#
# `id` is coerced with #to_i: unlike Model.find (which goes through
# Store#find and coerces internally), find_by compares with == and
# does not, so the raw String a controller reads straight out of
# params['id'] would otherwise never match the Integer id on the row
# and this would silently no-op on every real HTTP request - the same
# gotcha NestedAttributes#find_existing_nested_record and
# PostsController#restore already work around the same way.
def revoke_login_session!(id)
return false unless authenticatable_remember_sessions_model && current_user
target = authenticatable_remember_sessions_model.find_by(
id: id.to_i, authenticatable_remember_sessions_foreign_key => current_user.id
)
return false unless target
target.destroy
forget_current_login_session_cookie if current_login_session && current_login_session.id == target.id
true
end