Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Rails Devise Password Reset Email allowing multiple submissions

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

like image 391
Elliott de Launay Avatar asked Sep 01 '25 01:09

Elliott de Launay


2 Answers

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
like image 179
nruth Avatar answered Sep 02 '25 14:09

nruth


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.

like image 31
Billy Chan Avatar answered Sep 02 '25 13:09

Billy Chan