lib/rodauth/features/single_session.rb



# frozen-string-literal: true

module Rodauth
  Feature.define(:single_session, :SingleSession) do
    error_flash 'This session has been logged out as another session has become active'
    redirect

    auth_value_method :allow_raw_single_session_key?, false
    auth_value_method :inactive_session_error_status, 401
    auth_value_method :single_session_id_column, :id
    auth_value_method :single_session_key_column, :key
    session_key :single_session_session_key, :single_session_key
    auth_value_method :single_session_table, :account_session_keys

    auth_methods(
      :currently_active_session?,
      :no_longer_active_session,
      :reset_single_session_key,
      :update_single_session_key
    )

    def reset_single_session_key
      if logged_in?
        single_session_ds.update(single_session_key_column=>random_key)
      end
    end

    def currently_active_session?
      single_session_key = session[single_session_session_key]
      current_key = single_session_ds.get(single_session_key_column)
      if single_session_key.nil?
        unless current_key
          # No row exists for this user, indicating the feature has never
          # been used, so it is OK to treat the current session as a new
          # session.
          update_single_session_key
        end
        true
      elsif current_key
        if hmac_secret
          valid = timing_safe_eql?(single_session_key, compute_hmac(current_key))
          if !valid && !allow_raw_single_session_key?
            return false
          end
        end

        valid || timing_safe_eql?(single_session_key, current_key)
      end
    end

    def check_single_session
      if logged_in? && !currently_active_session?
        no_longer_active_session
      end
    end

    def no_longer_active_session
      clear_session
      set_redirect_error_status inactive_session_error_status
      set_error_reason :inactive_session
      set_redirect_error_flash single_session_error_flash
      redirect single_session_redirect
    end

    def update_single_session_key
      key = random_key
      set_single_session_key(key)
      if single_session_ds.update(single_session_key_column=>key) == 0
        # Don't handle uniqueness violations here.  While we could get the stored key from the
        # database, it could lead to two sessions sharing the same key, which this feature is
        # designed to prevent.
        single_session_ds.insert(single_session_id_column=>session_value, single_session_key_column=>key)
      end
    end

    def update_session
      super
      update_single_session_key
    end

    private

    def after_close_account
      super if defined?(super)
      single_session_ds.delete
    end

    def before_logout
      reset_single_session_key
      super if defined?(super)
    end

    def set_single_session_key(data)
      data = compute_hmac(data) if hmac_secret
      set_session_value(single_session_session_key, data)
    end

    def single_session_ds
      db[single_session_table].
        where(single_session_id_column=>session_value)
    end
  end
end