Rails Devise Password Reset Email allowing multiple submissions

3.2k views Asked by At

I have the following code that allows a user to request a password reset in an AJAX form:

<%= form_for(resource, :as => resource_name, :url => password_path(resource_name), :html => { :method => :post },:remote =>'true') do |f| %>
 <%= devise_error_messages! %>
 <div><%= f.label :email %><br />    
 <%= f.email_field :email %></div>
 <div><%= f.submit "Send me reset password instructions" %></div>
<% end %>

This is allowing the behavior whereby if the user clicks the button repeatedly, or presses "enter" repeatedly, before the server can provide a response, a corresponding # of password reset emails are being sent.

The following is within devise/password_controller.rb

def create
 self.resource = resource_class.send_reset_password_instructions(resource_params)   
 if successfully_sent?(resource)
  flash[:notice] = "You will receive an email with instructions about how to reset your password in a few minutes."
  respond_to do |format|
   format.html #responds with default html file
   format.js 
  end    
 else
  respond_to do |format|
   format.html #responds with default html file
   format.js{ render :js => "$(\".deviseErrors\").html(\"<span class='login-error'>Could not send reset instructions to that address.</span>\");" } #this will be the javascript file we respond with
  end
 end
end

Is there a way to only respond to the first submission?

Thanks

4

There are 4 answers

6
Billy Chan On BEST ANSWER

I would recommend to use JavaScript to prevent multiple submissions.

$('form#reset_password').on('submit', function() {
  $(this).find('input[type="submit"]').attr('disabled', 'disabled')
})

This will set the submit button as "disabled" status and user can't submit again.

Reference about form's disabled attribute: http://www.w3schools.com/tags/att_input_disabled.asp*

Add: Response to thr's answer

I browsed Devise source and found there should be a solution at model level. To set the max interval allowed between each resetting request, add such in resource model

class User < ActiveRecord::Base

  def self.reset_password_with
    1.day
    # Determine the interval. Any time objects will do, say 1.hour
  end
end

Then Devise::Models::Recoverable will check this value to decide if a token should be sent. I have not verified this but it should work.

2
trh On

If you're really just trying to keep people from double clicking submit, then restricting by javascript is the way to go as billy-chan suggested in his answer.

If you want to limit the amount of time between sending requests to a given use, then you can set the resource and wrap that functionality in an if statement checking the time stamp when the last password request was sent. Something like this

def create
  self.resource = resource_class.find_by_email(resource_params[:email])
  if resource.reset_password_sent_at.nil?  ||  Time.now > resource.reset_password_sent_at + 5.minutes
    self.resource = resource_class.send_reset_password_instructions(resource_params)
    if successfully_sent?(resource)
      flash[:notice] = "You will receive an email with instructions about how to reset your password in a few minutes."
      respond_to do |format|
        format.html #responds with default html file
        format.js
      end
    else
      respond_to do |format|
        format.html #responds with default html file
        format.js{ render :js => "$(\".deviseErrors\").html(\"<span class='login-error'>Could not send reset instructions to that address.</span>\");" } #this will be the javascript file we respond with
      end
    end
  else
    flash[:error] = "Passwords can only be reset every 5 minutes."
    respond_to do |format|
      format.html #responds with default html file
      format.js
    end
  end
end
0
Ferenc Dobi On

You can do something like this in Devise:

class User < ActiveRecord::Base
  def send_reset_password_instructions
    super unless reset_password_sent_at.present? && reset_password_sent_at > DateTime.now - 1.day
  end
end

Where 1.day is the interval between allowed password resets.

2
nruth On

I think this idea is pretty useful if you're dealing with customers, who instead of waiting for the email will re-request 3 or 4 times, at which point the first one might turn up, but will by now have an invalid link. Hysteresis or just re-sending the same link are nice to have, but as I mentioned above it's no longer (?) in the devise code, which just handles expiring old reset requests, not limiting the sending of new ones.

I've gone with a simplified version of trh's idea, which selectively forwards to the original devise code. In case there's been a request sent within the last hour it just pretends it's sent it again, and assumes that Mailgun or whoever you are using will get the message where it needs to go.

class Members::PasswordsController < Devise::PasswordsController
  def create
    self.resource = resource_class.find_by_email(resource_params[:email])
    if resource && (!resource.reset_password_sent_at.nil? || Time.now > resource.reset_password_sent_at + 1.hour)
      super
    else
      flash[:notice] = I18n.t('devise.passwords.send_instructions')
      respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name))
    end
  end
end

Behaves like this:

  specify "asking twice sends the email once only, until 1 hour later" do
    member = make_activated_member
    ActionMailer::Base.deliveries.clear
    2.times do
      ensure_on member_dashboard_path
      click_on "Forgotten your password?"
      fill_in "Email", :with => member.email
      click_on "Send me password reset instructions"
    end
    # see for mail helpers https://github.com/bmabey/email-spec/blob/master/lib/email_spec/helpers.rb

    expect(mailbox_for(member.email).length).to eq(1)
    expect(page).to have_content(I18n.t('devise.passwords.send_instructions'))    

    Timecop.travel(Time.now + 2.hours) do
      expect {
        ensure_on member_dashboard_path
        click_on "Forgotten your password?"
        fill_in "Email", :with => member.email
        click_on "Send me password reset instructions"
      }.to change{mailbox_for(member.email).length}.by(+1)
    end
  end

Bonus points for updating it to re-send the original email with the same link, as in this test:

  specify "asking twice sends the same link both times" do
    member = make_activated_member
    ActionMailer::Base.deliveries.clear
    2.times do
      visit member_dashboard_path
      click_on "Forgotten your password?"
      fill_in "Email", :with => member.email
      click_on "Send me password reset instructions"
    end
    # see for mail helpers https://github.com/bmabey/email-spec/blob/master/lib/email_spec/helpers.rb

    mails = mailbox_for(member.email)
    expect(mails.length).to eq(2)
    first_mail = mails.first
    second_mail = mails.last

    expect(links_in_email(first_mail)).to eq(links_in_email(second_mail))
  end