From 40225e5791a18d66087c162480e493aecd1105be Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 18:21:18 +0200 Subject: [PATCH] feat: add sign_in and sign_out to authenticatable --- lib/prism/authenticatable.rb | 167 +++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/lib/prism/authenticatable.rb b/lib/prism/authenticatable.rb index 8890b38..5954ec2 100644 --- a/lib/prism/authenticatable.rb +++ b/lib/prism/authenticatable.rb @@ -163,3 +163,170 @@ module Prism true end + def sign_in(user, remember: false) + session[authenticatable_session_key] = user.id + @current_user = user + if remember + remember_user(user) + elsif authenticatable_remember_sessions_model + session.delete(:login_session_id) + @current_login_session = nil + end + end + + # Ends the current login: for `remember_cookie:`, rotates the shared + # token (not just deletes the cookie) so a copy of the old cookie + # value - already sitting in a proxy log, another browser tab, + # wherever - stops working immediately, everywhere, instead of + # remaining valid until it naturally expires. For `remember_sessions:`, + # destroys only THIS ONE login-session row, leaving every other + # remembered browser signed in exactly as before. + def sign_out + if authenticatable_remember_sessions_model + current_login_session&.destroy + forget_current_login_session_cookie + elsif authenticatable_remember_cookie + forget_user(current_user) + end + session.delete(authenticatable_session_key) + @current_user = nil + @current_login_session = nil + end + + def require_authentication + return if user_signed_in? + session[:return_to_after_authenticating] = request.path + respond_to_unauthenticated + end + + # Where to send the user once they do sign in: the page they were + # trying to reach before `require_authentication` intercepted them, or + # '/' if they arrived at the sign-in page directly. + def after_authentication_url + session.delete(:return_to_after_authenticating) || '/' + end + + private + + # Accepts either the model class itself or its name as a String/Symbol + # (resolved lazily, the same way Associable resolves `class_name:`), so + # `authenticate_with` can be declared before the model class exists yet + # (e.g. load order, or a model built dynamically in a test). + def authenticatable_model + configured = self.class.authenticatable_model + raise "#{self.class} must call authenticate_with model: SomeModel" unless configured + configured.is_a?(Module) ? configured : Object.const_get(configured.to_s) + end + + def authenticatable_session_key + self.class.authenticatable_session_key || :user_id + end + + def authenticatable_remember_cookie + self.class.authenticatable_remember_cookie + end + + def authenticatable_remember_sessions_model + configured = self.class.authenticatable_remember_sessions_model + return nil unless configured + configured.is_a?(Module) ? configured : Object.const_get(configured.to_s) + end + + def authenticatable_remember_sessions_foreign_key + (self.class.authenticatable_remember_sessions_foreign_key || :user_id).to_s + end + + def authenticatable_max_remember_sessions + self.class.authenticatable_max_remember_sessions + end + + def user_from_remember_cookie + if authenticatable_remember_sessions_model + user_from_remember_session_cookie + elsif authenticatable_remember_cookie + user_from_remember_column_cookie + end + end + + def user_from_remember_session_cookie + token = cookies.signed[REMEMBER_SESSION_COOKIE] + return nil unless token + login_session = authenticatable_remember_sessions_model.find_by(token: token) + return nil unless login_session + user = authenticatable_model.find_by(id: login_session[authenticatable_remember_sessions_foreign_key]) + return nil unless user + session[authenticatable_session_key] = user.id + session[:login_session_id] = login_session.id + user + end + + def user_from_remember_column_cookie + token = cookies.signed[authenticatable_remember_cookie] + return nil unless token + user = authenticatable_model.find_by(authenticatable_remember_cookie => token) + session[authenticatable_session_key] = user.id if user + user + end + + def remember_user(user) + if authenticatable_remember_sessions_model + remember_user_with_session(user) + elsif authenticatable_remember_cookie + remember_user_with_column(user) + else + raise "#{self.class} must pass remember_cookie: or remember_sessions: to authenticate_with " \ + 'to use sign_in(remember: true)' + end + end + + def remember_user_with_session(user) + model = authenticatable_remember_sessions_model + attributes = { authenticatable_remember_sessions_foreign_key => user.id } + attributes['user_agent'] = request.user_agent if model.columns.include?('user_agent') + attributes['ip_address'] = request.remote_addr if model.columns.include?('ip_address') + login_session = model.create!(attributes) + session[:login_session_id] = login_session.id + @current_login_session = login_session + cookies.signed[REMEMBER_SESSION_COOKIE] = { value: login_session.token, max_age: REMEMBER_COOKIE_MAX_AGE } + prune_oldest_login_sessions(user) + end + + # Destroys the oldest rows for `user` beyond `max_remember_sessions:`, if + # configured. The row just created above is always the newest by id, so + # it is never among the ones pruned here (unless the limit is 0, which + # would prune it too - authenticate_with does not special-case that, + # matching Devise's own `0 means never remembered` behavior for a + # deliberately-set zero). + def prune_oldest_login_sessions(user) + limit = authenticatable_max_remember_sessions + return unless limit + model = authenticatable_remember_sessions_model + sessions = model.where(authenticatable_remember_sessions_foreign_key => user.id).order(:id, :asc).to_a + overflow = sessions.size - limit + return if overflow <= 0 + sessions.first(overflow).each(&:destroy) + end + + def remember_user_with_column(user) + column = authenticatable_remember_cookie + user.send("regenerate_#{column}!") if user.send(column).to_s.empty? + cookies.signed[column] = { value: user.send(column), max_age: REMEMBER_COOKIE_MAX_AGE } + end + + def forget_user(user) + column = authenticatable_remember_cookie + user.send("regenerate_#{column}!") if user && user.persisted? + cookies.set(column, '', max_age: 0) + end + + def forget_current_login_session_cookie + cookies.set(REMEMBER_SESSION_COOKIE, '', max_age: 0) + session.delete(:login_session_id) + end + + def respond_to_unauthenticated + path = self.class.unauthenticated_redirect_path + path ? redirect(path) : halt(401, 'authentication required') + end + end +end