Add options to expire confirmation tokens

With this patch, functionality is added to expire the confirmation
tokens that are being sent by email.
For example, if a token is valid for 3 days only, it cannot be used for
confirmation on the 4th day.
This commit is contained in:
Nils Landt 2012-07-09 14:43:12 +02:00
parent 8463c6dce4
commit 87f2fa9767
9 changed files with 120 additions and 11 deletions

View File

@ -16,6 +16,9 @@ group :test do
platforms :mri_18 do platforms :mri_18 do
gem "ruby-debug", ">= 0.10.3" gem "ruby-debug", ">= 0.10.3"
end end
platforms :mri_19 do
gem 'debugger'
end
end end
platforms :jruby do platforms :jruby do

View File

@ -44,6 +44,13 @@ GEM
bson_ext (1.3.1) bson_ext (1.3.1)
builder (3.0.0) builder (3.0.0)
columnize (0.3.5) columnize (0.3.5)
debugger (1.1.4)
columnize (>= 0.3.1)
debugger-linecache (~> 1.1.1)
debugger-ruby_core_source (~> 1.1.3)
debugger-linecache (1.1.2)
debugger-ruby_core_source (>= 1.1.1)
debugger-ruby_core_source (1.1.3)
erubis (2.7.0) erubis (2.7.0)
faraday (0.7.5) faraday (0.7.5)
addressable (~> 2.2.6) addressable (~> 2.2.6)
@ -149,6 +156,7 @@ DEPENDENCIES
activerecord-jdbc-adapter activerecord-jdbc-adapter
activerecord-jdbcsqlite3-adapter activerecord-jdbcsqlite3-adapter
bson_ext (~> 1.3.0) bson_ext (~> 1.3.0)
debugger
devise! devise!
jruby-openssl jruby-openssl
mocha mocha

View File

@ -10,6 +10,7 @@ en:
not_saved: not_saved:
one: "1 error prohibited this %{resource} from being saved:" one: "1 error prohibited this %{resource} from being saved:"
other: "%{count} errors prohibited this %{resource} from being saved:" other: "%{count} errors prohibited this %{resource} from being saved:"
confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one"
devise: devise:
failure: failure:

View File

@ -104,6 +104,10 @@ module Devise
mattr_accessor :allow_unconfirmed_access_for mattr_accessor :allow_unconfirmed_access_for
@@allow_unconfirmed_access_for = 0.days @@allow_unconfirmed_access_for = 0.days
# Time interval the confirmation token is valid. nil = unlimited
mattr_accessor :expire_confirmation_token_after
@@expire_confirmation_token_after = nil
# Defines which key will be used when confirming an account. # Defines which key will be used when confirming an account.
mattr_accessor :confirmation_keys mattr_accessor :confirmation_keys
@@confirmation_keys = [ :email ] @@confirmation_keys = [ :email ]
@ -199,7 +203,7 @@ module Devise
# to provide custom routes. # to provide custom routes.
mattr_accessor :router_name mattr_accessor :router_name
@@router_name = nil @@router_name = nil
# Set the omniauth path prefix so it can be overriden when # Set the omniauth path prefix so it can be overriden when
# Devise is used in a mountable engine # Devise is used in a mountable engine
mattr_accessor :omniauth_path_prefix mattr_accessor :omniauth_path_prefix

View File

