Separating perishable token into confirmation and reset_password tokens. Adding confirmation_sent_at attribute.

This commit is contained in:
Carlos A. da Silva
2009-10-18 09:14:52 -02:00
parent bc825d3b23
commit 75e98d3041
19 changed files with 210 additions and 200 deletions

View File

@@ -40,10 +40,12 @@ We're assuming here you want a User model. First of all you have to setup a migr
t.string :email, :null => false
t.string :encrypted_password, :null => false
t.string :password_salt, :null => false
# required for recoverable and/or confirmable
t.string :perishable_token
# required for confirmable
t.string :confirmation_token
t.datetime :confirmation_sent_at
t.datetime :confirmed_at
# required for recoverable
t.string :reset_password_token
Now let's setup a User model adding the devise line to have your authentication working:

View File

@@ -19,7 +19,7 @@ class ConfirmationsController < ApplicationController
# GET /resource/confirmation?perishable_token=abcdef
def show
self.resource = resource_class.confirm!(:perishable_token => params[:perishable_token])
self.resource = resource_class.confirm!(:confirmation_token => params[:confirmation_token])
if resource.errors.empty?
set_flash_message :success, :confirmed

View File

@@ -20,7 +20,7 @@ class PasswordsController < ApplicationController
# GET /resource/password/edit?perishable_token=abcdef
def edit
self.resource = resource_class.new
resource.perishable_token = params[:perishable_token]
resource.reset_password_token = params[:reset_password_token]
end
# PUT /resource/password

View File

@@ -2,4 +2,4 @@ Welcome <%= @resource.email %>!
You can confirm your account through the link below:
<%= link_to 'Confirm my account', confirmation_url(@resource, :perishable_token => @resource.perishable_token) %>
<%= link_to 'Confirm my account', confirmation_url(@resource, :confirmation_token => @resource.confirmation_token) %>

View File

@@ -2,7 +2,7 @@ Hello <%= @resource.email %>!
Someone has requested a link to change your password, and you can do this through the link below.
<%= link_to 'Change my password', edit_password_url(@resource, :perishable_token => @resource.perishable_token) %>
<%= link_to 'Change my password', edit_password_url(@resource, :reset_password_token => @resource.reset_password_token) %>
If you didn't request this, please ignore this email.
Your password won't change until you access the link above and create a new one.

View File

@@ -2,7 +2,7 @@
<% form_for resource_name, :url => password_path(resource_name), :html => { :method => :put } do |f| %>
<%= f.error_messages %>
<%= f.hidden_field :perishable_token %>
<%= f.hidden_field :reset_password_token %>
<p><%= f.label :password %></p>
<p><%= f.password_field :password %></p>

View File

@@ -24,7 +24,6 @@ module Devise
def devise(*options)
options = [:confirmable, :recoverable, :validatable] if options.include?(:all)
options |= [:authenticable]
options |= [:perishable] if options.include?(:confirmable) || options.include?(:recoverable)
options.each do |m|
devise_modules << m.to_sym

View File

@@ -78,6 +78,16 @@ module Devise
authenticable = self.find_by_email(attributes[:email])
authenticable if authenticable.try(:valid_password?, attributes[:password])
end
# Attempt to find a user by it's email. If not user is found, returns a
# new user with an email not found error.
def find_or_initialize_with_error_by_email(email)
perishable = find_or_initialize_by_email(email)
if perishable.new_record?
perishable.errors.add(:email, :not_found, :default => 'not found')
end
perishable
end
end
end
end

View File

