public
Description: acts_as_authenticated Ruby on Rails authentication system
Home | Edit | New

Password Resetting

Okay, so I’m assuming you’ve already completed the following

  • User Activation
  • Mailer Setup

It’s a little bit of work to get everything setup, but following on from the practices displayed in the other two, things should be pretty familiar.
Migration

First we’ll generate a migration to add the password reset code field, that we’ll use to ensure that our user has indeed requested to change their password


class AddPasswordResetCode < ActiveRecord::Migration
  def self.up
    add_column "users", "password_reset_code", :string, :limit => 40
  end

  def self.down
    remove_column "users", "password_reset_code" 
  end
end

Model ( user.rb )

Okay, lets set up a couple more methods in our User model


def forgot_password @forgotten_password = true self.make_password_reset_code end def reset_password
  1. First update the password_reset_code before setting the
  2. reset_password flag to avoid duplicate email notifications.
    update_attributes(:password_reset_code => nil)
    @reset_password = true
    end
def recently_reset_password? @reset_password end def recently_forgot_password? @forgotten_password end protected def make_password_reset_code self.password_reset_code = Digest::SHA1.hexdigest( Time.now.to_s.split(//).sort_by {rand}.join ) end



Controller ( account_controller.rb )

Now the two methods for requesting to change the password, and then reseting it to the users choice


def forgot_password return unless request.post? if @user = User.find_by_email(params[:email]) @user.forgot_password @user.save redirect_back_or_default(:controller => ‘/account’, :action => ‘index’) flash[:notice] = “A password reset link has been sent to your email address” else flash[:notice] = “Could not find a user with that email address” end end def reset_password @user = User.find_by_password_reset_code(params[:id]) raise if @user.nil? return if @user unless params[:password] if (params[:password] == params[:password_confirmation]) self.current_user = @user #for the next two lines to work current_user.password_confirmation = params[:password_confirmation] current_user.password = params[:password] @user.reset_password flash[:notice] = current_user.save ? “Password reset” : “Password not reset” else flash[:notice] = “Password mismatch” end redirect_back_or_default(:controller => ‘/account’, :action => ‘index’) rescue logger.error “Invalid Reset Code entered” flash[:notice] = “Sorry – That is an invalid password reset code. Please check your code and try again. (Perhaps your email client inserted a carriage return?” redirect_back_or_default(:controller => ‘/account’, :action => ‘index’) end


Views

Add these two new views to views/accounts
reset_password.rhtml


<% form_tag url_for(:action => “reset_password”) do >
Password:
<
= password_field_tag :password >
Confirm Password:
<
= password_field_tag :password_confirmation >
<
= submit_tag “Reset Your Password” >
<
end %>


forgot_password.rhtml

Forgot Password

<% form_tag url_for(:action => ‘forgot_password’) do >
What is the email address used to create your account?
<
= text_field_tag :email, "", :size => 50 >
<
= submit_tag ‘Request Password’ >
<
end %>


user_notifier.rb

Add these two new methods


def forgot_password(user) setup_email(user) @subject += ‘Request to change your password’ @body[:url] = “http://localhost:3000/account/reset_password/#{user.password_reset_code}” end def reset_password(user) setup_email(user) @subject += ‘Your password has been reset’ end



user_observer.rb

Add two new lines to the after_save function


def after_save(user) … UserNotifier.deliver_forgot_password(user) if user.recently_forgot_password? UserNotifier.deliver_reset_password(user) if user.recently_reset_password? end



Mailer Templates

Add these two new templates to views/user_notifier
forgot_password.rhtml


<%= @user.login %>, follow the link to reset your password

<%= @url %>



reset_password.rhtml

<%= @user.login %>, Your password has been reset


Test Cases

class AccountControllerTest < Test::Unit::TestCase

def test_should_forget_password post :forgot_password, :email => ‘quentin@example.com’ assert_response :redirect assert flash.has_key?(:notice), “Flash should contain notice message.” assert_equal 1, @emails.length assert(@emails.first.subject =~ /Request to change your password/) end def test_should_not_forget_password post :forgot_password, :email => ‘invalid@email’ assert_response :success assert flash.has_key?(:notice), “Flash should contain notice message.” assert_equal 0, @emails.length end def test__reset_password__valid_code_and_password__should_reset @user = users(:aaron) @user.forgot_password && @user.save @emails.clear post :reset_password, :id => @user.password_reset_code, :password => “new_password”, :password_confirmation => “new_password” assert_match(“Password reset”, flash[:notice]) assert_equal 1, @emails.length # make sure that it e-mails the user notifying that their password was reset assert_equal(@user.email, @emails.first.to0, “should have gone to user”)
  1. Make sure that the user can login with this new password
    assert(User.authenticate(@user.login, “new_password”), “password should have been reset”)
    end
def test__reset_password__valid_code_but_not_matching_password__shouldnt_reset @user = users(:aaron) @user.forgot_password && @user.save @emails.clear post :reset_password, :id => @user.password_reset_code, :password => “new_password”, :password_confirmation => “not matching password” assert_equal(0, @emails.length) assert_match(“Password mismatch”, flash[:notice]) assert(!User.authenticate(@user.login, “new_password”), “password should not have been reset”) end def test__reset_password__invalid_code__should_show_error post :reset_password, :id => “Invalid Code”, :password => “new_password”, :password_confirmation => “not matching password” assert_match(/invalid password reset code/, flash[:notice]) end

end


Bugfix: in make_password_reset_code(), split(’//’) returns the original string. I replaced with split(//). —Lewis Hoffman

Addition from Dave Smylie

Some email clients will insert line breaks into the reset code due to length. This causes the reset_password method above to fail when it can’t find the password_reset_code in the db. (also could be caused by malicious users probing website for vulnerabilities). I added an exception handler to return a nice error message to the user, rather than having them dumped at an error page:

  1. included above ^

——
I think there’s a security issue in reset_password (all versions above). You need to verify that params[:reset_password_code] isn’t nil. Since most users have a nil value for that, anyone could reset a password and login by submitting an empty value, depending on routing setup.

mods by Nick Plante (nap-at-ubikorp)

It does seem that way, for sure.. Here’s my fixed code, that includes the ability to enter the reset code from a page in case your mail client mangles the request. After you’re logged in by using the password reset code, you’re taken to the change_password screen, which is general purpose and can be used elsewhere. Please post changes/improvements if you got em!

controller mods:


  def reset_password
    password_reset_code = request.post? ? params[:password_reset_code] : params[:id]
    return if password_reset_code.blank?
    if @user = User.find_by_password_reset_code(password_reset_code)
      self.current_user = @user
      redirect_to(:action => 'change_password')
    else
      logger.error "Invalid Password Reset Code entered." 
      flash[:notice] = "Invalid Password Reset Code entered. Please check your Code and try again." 
    end
  end

  def change_password
    return unless request.post?
    if (params[:password] == params[:password_confirmation])
      current_user.password_confirmation = params[:password_confirmation]
      current_user.password = params[:password]
      current_user.reset_password
      flash[:notice] = current_user.save ? "Password reset" : "Password not reset" 
      redirect_back_or_default(:action => 'index')
    else
      flash[:notice] = "Password mismatch" 
    end  
  end


View: reset_password.rhtml


<%= start_form_tag :action => “reset_password” >


<= text_field_tag(:password_reset_code, @params[:password_reset_code], :size=>50 ) %>

<%= submit_tag ‘Reset’ %>

<%= end_form_tag %>

View: change_password.rhtml


Change your password: <%= start_form_tag %> <p><label for="password">Password</label><br/> <%= password_field_tag 'password' %></p>


<%= password_field_tag ‘password_confirmation’ %>

<%= submit_tag ‘Reset password’ %>

<%= end_form_tag %>

Make sure that you protect change_password by putting a line at the top of your account_controller to require :login_required as a before_filter for that particular action.

——
The method above has a naming issue with that of “Change Password” ~Zach Inglis?

Suggestion by Barry Hess.

I think this was implied previously, but the simplest way to resolve the missing params[:id] problem is simply to change the first line in the reset_password action from:

@user = User.find_by_password_reset_code(params[:id])
To:

@user = User.find_by_password_reset_code(params[:id]) if params[:id]
Along with Dave Smylie’s exception addition (above), that should do the trick.

Suggestion by Gama Franco.

Just my two cents on functional tests:

(included above)

Question by Nicolas.

When resetting a password, I noticed that a user is automatically logged in. I would like to offer the same “forgot password” functionality as digg.com does. After clicking on the link a user receives by mail, a reset form is displayed but the user isn’t logged in.

Answer:

change this:


self.current_user = @user #for the next two lines to work current_user.password_confirmation = params[:password_confirmation] current_user.password = params[:password] @user.reset_password if current_user.save

To this:

@user.password_confirmation = params[:password_confirmation] @user.password = params[:password] @user.reset_password if @user.save



Tim Harper (June 11, 2007)

Merged some of the code from the comments in with the recipe, added more test cases. Fixed a bug in the views reset_password.rhtml and forgot_password.rhtml.

Brian Smith (July 12,2007)

I was having a problem with the tests because it kept emailing the results since the forgot_password method triggers the email I had to add @emails.clear after it to make it work. Added above.

Nate (July 25, 2007)

I was also having problems with the test cases, specifically the functional tests for the account controller.

Here’s my users.yml file:


quentin: id: 1 first_name: quentin last_name: jones organization: fooman login: quentin email: quentin@example.com salt: 7e3041ebc2fc05a40c60028e2c4901a81035d3cd crypted_password: 00742970dc9e6319f8019fd54864d3ea740f04b1 # test #crypted_password: "ce2/iFrNtQ8=\n" # quentin, use only if you're using 2-way encryption created_at: <%= 5.days.ago.to_s :db %> activated_at: <%= 5.days.ago.to_s :db %> # only if you're activating new signups

aaron:
id: 2
first_name: aaron
last_name: goober
organization: goober-tron
login: aaron
email: aaron@example.com
salt: 7e3041ebc2fc05a40c60028e2c4901a81035d3cd
crypted_password: 00742970dc9e6319f8019fd54864d3ea740f04b1 # test
activation_code: aaronscode # only if you’re activating new signups
created_at: <%= 1.days.ago.to_s :db %>

</code<


And I simply changed these two test cases to use users(:quentin) instead of users(:aaron):

testreset_passwordvalid_code_but_not_matching_passwordshouldnt_reset
testreset_passwordvalid_code_and_passwordshould_reset

Anyone else have these same problems?

-Nate

Wolfmanjm (August 27, 2007)
If used with Activation, someone can use reset password to login without actually activating their account, the fix is to change


  def forgot_password
    return unless request.post?
    if @user = User.find_by_email(params[:email])

to


def forgot_password return unless request.post? if @user = User.find_for_forgot(params[:email])



and add this to the user.rb model…

def self.find_for_forget(email) find :first, :conditions => ['email = ? and activation_code IS NULL', email] end

-Jim

Jeff – 11/17/07

This piece of code:
def reset_password @user = User.find_by_password_reset_code(params[:id]) raise if @user.nil?
Produces a massive security hole. By submitting a null ID, you reset the password of another user (first one to come up with a null password_reset_code, which should be any user not in the process of resetting password)! And since current_user = @user is called later, you automatically become logged in as that user without even knowing who it was…

May want to add this at the top:
return if params[:id].nil?
Shig – Dec 17, 07

For the tests at the top that call User.authenticate, make sure that the user object that you’re using in your fixtures is one that has been activated. I was using users(:aaron), who isn’t activated in my fixture, so the user didn’t pass User.authenticate.

Also, I’m thinking – shouldn’t these tests where you reset the password and then try to log in be integration tests rather than functional?

To do an integration test of logging in, I tried the following, but it seems that the session hash is not available to tests?


    @user = users(:quentin)
    post "/account/login", :login => @user.login, :password => @user.password
    assert_equal @user.id, session[:user]

Last edited by gundestrup, Mon Feb 02 08:01:57 -0800 2009
Home | Edit | New
Versions: