lib/rodauth/features/reset_password.rb



# frozen-string-literal: true

module Rodauth
  ResetPassword = Feature.define(:reset_password) do
    depends :login, :email_base, :login_password_requirements_base

    notice_flash "Your password has been reset"
    notice_flash "An email has been sent to you with a link to reset the password for your account", 'reset_password_email_sent'
    error_flash "There was an error resetting your password"
    error_flash "There was an error requesting a password reset", 'reset_password_request'
    view 'reset-password', 'Reset Password'
    additional_form_tags
    additional_form_tags 'reset_password_request'
    before 
    before 'reset_password_request'
    after
    after 'reset_password_request'
    button 'Reset Password'
    button 'Request Password Reset', 'reset_password_request'
    redirect
    redirect :reset_password_email_sent
    
    auth_value_method :reset_password_deadline_column, :deadline
    auth_value_method :reset_password_deadline_interval, {:days=>1}
    auth_value_method :no_matching_reset_password_key_message, "invalid password reset key"
    auth_value_method :reset_password_email_subject, 'Reset Password'
    auth_value_method :reset_password_key_param, 'key'
    auth_value_method :reset_password_autologin?, false
    auth_value_method :reset_password_table, :account_password_reset_keys
    auth_value_method :reset_password_id_column, :id
    auth_value_method :reset_password_key_column, :key

    auth_value_methods :reset_password_email_sent_redirect

    auth_methods(
      :create_reset_password_key,
      :create_reset_password_email,
      :get_reset_password_key,
      :remove_reset_password_key,
      :reset_password_email_body,
      :reset_password_email_link,
      :reset_password_key_insert_hash,
      :reset_password_key_value,
      :send_reset_password_email
    )
    auth_private_methods(
      :account_from_reset_password_key
    )

    route(:reset_password_request) do |r|
      check_already_logged_in
      before_reset_password_request_route

      r.post do
        if account_from_login(param(login_param)) && open_account?
          generate_reset_password_key_value
          transaction do
            before_reset_password_request
            create_reset_password_key
            send_reset_password_email
            after_reset_password_request
          end

          set_notice_flash reset_password_email_sent_notice_flash
        else
          set_redirect_error_flash reset_password_request_error_flash
        end

        redirect reset_password_email_sent_redirect
      end
    end

    route do |r|
      check_already_logged_in
      before_reset_password_route

      r.get do
        if key = param_or_nil(reset_password_key_param)
          if account_from_reset_password_key(key)
            reset_password_view
          else
            set_redirect_error_flash no_matching_reset_password_key_message
            redirect require_login_redirect
          end
        end
      end

      r.post do
        key = param(reset_password_key_param)
        unless account_from_reset_password_key(key)
          set_redirect_error_flash reset_password_error_flash
          redirect reset_password_email_sent_redirect
        end

        password = param(password_param)
        catch_error do
          if password_match?(password) 
            throw_error(password_param, same_as_existing_password_message)
          end

          if require_password_confirmation? && password != param(password_confirm_param)
            throw_error(password_param, passwords_do_not_match_message)
          end

          unless password_meets_requirements?(password)
            throw_error(password_param, password_does_not_meet_requirements_message)
          end

          transaction do
            before_reset_password
            set_password(password)
            remove_reset_password_key
            after_reset_password
          end

          if reset_password_autologin?
            update_session
          end

          set_notice_flash reset_password_notice_flash
          redirect reset_password_redirect
        end

        set_error_flash reset_password_error_flash
        reset_password_view
      end
    end

    def create_reset_password_key
      ds = password_reset_ds
      transaction do
        ds.where(Sequel::CURRENT_TIMESTAMP > reset_password_deadline_column).delete
        if ds.empty?
          if e = raised_uniqueness_violation{ds.insert(reset_password_key_insert_hash)}
            # If inserting into the reset password table causes a violation, we can pull the 
            # existing reset password key from the table, or reraise.
            raise e unless @reset_password_key_value = get_password_reset_key(account_id)
          end
        end
      end
    end

    def remove_reset_password_key
      password_reset_ds.delete
    end

    def account_from_reset_password_key(key)
      @account = _account_from_reset_password_key(key)
    end

    def send_reset_password_email
      create_reset_password_email.deliver!
    end

    def reset_password_email_link
      token_link(reset_password_route, reset_password_key_param, reset_password_key_value)
    end

    def get_password_reset_key(id)
      password_reset_ds(id).get(reset_password_key_column)
    end

    private

    attr_reader :reset_password_key_value

    def after_login_failure
      @login_form_header = render("reset-password-request")
      super
    end

    def after_close_account
      remove_reset_password_key
      super if defined?(super)
    end

    def generate_reset_password_key_value
      @reset_password_key_value = random_key
    end

    def create_reset_password_email
      create_email(reset_password_email_subject, reset_password_email_body)
    end

    def reset_password_email_body
      render('reset-password-email')
    end

    def use_date_arithmetic?
      db.database_type == :mysql
    end

    def reset_password_key_insert_hash
      hash = {reset_password_id_column=>account_id, reset_password_key_column=>reset_password_key_value}
      set_deadline_value(hash, reset_password_deadline_column, reset_password_deadline_interval)
      hash
    end

    def password_reset_ds(id=account_id)
      db[reset_password_table].where(reset_password_id_column=>id)
    end

    def _account_from_reset_password_key(token)
      account_from_key(token, account_open_status_value){|id| get_password_reset_key(id)}
    end
  end
end