@@ -30,7 +30,7 @@ module Devise
# is already confirmed, add en error to email field
def confirm!
unless_confirmed do
clear_perishable_token
clear_confirmation_token
update_attribute(:confirmed_at, Time.now)
end
end
@@ -58,15 +58,15 @@ module Devise
private
# Remove confirmation date from the user, ensuring after a user update it's
# email, it won't be able to sign in without confirming it.
# Remove confirmation date from the user, ensuring after a user update
# it's email, it won't be able to sign in without confirming it.
def reset_confirmation
reset_perishable_token
generate_confirmation_token
self.confirmed_at = nil
end
# Checks whether the record is confirmed or not, yielding to the block if
# it's already confirmed, otherwise adds an error to email.
# Checks whether the record is confirmed or not, yielding to the block
# if it's already confirmed, otherwise adds an error to email.
def unless_confirmed
unless confirmed?
yield
@@ -76,6 +76,24 @@ module Devise
end
end
# Generates a new random token for confirmation, and stores the time
# this token is being generated
def generate_confirmation_token
self.confirmation_token = friendly_token
self.confirmation_sent_at = Time.now.utc
end
# Resets the confirmation token with and save the record without
# validating.
# def generate_confirmation_token!
# generate_confirmation_token && save(false)
# end
# Removes confirmation token
def clear_confirmation_token
self.confirmation_token = nil
end
module ClassMethods
# Attempt to find a user by it's email. If a record is found, send new
@@ -93,10 +111,21 @@ module Devise
# If the user is already confirmed, create an error for the user
# Options must have the perishable_token
def confirm!(attributes={})
confirmable = find_or_initialize_with_error_by_perishable_token(attributes[:perishable_token])
confirmable = find_or_initialize_with_error_by_confirmation_token(attributes[:confirmation_token])
confirmable.confirm! unless confirmable.new_record?
confirmable
end
# Attempt to find a user by and incoming confirmation_token. If no user
# is found, initialize a new one and adds an :invalid error to
# confirmation_token.
def find_or_initialize_with_error_by_confirmation_token(confirmation_token)
confirmable = find_or_initialize_by_confirmation_token(confirmation_token)
if confirmable.new_record?
confirmable.errors.add(:confirmation_token, :invalid)
end
confirmable
end
end
end
end

View File

@@ -1,66 +0,0 @@
module Devise
module Models
# Perishable is mainly responsible for recreating tokens used inside
# Recoverable and Confirmable modules.
# It also has some utility class methods to find or initialize records based
# on perishable_token or email, and adding an error to specific fields if no
# record is found.
#
# Examples:
#
# # create a new token and save the record, without validating
# User.find(1).reset_perishable_token!
#
# # only create a new token, without saving the record
# user = User.find(1)
# user.reset_perishable_token
module Perishable
def self.included(base)
base.class_eval do
extend ClassMethods
end
end
# Generates a new random token for confirmation, based on actual Time and salt
def reset_perishable_token
self.perishable_token = friendly_token
end
# Resets the perishable token with and save the record without validating
def reset_perishable_token!
reset_perishable_token && save(false)
end
# Removes perishable token
def clear_perishable_token
self.perishable_token = nil
end
module ClassMethods
# Attempt to find a user by and incoming perishable_token. If no user is
# found, initialize a new one and adds an :invalid error to perishable_token
def find_or_initialize_with_error_by_perishable_token(perishable_token)
perishable = find_or_initialize_by_perishable_token(perishable_token)
if perishable.new_record?
perishable.errors.add(:perishable_token, :invalid, :default => 'invalid confirmation')
end
perishable
end
# Attempt to find a user by it's email. If not user is found, returns a
# new user with an email not found error.
def find_or_initialize_with_error_by_email(email)
perishable = find_or_initialize_by_email(email)
if perishable.new_record?
perishable.errors.add(:email, :not_found, :default => 'not found')
end
perishable
end
end
end
end
end

View File