@ -19,6 +19,8 @@ module Devise
# db field to be setup (t.reconfirmable in migrations). Until confirmed new email is # db field to be setup (t.reconfirmable in migrations). Until confirmed new email is
# stored in unconfirmed email column, and copied to email column on successful # stored in unconfirmed email column, and copied to email column on successful
# confirmation. # confirmation.
# * +expire_confirmation_token_after+: the time before a sent confirmation token becomes invalid.
# You can use this to force the user to confirm within a set period of time.
# #
# == Examples # == Examples
# #
@ -28,6 +30,8 @@ module Devise
# #
module Confirmable module Confirmable
extend ActiveSupport::Concern extend ActiveSupport::Concern
# TODO: is this a good idea?
include ActionView::Helpers::DateHelper
included do included do
before_create :generate_confirmation_token, :if => :confirmation_required? before_create :generate_confirmation_token, :if => :confirmation_required?
@ -118,7 +122,6 @@ module Devise
end end
headers headers
end end
protected protected
# A callback method used to deliver confirmation # A callback method used to deliver confirmation
@ -156,12 +159,34 @@ module Devise
confirmation_sent_at && confirmation_sent_at.utc >= self.class.allow_unconfirmed_access_for.ago confirmation_sent_at && confirmation_sent_at.utc >= self.class.allow_unconfirmed_access_for.ago
end end
# Checks if the user confirmation happens before the token becomes invalid
#
# Examples:
#
# # expire_confirmation_token_after = 3.days and confirmation_sent_at = 2.days.ago
# confirmation_period_expired? # returns false
#
# # expire_confirmation_token_after = 3.days and confirmation_sent_at = 4.days.ago
# confirmation_period_expired? # returns true
#
# # expire_confirmation_token_after = nil
# confirmation_period_expired? # will always return false
#
def confirmation_period_expired?
self.class.expire_confirmation_token_after && (Time.now > self.confirmation_sent_at + self.class.expire_confirmation_token_after)
end
# Checks whether the record requires any confirmation. # Checks whether the record requires any confirmation.
def pending_any_confirmation def pending_any_confirmation
if !confirmed? || pending_reconfirmation? if !confirmation_period_expired? && (!confirmed? || pending_reconfirmation?)
yield yield
else else
self.errors.add(:email, :already_confirmed) # TODO: cache this call or not?
if confirmation_period_expired?
self.errors.add(:email, :confirmation_period_expired, period: time_ago_in_words(self.class.expire_confirmation_token_after.ago))
else
self.errors.add(:email, :already_confirmed)
end
false false
end end
end end
@ -235,7 +260,7 @@ module Devise
find_or_initialize_with_errors(unconfirmed_required_attributes, unconfirmed_attributes, :not_found) find_or_initialize_with_errors(unconfirmed_required_attributes, unconfirmed_attributes, :not_found)
end end
Devise::Models.config(self, :allow_unconfirmed_access_for, :confirmation_keys, :reconfirmable) Devise::Models.config(self, :allow_unconfirmed_access_for, :confirmation_keys, :reconfirmable, :expire_confirmation_token_after)
end end
end end
end end

View File

@ -92,6 +92,14 @@ Devise.setup do |config|
# the user cannot access the website without confirming his account. # the user cannot access the website without confirming his account.
# config.allow_unconfirmed_access_for = 2.days # config.allow_unconfirmed_access_for = 2.days
# A period that the user is allowed to confirm their account before their token
# becomes invalid. For example, if set to 3.days, the user can confirm their account
# within 3 days after the mail was sent, but on the fourth day their account can't be
# confirmed with the token any more
# Default is nil, meaning there is no restriction on how long a user can take before
# comfirming their account.
# config.expire_confirmation_token_after = 3.days
# If true, requires any email changes to be confirmed (exactly the same way as # If true, requires any email changes to be confirmed (exactly the same way as
# initial account confirmation) to be applied. Requires additional unconfirmed_email # initial account confirmation) to be applied. Requires additional unconfirmed_email
# db field (see migrations). Until confirmed new email is stored in # db field (see migrations). Until confirmed new email is stored in
@ -125,7 +133,7 @@ Devise.setup do |config|
# The time you want to timeout the user session without activity. After this # The time you want to timeout the user session without activity. After this
# time the user will be asked for credentials again. Default is 30 minutes. # time the user will be asked for credentials again. Default is 30 minutes.
# config.timeout_in = 30.minutes # config.timeout_in = 30.minutes
# If true, expires auth token on session timeout. # If true, expires auth token on session timeout.
# config.expire_auth_token_on_timeout = false # config.expire_auth_token_on_timeout = false

View File

