From 7e18e4f80d2dd5d68f77a18d85b1300b35fb665e Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 17:15:05 +0200 Subject: [PATCH] feat: add Prism and Authenticatable --- lib/prism/authenticatable.rb | 165 +++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 lib/prism/authenticatable.rb diff --git a/lib/prism/authenticatable.rb b/lib/prism/authenticatable.rb new file mode 100644 index 0000000..8890b38 --- /dev/null +++ b/lib/prism/authenticatable.rb @@ -0,0 +1,165 @@ +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 +