@@ -28,18 +28,47 @@ module Devise
# the passwords are valid and the record was saved, false otherwise.
def reset_password!(new_password, new_password_confirmation)
reset_password(new_password, new_password_confirmation)
clear_perishable_token
clear_reset_password_token
save
end
# Resets perishable token and send reset password instructions by email
def send_reset_password_instructions
reset_perishable_token!
generate_reset_password_token!
::Notifier.deliver_reset_password_instructions(self)
end
protected
# Generates a new random token for reset password
def generate_reset_password_token
self.reset_password_token = friendly_token
end
# Resets the reset password token with and save the record without
# validating
def generate_reset_password_token!
generate_reset_password_token && save(false)
end
# Removes reset_password token
def clear_reset_password_token
self.reset_password_token = nil
end
module ClassMethods
# Attempt to find a user by and incoming reset_password_token. If no
# user is found, initialize a new one and adds an :invalid error to
# reset_password_token
def find_or_initialize_with_error_by_reset_password_token(reset_password_token)
recoverable = find_or_initialize_by_reset_password_token(reset_password_token)
if recoverable.new_record?
recoverable.errors.add(:reset_password_token, :invalid)
end
recoverable
end
# Attempt to find a user by it's email. If a record is found, send new
# password instructions to it. If not user is found, returns a new user
# with an email not found error.
@@ -50,13 +79,13 @@ module Devise
recoverable
end
# Attempt to find a user by it's perishable_token to reset it's password.
# If a user is found, reset it's password and automatically try saving the
# record. If not user is found, returns a new user containing an error
# in perishable_token attribute.
# Attributes must contain perishable_token, password and confirmation
# Attempt to find a user by it's reset_password_token to reset it's
# password. If a user is found, reset it's password and automatically
# try saving the record. If not user is found, returns a new user
# containing an error in perishable_token attribute.
# Attributes must contain reset_password_token, password and confirmation
def reset_password!(attributes={})
recoverable = find_or_initialize_with_error_by_perishable_token(attributes[:perishable_token])
recoverable = find_or_initialize_with_error_by_reset_password_token(attributes[:reset_password_token])
recoverable.reset_password!(attributes[:password], attributes[:password_confirmation]) unless recoverable.new_record?
recoverable
end

View File

@@ -17,20 +17,20 @@ class ConfirmationTest < ActionController::IntegrationTest
assert_equal 1, ActionMailer::Base.deliveries.size
end
test 'user with invalid perishable token should not be able to confirm an account' do
visit user_confirmation_path(:perishable_token => 'invalid_perishable')
test 'user with invalid confirmation token should not be able to confirm an account' do
visit user_confirmation_path(:confirmation_token => 'invalid_confirmation')
assert_response :success
assert_template 'confirmations/new'
assert_have_selector '#errorExplanation'
assert_contain 'invalid confirmation'
assert_contain 'Confirmation token is invalid'
end
test 'user with valid perishable token should be able to confirm an account' do
test 'user with valid confirmation token should be able to confirm an account' do
user = create_user(:confirm => false)
assert_not user.confirmed?
visit user_confirmation_path(:perishable_token => user.perishable_token)
visit user_confirmation_path(:confirmation_token => user.confirmation_token)
assert_template 'sessions/new'
assert_contain 'Your account was successfully confirmed!'
@@ -40,7 +40,7 @@ class ConfirmationTest < ActionController::IntegrationTest
test 'user already confirmed user should not be able to confirm the account again' do
user = create_user
visit user_confirmation_path(:perishable_token => user.perishable_token)
visit user_confirmation_path(:confirmation_token => user.confirmation_token)
assert_template 'confirmations/new'
assert_have_selector '#errorExplanation'

View File

@@ -20,7 +20,7 @@ class PasswordTest < ActionController::IntegrationTest
end
def reset_password(options={}, &block)
visit edit_user_password_path(:perishable_token => options[:perishable_token])
visit edit_user_password_path(:reset_password_token => options[:reset_password_token])
assert_response :success
assert_template 'passwords/edit'
@@ -69,21 +69,21 @@ class PasswordTest < ActionController::IntegrationTest
assert warden.authenticated?(:user)
end
test 'not authenticated user with invalid perishable token should not be able to change his password' do
test 'not authenticated user with invalid reset password token should not be able to change his password' do
user = create_user
reset_password :perishable_token => 'invalid_perishable'
reset_password :reset_password_token => 'invalid_reset_password'
assert_response :success
assert_template 'passwords/edit'
assert_have_selector '#errorExplanation'
assert_contain 'invalid confirmation'
assert_contain 'Reset password token is invalid'
assert_not user.reload.valid_password?('987654321')
end
test 'not authenticated user with valid perisable token but invalid password should not be able to change his password' do
test 'not authenticated user with valid reset password token but invalid password should not be able to change his password' do
user = create_user
request_forgot_password
reset_password :perishable_token => user.reload.perishable_token do
reset_password :reset_password_token => user.reload.reset_password_token do
fill_in 'Password confirmation', :with => 'other_password'
end
@@ -97,7 +97,7 @@ class PasswordTest < ActionController::IntegrationTest
test 'not authenticated user with valid data should be able to change his password' do
user = create_user
request_forgot_password
reset_password :perishable_token => user.reload.perishable_token
reset_password :reset_password_token => user.reload.reset_password_token
assert_template 'sessions/new'
assert_contain 'Your password was changed successfully.'

