lib/rodauth/features/remember.rb



# frozen-string-literal: true

module Rodauth
  Feature.define(:remember, :Remember) do
    notice_flash "Your remember setting has been updated"
    error_flash "There was an error updating your remember setting"
    loaded_templates %w'remember'
    view 'remember', 'Change Remember Setting'
    additional_form_tags
    button 'Change Remember Setting'
    before
    before 'load_memory'
    after
    after 'load_memory'
    redirect

    auth_value_method :raw_remember_token_deadline, nil
    auth_value_method :remember_cookie_options, {}.freeze
    auth_value_method :extend_remember_deadline?, false
    auth_value_method :remember_period, {:days=>14}.freeze
    auth_value_method :remember_deadline_interval, {:days=>14}.freeze
    auth_value_method :remember_id_column, :id
    auth_value_method :remember_key_column, :key
    auth_value_method :remember_deadline_column, :deadline
    auth_value_method :remember_table, :account_remember_keys
    auth_value_method :remember_cookie_key, '_remember'
    auth_value_method :remember_param, 'remember'
    auth_value_method :remember_remember_param_value, 'remember'
    auth_value_method :remember_forget_param_value, 'forget'
    auth_value_method :remember_disable_param_value, 'disable'
    translatable_method :remember_remember_label, 'Remember Me'
    translatable_method :remember_forget_label, 'Forget Me'
    translatable_method :remember_disable_label, 'Disable Remember Me'

    auth_methods(
      :add_remember_key,
      :disable_remember_login,
      :forget_login,
      :generate_remember_key_value,
      :get_remember_key,
      :load_memory,
      :remembered_session_id,
      :logged_in_via_remember_key?,
      :remember_key_value,
      :remember_login,
      :remove_remember_key
    )

    internal_request_method :remember_setup
    internal_request_method :remember_disable
    internal_request_method :account_id_for_remember_key

    route do |r|
      require_account
      before_remember_route

      r.get do
        remember_view
      end

      r.post do
        remember = param(remember_param)
        if [remember_remember_param_value, remember_forget_param_value, remember_disable_param_value].include?(remember)
          transaction do
            before_remember
            # :nocov:
            case remember
            # :nocov:
            when remember_remember_param_value
              remember_login
            when remember_forget_param_value
              forget_login 
            when remember_disable_param_value
              disable_remember_login 
            end
            after_remember
          end

          set_notice_flash remember_notice_flash
          redirect remember_redirect
        else
          set_response_error_reason_status(:invalid_remember_param, invalid_field_error_status)
          set_error_flash remember_error_flash
          remember_view
        end
      end
    end

    def remembered_session_id
      return unless cookie = _get_remember_cookie
      id, key = cookie.split('_', 2)
      return unless id && key

      actual, deadline = active_remember_key_ds(id).get([remember_key_column, remember_deadline_column])
      return unless actual

      if hmac_secret
        unless valid = timing_safe_eql?(key, compute_hmac(actual))
          unless raw_remember_token_deadline && raw_remember_token_deadline > convert_timestamp(deadline)
            return
          end
        end
      end

      unless valid || timing_safe_eql?(key, actual)
        return
      end

      id
    end

    def load_memory
      return if session[session_key]

      unless id = remembered_session_id
        # Only set expired cookie if there is already a cookie set.
        forget_login if _get_remember_cookie
        return
      end

      set_session_value(session_key, id)
      account = account_from_session
      remove_session_value(session_key)

      unless account
        remove_remember_key(id)
        forget_login
        return 
      end

      before_load_memory
      login_session('remember')

      if extend_remember_deadline?
        active_remember_key_ds(id).update(remember_deadline_column=>Sequel.date_add(Sequel::CURRENT_TIMESTAMP, remember_period))
        remember_login
      end
      after_load_memory
    end

    def remember_login
      get_remember_key
      opts = Hash[remember_cookie_options]
      opts[:value] = "#{account_id}_#{convert_token_key(remember_key_value)}"
      opts[:expires] = convert_timestamp(active_remember_key_ds.get(remember_deadline_column))
      opts[:path] = "/" unless opts.key?(:path)
      opts[:httponly] = true unless opts.key?(:httponly)
      opts[:secure] = true unless opts.key?(:secure) || !request.ssl?
      ::Rack::Utils.set_cookie_header!(response.headers, remember_cookie_key, opts)
    end

    def forget_login
      opts = Hash[remember_cookie_options]
      opts[:path] = "/" unless opts.key?(:path)
      ::Rack::Utils.delete_cookie_header!(response.headers, remember_cookie_key, opts)
    end

    def get_remember_key
      unless @remember_key_value = active_remember_key_ds.get(remember_key_column)
       generate_remember_key_value
       transaction do
         remove_remember_key
         add_remember_key
       end
      end
      nil
    end

    def disable_remember_login
      remove_remember_key
    end

    def add_remember_key
      hash = {remember_id_column=>account_id, remember_key_column=>remember_key_value}
      set_deadline_value(hash, remember_deadline_column, remember_deadline_interval)

      if e = raised_uniqueness_violation{remember_key_ds.insert(hash)}
        # If inserting into the remember key table causes a violation, we can pull the 
        # existing row from the table.  If there is no invalid row, we can then reraise.
        raise e unless @remember_key_value = active_remember_key_ds.get(remember_key_column)
      end
    end

    def remove_remember_key(id=account_id)
      remember_key_ds(id).delete
    end

    def logged_in_via_remember_key?
      authenticated_by.include?('remember')
    end

    private

    def _get_remember_cookie
      request.cookies[remember_cookie_key]
    end

    def after_logout
      forget_login
      super if defined?(super)
    end

    def after_close_account
      remove_remember_key
      super if defined?(super)
    end

    attr_reader :remember_key_value

    def generate_remember_key_value
      @remember_key_value = random_key
    end

    def use_date_arithmetic?
      super || extend_remember_deadline? || db.database_type == :mysql
    end

    def remember_key_ds(id=account_id)
      db[remember_table].where(remember_id_column=>id)
    end

    def active_remember_key_ds(id=account_id)
      remember_key_ds(id).where(Sequel.expr(remember_deadline_column) > Sequel::CURRENT_TIMESTAMP)
    end
  end
end