lib/rodauth/features/reset_password.rb



# frozen-string-literal: true

module Rodauth
  Feature.define(:reset_password, :ResetPassword) 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'
    error_flash "An email has recently been sent to you with a link to reset your password", 'reset_password_email_recently_sent'
    error_flash "There was an error resetting your password: invalid or expired password reset key", 'no_matching_reset_password_key'
    loaded_templates %w'reset-password-request reset-password password-field password-confirm-field reset-password-email'
    view 'reset-password', 'Reset Password'
    view 'reset-password-request', 'Request Password Reset', 'reset_password_request'
    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){default_post_email_redirect}
    redirect(:reset_password_email_recently_sent){default_post_email_redirect}
    
    auth_value_method :reset_password_deadline_column, :deadline
    auth_value_method :reset_password_deadline_interval, {:days=>1}.freeze
    translatable_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_method :reset_password_email_last_sent_column, :email_last_sent
    translatable_method :reset_password_explanatory_text, "<p>If you have forgotten your password, you can request a password reset:</p>"
    auth_value_method :reset_password_skip_resend_email_within, 300
    translatable_method :reset_password_request_link_text, "Forgot Password?"
    session_key :reset_password_session_key, :reset_password_key

    auth_methods(
      :create_reset_password_key,
      :create_reset_password_email,
      :get_reset_password_key,
      :get_reset_password_email_last_sent,
      :login_failed_reset_password_request_form,
      :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,
      :set_reset_password_email_last_sent
    )
    auth_private_methods(
      :account_from_reset_password_key
    )

    internal_request_method(:reset_password_request)
    internal_request_method

    route(:reset_password_request) do |r|
      check_already_logged_in
      before_reset_password_request_route

      r.get do
        reset_password_request_view
      end

      r.post do
        catch_error do
          unless account_from_login(param(login_param))
            throw_error_reason(:no_matching_login, no_matching_login_error_status, login_param, no_matching_login_message)
          end

          unless open_account?
            throw_error_reason(:unverified_account, unopen_account_error_status, login_param, unverified_account_message)
          end

          if reset_password_email_recently_sent?
            set_redirect_error_flash reset_password_email_recently_sent_error_flash
            redirect reset_password_email_recently_sent_redirect
          end

          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
          redirect reset_password_email_sent_redirect
        end

        set_error_flash reset_password_request_error_flash
        reset_password_request_view
      end
    end

    route do |r|
      check_already_logged_in
      before_reset_password_route
      @password_field_autocomplete_value = 'new-password'

      r.get do
        if key = param_or_nil(reset_password_key_param)
          set_session_value(reset_password_session_key, key)
          redirect(r.path)
        end

        if key = session[reset_password_session_key]
          if account_from_reset_password_key(key)
            reset_password_view
          else
            remove_session_value(reset_password_session_key)
            set_redirect_error_flash no_matching_reset_password_key_error_flash
            redirect require_login_redirect
          end
        end
      end

      r.post do
        key = session[reset_password_session_key] || param(reset_password_key_param)
        unless account_from_reset_password_key(key)
          set_redirect_error_status(invalid_key_error_status)
          set_error_reason :invalid_reset_password_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_reason(:same_as_existing_password, invalid_field_error_status, password_param, same_as_existing_password_message)
          end

          if require_password_confirmation? && password != param(password_confirm_param)
            throw_error_reason(:passwords_do_not_match, unmatched_field_error_status, password_param, passwords_do_not_match_message)
          end

          unless password_meets_requirements?(password)
            throw_error_status(invalid_field_error_status, 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?
            autologin_session('reset_password')
          end

          remove_session_value(reset_password_session_key)
          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
      transaction do
        if reset_password_key_value = get_password_reset_key(account_id)
          set_reset_password_email_last_sent
          @reset_password_key_value = reset_password_key_value
        elsif e = raised_uniqueness_violation{password_reset_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

    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
      send_email(create_reset_password_email)
    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)
      ds = password_reset_ds(id)
      ds.where(Sequel::CURRENT_TIMESTAMP > reset_password_deadline_column).delete
      ds.get(reset_password_key_column)
    end

    def set_reset_password_email_last_sent
       password_reset_ds.update(reset_password_email_last_sent_column=>Sequel::CURRENT_TIMESTAMP) if reset_password_email_last_sent_column
    end

    def get_reset_password_email_last_sent
      if column = reset_password_email_last_sent_column
        if ts = password_reset_ds.get(column)
          convert_timestamp(ts)
        end
      end
    end

    private

    def _login_form_footer_links
      super << [20, reset_password_request_path, reset_password_request_link_text]
    end

    def reset_password_email_recently_sent?
      (email_last_sent = get_reset_password_email_last_sent) && (Time.now - email_last_sent < reset_password_skip_resend_email_within)
    end

    attr_reader :reset_password_key_value

    def after_login_failure
      unless only_json?
        @login_form_header = login_failed_reset_password_request_form
      end
      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 login_failed_reset_password_request_form
      render("reset-password-request")
    end

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

    def use_date_arithmetic?
      super || 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