View File

@@ -36,7 +36,7 @@ class ConfirmationInstructionsTest < ActionMailer::TestCase
test 'body should have link to confirm the account' do
host = ActionMailer::Base.default_url_options[:host]
confirmation_url_regexp = %r{<a href=\"http://#{host}/users/confirmation\?perishable_token=#{@user.perishable_token}">}
confirmation_url_regexp = %r{<a href=\"http://#{host}/users/confirmation\?confirmation_token=#{@user.confirmation_token}">}
assert_match confirmation_url_regexp, @mail.body
end
end

View File

@@ -7,7 +7,8 @@ class ResetPasswordInstructionsTest < ActionMailer::TestCase
I18n.backend.store_translations :en, {:devise => { :notifier => { :reset_password_instructions => 'Reset instructions' } }}
Notifier.sender = 'test@example.com'
@user = create_user
@mail = Notifier.deliver_reset_password_instructions(@user)
@user.send_reset_password_instructions
@mail = ActionMailer::Base.deliveries.last
end
test 'email sent after reseting the user password' do
@@ -36,7 +37,7 @@ class ResetPasswordInstructionsTest < ActionMailer::TestCase
test 'body should have link to confirm the account' do
host = ActionMailer::Base.default_url_options[:host]
confirmation_url_regexp = %r{<a href=\"http://#{host}/users/password/edit\?perishable_token=#{@user.perishable_token}">}
confirmation_url_regexp = %r{<a href=\"http://#{host}/users/password/edit\?reset_password_token=#{@user.reset_password_token}">}
assert_match confirmation_url_regexp, @mail.body
end
end

View File