@ -16,7 +16,7 @@ class ConfirmationTest < ActionController::IntegrationTest
fill_in 'email', :with => user.email fill_in 'email', :with => user.email
click_button 'Resend confirmation instructions' click_button 'Resend confirmation instructions'
end end
test 'user should be able to request a new confirmation' do test 'user should be able to request a new confirmation' do
resend_confirmation resend_confirmation
@ -50,6 +50,33 @@ class ConfirmationTest < ActionController::IntegrationTest
assert user.reload.confirmed? assert user.reload.confirmed?
end end
test 'user with valid confirmation token should not be able to confirm an account after the token has expired' do
swap Devise, :expire_confirmation_token_after => 3.days do
# TODO: once again, confirmation_sent_at is not being set to the correct date
user = create_user(:confirm => false, :confirmation_sent_at => 4.days.ago)
#user.confirmation_sent_at = 4.days.ago
assert_not user.confirmed?
visit_user_confirmation_with_token(user.confirmation_token)
assert_contain 'Your account was successfully confirmed.'
assert_current_url '/'
assert user.reload.confirmed?
end
end
test 'user with valid confirmation token should be able to confirm an account before the token has expires' do
swap Devise, :expire_confirmation_token_after => 3.days do
# TODO: once again, confirmation_sent_at is not being set to the correct date
user = create_user(:confirm => false, :confirmation_sent_at => 2.days.ago)
assert_not user.confirmed?
visit_user_confirmation_with_token(user.confirmation_token)
assert_have_selector '#error_explanation'
assert_contain /needs to be confirmed within/
assert_not user.reload.confirmed?
end
end
test 'user should be redirected to a custom path after confirmation' do test 'user should be redirected to a custom path after confirmation' do
Devise::ConfirmationsController.any_instance.stubs(:after_confirmation_path_for).returns("/?custom=1") Devise::ConfirmationsController.any_instance.stubs(:after_confirmation_path_for).returns("/?custom=1")
@ -239,7 +266,7 @@ class ConfirmationOnChangeTest < ActionController::IntegrationTest
assert admin.reload.confirmed? assert admin.reload.confirmed?
assert_not admin.reload.pending_reconfirmation? assert_not admin.reload.pending_reconfirmation?
end end
test 'admin with previously valid confirmation token should not be able to confirm email after email changed again' do test 'admin with previously valid confirmation token should not be able to confirm email after email changed again' do
admin = create_admin admin = create_admin
admin.update_attributes(:email => 'first_test@example.com') admin.update_attributes(:email => 'first_test@example.com')
@ -247,11 +274,11 @@ class ConfirmationOnChangeTest < ActionController::IntegrationTest
confirmation_token = admin.confirmation_token confirmation_token = admin.confirmation_token
admin.update_attributes(:email => 'second_test@example.com') admin.update_attributes(:email => 'second_test@example.com')
assert_equal 'second_test@example.com', admin.unconfirmed_email assert_equal 'second_test@example.com', admin.unconfirmed_email
visit_admin_confirmation_with_token(confirmation_token) visit_admin_confirmation_with_token(confirmation_token)
assert_have_selector '#error_explanation' assert_have_selector '#error_explanation'
assert_contain /Confirmation token(.*)invalid/ assert_contain /Confirmation token(.*)invalid/
visit_admin_confirmation_with_token(admin.confirmation_token) visit_admin_confirmation_with_token(admin.confirmation_token)
assert_contain 'Your account was successfully confirmed.' assert_contain 'Your account was successfully confirmed.'
assert_current_url '/admin_area/home' assert_current_url '/admin_area/home'

View File

@ -235,6 +235,38 @@ class ConfirmableTest < ActiveSupport::TestCase
assert_equal "can't be blank", confirm_user.errors[:username].join assert_equal "can't be blank", confirm_user.errors[:username].join
end end
end end
def confirm_user_by_token_with_confirmation_sent_at confirmation_sent_at
user = create_user
user.confirmation_sent_at = confirmation_sent_at
confirmed_user = User.confirm_by_token(user.confirmation_token)
assert_equal confirmed_user, user
user.reload.confirmed?
end
test 'should accept confirmation email token even after 5 years when no expiration is set' do
assert confirm_user_by_token_with_confirmation_sent_at(5.years.ago)
end
test 'should accept confirmation email token after 2 days when expiration is set to 3 days' do
swap Devise, :expire_confirmation_token_after => 3.days do
assert confirm_user_by_token_with_confirmation_sent_at(2.days.ago)
end
end
test 'should not accept confirmation email token after 4 days when expiration is set to 3 days' do
swap Devise, :expire_confirmation_token_after => 3.days do
#assert_not confirm_user_by_token_with_confirmation_sent_at(4.days.ago)
# TODO: confirmation_sent_at is Time.now during confirm_by_token
# TODO: when everything works, use the test line above
user = create_user
user.confirmation_sent_at = 4.days.ago
assert_not user.confirmed?
confirmed_user = User.confirm_by_token(user.confirmation_token)
assert_equal confirmed_user, user
assert_not user.reload.confirmed?
end
end
end end
class ReconfirmableTest < ActiveSupport::TestCase class ReconfirmableTest < ActiveSupport::TestCase

View File

@ -12,7 +12,8 @@ class ActionDispatch::IntegrationTest
:email => options[:email] || 'user@test.com', :email => options[:email] || 'user@test.com',
:password => options[:password] || '12345678', :password => options[:password] || '12345678',
:password_confirmation => options[:password] || '12345678', :password_confirmation => options[:password] || '12345678',
:created_at => Time.now.utc :created_at => Time.now.utc,
:confirmation_sent_at => options[:confirmation_sent_at]
) )
user.confirm! unless options[:confirm] == false user.confirm! unless options[:confirm] == false
user.lock_access! if options[:locked] == true user.lock_access! if options[:locked] == true