166 lines
8.9 KiB
Ruby
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
|
|
|