@@ -10,6 +10,37 @@ class ConfirmableTest < ActiveSupport::TestCase
assert_not field_accessible?(:confirmed_at)
end
test 'should not have confirmation token accessible' do
assert_not field_accessible?(:confirmation_token)
end
test 'should not have confirmation sent at accessible' do
assert_not field_accessible?(:confirmation_sent_at)
end
test 'should generate confirmation token after creating a record' do
assert_nil new_user.confirmation_token
assert_not_nil create_user.confirmation_token
end
test 'should regenerate confirmation token each time' do
user = create_user
3.times do
token = user.confirmation_token
user.reset_confirmation!
assert_not_equal token, user.confirmation_token
end
end
test 'should never generate the same confirmation token for different users' do
confirmation_tokens = []
10.times do
token = create_user.confirmation_token
assert !confirmation_tokens.include?(token)
confirmation_tokens << token
end
end
test 'should confirm a user updating confirmed at' do
user = create_user
assert_nil user.confirmed_at
@@ -17,11 +48,11 @@ class ConfirmableTest < ActiveSupport::TestCase
assert_not_nil user.confirmed_at
end
test 'should clear perishable token while confirming a user' do
test 'should clear confirmation token while confirming a user' do
user = create_user
assert_present user.perishable_token
assert_present user.confirmation_token
user.confirm!
assert_nil user.perishable_token
assert_nil user.confirmation_token
end
test 'should verify whether a user is confirmed or not' do
@@ -43,27 +74,27 @@ class ConfirmableTest < ActiveSupport::TestCase
test 'should find and confirm an user automatically' do
user = create_user
confirmed_user = User.confirm!(:perishable_token => user.perishable_token)
confirmed_user = User.confirm!(:confirmation_token => user.confirmation_token)
assert_not_nil confirmed_user
assert_equal confirmed_user, user
assert user.reload.confirmed?
end
test 'should return a new user with errors if no user exists while trying to confirm' do
confirmed_user = User.confirm!(:perishable_token => 'invalid_perishable_token')
confirmed_user = User.confirm!(:confirmation_token => 'invalid_confirmation_token')
assert confirmed_user.new_record?
end
test 'should return errors for a new user when trying to confirm' do
confirmed_user = User.confirm!(:perishable_token => 'invalid_perishable_token')
assert_not_nil confirmed_user.errors[:perishable_token]
assert_equal "invalid confirmation", confirmed_user.errors[:perishable_token]
confirmed_user = User.confirm!(:confirmation_token => 'invalid_confirmation_token')
assert_not_nil confirmed_user.errors[:confirmation_token]
assert_equal 'is invalid', confirmed_user.errors[:confirmation_token]
end
test 'should generate errors for a user email if user is already confirmed' do
user = create_user
user.confirm!
confirmed_user = User.confirm!(:perishable_token => user.perishable_token)
confirmed_user = User.confirm!(:confirmation_token => user.confirmation_token)
assert confirmed_user.confirmed?
assert confirmed_user.errors[:email]
end
@@ -109,11 +140,11 @@ class ConfirmableTest < ActiveSupport::TestCase
assert_equal 'not found', confirmation_user.errors[:email]
end
test 'should reset perishable token before send the confirmation instructions email' do
test 'should reset confirmation token before send the confirmation instructions email' do
user = create_user
token = user.perishable_token
token = user.confirmation_token
confirmation_user = User.send_confirmation_instructions(:email => user.email)
assert_not_equal token, user.reload.perishable_token
assert_not_equal token, user.reload.confirmation_token
end
test 'should reset confirmation status when sending the confirmation instructions' do
@@ -156,12 +187,12 @@ class ConfirmableTest < ActiveSupport::TestCase
assert_not user.reload.confirmed?
end
test 'should reset perishable token when updating email' do
test 'should reset confirmation token when updating email' do
user = create_user
token = user.perishable_token
token = user.confirmation_token
user.email = 'new_test@example.com'
user.save!
assert_not_equal token, user.reload.perishable_token
assert_not_equal token, user.reload.confirmation_token
end
test 'should not be able to send instructions if the user is already confirmed' do

View File

@@ -1,58 +0,0 @@
require 'test/test_helper'
class PerishableTest < ActiveSupport::TestCase
test 'should not have perishable token accessible' do
assert_not field_accessible?(:perishable_token)
end
test 'should generate perishable token after creating a record' do
assert_nil new_user.perishable_token
assert_not_nil create_user.perishable_token
end
test 'should reset perisable token each time' do
user = new_user
3.times do
token = user.perishable_token
user.reset_perishable_token
assert_not_equal token, user.perishable_token
end
end
test 'should reset perishable token and save the record' do
user = new_user
assert_nil user.perishable_token
user.reset_perishable_token!
assert_not_nil user.perishable_token
assert !user.new_record?
end
test 'should save without validations when reseting perisable token' do
user = new_user
user.expects(:valid?).never
user.reset_perishable_token!
end
test 'should never generate the same perishable token for different users' do
perishable_tokens = []
10.times do
token = create_user.perishable_token
assert !perishable_tokens.include?(token)
perishable_tokens << token
end
end
test 'should not change perishable token when updating' do
user = create_user
token = user.perishable_token
user.expects(:perishable_token=).never
user.save!
assert_equal token, user.perishable_token
end
test 'should generate a sha1 hash for perishable token' do
ActiveSupport::SecureRandom.expects(:base64).with(15).twice.returns('perishable token')
assert_equal 'perishable token', create_user.perishable_token
end
end

View File

