Wednesday 11 November 2009

Add a Lost Password feature to restful_authentication

This post describes how to add a lost password feature to restful_authentication. It is based off the instructions found here, adding some extra robustness and fixes for updates in the authentication plugin.

app/controllers/users_controller.rb

   
before_filter :login_required, :except => [:new, :create, :activate, :lost_password, :reset_password]
before_filter :get_partial_user_from_session, :only => [:new, :lost_password]
after_filter :save_partial_user_in_session, :only => [:new, :lost_password]
require_role "admin", :for_all_except => [:new, :create, :activate, :edit, :update, :lost_password, :reset_password]

def lost_password
case request.method
when :post
@user.attributes = params['user']
if valid_for_attributes(@user, ["email","email_confirmation"])
user = User.find_by_email(params[:user][:email])
user.create_password_reset_code if user
flash[:notice] = "Reset code sent to #{params[:user][:email]}"
redirect_back_or_default('/')
else
flash[:error] = "Please enter a valid email address"
end
when :get
@user = User.new
@user.confirming_email = true
@user.updating_email = false
end
end

def reset_password
@user = User.find_by_password_reset_code(params[:reset_code]) unless params[:reset_code].nil?
if !@user
flash[:error] = "Reset password token invalid, please contact support."
redirect_to('/')
return
else
@user.crypted_password = nil
end
if request.post?
if @user.update_attributes(:password => params[:user][:password], :password_confirmation => params[:user][:password_confirmation])
#self.current_user = @user
@user.delete_password_reset_code
flash[:notice] = "Password updated successfully for #{@user.email} - You may now log in using your new password."
redirect_back_or_default('/')
else
render :action => :reset_password
end
end
end

# Might be a good addition to AR::Base
def valid_for_attributes( model, attributes )
unless model.valid?
errors = model.errors
our_errors = Array.new
errors.each { |attr,error|
if attributes.include? attr
our_errors << [attr,error]
end
}
errors.clear
our_errors.each { |attr,error| errors.add(attr,error) }
return false unless errors.empty?
end
return true
end


app/models/user.rb

 
validates_uniqueness_of :email, :if => :updating_email?

validates_presence_of :email_confirmation, :if => :confirming_email
validates_confirmation_of :email, :if => :confirming_email

attr_accessible :login, :email, :email_confirmation

attr_accessor :updating_email, :confirming_email # allows us to control when email uniqueness and confirmation are validated (respectively)

def create_password_reset_code
@password_reset = true
self.password_reset_code = Digest::SHA1.hexdigest( Time.now.to_s.split(//).sort_by {rand}.join )
self.save(false)
end
def password_recently_reset?
@password_reset
end
def delete_password_reset_code
self.password_reset_code = nil
self.save(false)
end

protected
def updating_email?
# validate_uniqueness_of email unless specifically set to false
if updating_email == false
return false
else
return true
end
end


app/models/user_mailer.rb

 
def password_reset_notification(user)
setup_email(user)
@subject = 'Link to reset your password'
@body[:url] = "http://www.yourapp.com/reset_password/#{user.password_reset_code}"
end



app/models/user_observer.rb

 
def after_save(user)
UserMailer.deliver_password_reset_notification(user) if user.password_recently_reset?
end


app/views/sessions/new.html.haml

 = link_to " > Lost Password?", lost_password_path


config/routes.rb

   
map.lost_password '/lost_password', :controller => 'users', :action => 'lost_password'
map.reset_password 'reset_password/:reset_code', :controller => 'users', :action => 'reset_password'


users migration file:

       t.string    :password_reset_code,       :limit => 40


app/views/user_mailer/password_reset_notification.erb

Request to reset password received for <%= @user.login %>

Visit this url to choose a new password:

<%= @url %>

(Your password will remain the same if no action is taken)


app/views/users/lost_password.html.haml

%h2 Lost Password

%p
Please enter the email address that you registered with. Once you click on the Send button we will send you an email that allows you to change your password.

= error_messages_for :user, :header_message => "Please review the following errors:", :message => ""

- form_for :user do |f|
%table
%tr
%td{:align => "right"}
Email Address
%td
= f.text_field :email
%tr
%td{:align => "right"}
Retype Email Address
%td
= f.text_field :email_confirmation
%tr
%td
&nbsp;
%td
= submit_tag 'Submit'


app/views/users/reset_password.html.haml

= error_messages_for :user

%h2 Choose a new password

- form_for :user do |f|

%div
New Password:
= f.password_field :password
%div
Confirm Password:
= f.password_field :password_confirmation
%div
= submit_tag 'Change Password'

4 comments:

  1. Thanks, philrosenstein.
    First simple note while trying it:
    Could't "updating_email?" just return "updating_email"?

    Instead of:
    if updating_email == false
    return false
    else
    return true
    end

    Do (not even needed the return keyword):
    updating_email

    Regards.

    ReplyDelete
  2. I notice @password_reset is never set to false. Doesn't this cause the user_observer to mail out a notification on every save after a reset code is created?

    ReplyDelete
  3. That's true!
    The email uniqueness validation is never skipped!

    You are missing those two lines inside users_controller, lost_password method:
    @user = User.new
    @user.updating_email = false


    def lost_password
    case request.method
    when :post
    @user = User.new
    @user.updating_email = false
    @user.attributes = params['user']
    ......


    And inside the user model you are missing an @ before updating_email variable:

    def updating_email?
    # validate_uniqueness_of email unless specifically set to false
    if @updating_email == false
    return false
    else
    return true
    end
    end

    ReplyDelete