Every repository with this icon (
Every repository with this icon (
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_passworddef 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
- First update the password_reset_code before setting the
- reset_password flag to avoid duplicate email notifications.
update_attributes(:password_reset_code => nil)
@reset_password = true
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”)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
- Make sure that the user can login with this new password
assert(User.authenticate(@user.login, “new_password”), “password should have been reset”)
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:
- 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 signupsaaron:
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]