@@ -6,6 +6,35 @@ class RecoverableTest < ActiveSupport::TestCase
setup_mailer
end
test 'should not have reset password token accessible' do
assert_not field_accessible?(:reset_password_token)
end
test 'should not generate reset password token after creating a record' do
assert_nil new_user.reset_password_token
assert_nil create_user.reset_password_token
end
test 'should regenerate reset password token each time' do
user = create_user
3.times do
token = user.reset_password_token
user.send_reset_password_instructions
assert_not_equal token, user.reset_password_token
end
end
test 'should never generate the same reset password token for different users' do
reset_password_tokens = []
10.times do
user = create_user
user.send_reset_password_instructions
token = user.reset_password_token
assert !reset_password_tokens.include?(token)
reset_password_tokens << token
end
end
test 'should reset password and password confirmation from params' do
user = create_user
user.reset_password('123456789', '987654321')
@@ -17,11 +46,13 @@ class RecoverableTest < ActiveSupport::TestCase
assert create_user.reset_password!('123456789', '123456789')
end
test 'should clear perishable token while reseting the password' do
test 'should clear reset password token while reseting the password' do
user = create_user
assert_present user.perishable_token
assert_nil user.reset_password_token
user.send_reset_password_instructions
assert_present user.reset_password_token
user.reset_password!('123456789', '123456789')
assert_nil user.perishable_token
assert_nil user.reset_password_token
end
test 'should not reset password with invalid data' do
@@ -30,12 +61,12 @@ class RecoverableTest < ActiveSupport::TestCase
assert_not user.reset_password!('123456789', '987654321')
end
test 'should reset perishable token and send instructions by email' do
test 'should reset reset password token and send instructions by email' do
user = create_user
assert_email_sent do
token = user.perishable_token
token = user.reset_password_token
user.send_reset_password_instructions
assert_not_equal token, user.perishable_token
assert_not_equal token, user.reset_password_token
end
end
@@ -58,11 +89,11 @@ class RecoverableTest < ActiveSupport::TestCase
assert_equal 'not found', reset_password_user.errors[:email]
end
test 'should reset perishable token before send the reset instructions email' do
test 'should reset reset password token before send the reset instructions email' do
user = create_user
token = user.perishable_token
token = user.reset_password_token
reset_password_user = User.send_reset_password_instructions(:email => user.email)
assert_not_equal token, user.reload.perishable_token
assert_not_equal token, user.reload.reset_password_token
end
test 'should send email instructions to the user reset it\'s password' do
@@ -72,30 +103,30 @@ class RecoverableTest < ActiveSupport::TestCase
end
end
test 'should find a user to reset it\'s password based on perishable_token' do
test 'should find a user to reset it\'s password based on reset_password_token' do
user = create_user
reset_password_user = User.reset_password!(:perishable_token => user.perishable_token)
reset_password_user = User.reset_password!(:reset_password_token => user.reset_password_token)
assert_not_nil reset_password_user
assert_equal reset_password_user, user
end
test 'should return a new user when trying to reset it\'s password if no perishable_token is found' do
reset_password_user = User.reset_password!(:perishable_token => 'invalid_token')
test 'should return a new user when trying to reset it\'s password if no reset_password_token is found' do
reset_password_user = User.reset_password!(:reset_password_token => 'invalid_token')
assert_not_nil reset_password_user
assert reset_password_user.new_record?
end
test 'should add error to new user email if no perishable token was found' do
reset_password_user = User.reset_password!(:perishable_token => "invalid_token")
assert reset_password_user.errors[:perishable_token]
assert_equal 'invalid confirmation', reset_password_user.errors[:perishable_token]
test 'should add error to new user email if no reset password token was found' do
reset_password_user = User.reset_password!(:reset_password_token => "invalid_token")
assert reset_password_user.errors[:reset_password_token]
assert_equal 'is invalid', reset_password_user.errors[:reset_password_token]
end
test 'should reset successfully user password given the new password and confirmation' do
user = create_user
old_password = user.password
reset_password_user = User.reset_password!(
:perishable_token => user.perishable_token,
:reset_password_token => user.reset_password_token,
:password => 'new_password',
:password_confirmation => 'new_password'
)

View File

@@ -16,11 +16,13 @@ ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :database => ":me
ActiveRecord::Schema.define(:version => 1) do
[:users, :admins].each do |table|
create_table table do |t|
t.string :email, :null => false
t.string :encrypted_password, :null => false
t.string :password_salt, :null => false
t.string :perishable_token
t.string :email, :null => false
t.string :encrypted_password, :null => false
t.string :password_salt, :null => false
t.string :confirmation_token
t.datetime :confirmation_sent_at
t.datetime :confirmed_at
t.string :reset_password_token
end
end
end