1
0
Fork 0
mirror of https://github.com/heartcombo/devise.git synced 2022-11-09 12:18:31 -05:00

Use HMAC on tokens stored in the DB

This commit is contained in:
José Valim 2013-08-05 18:56:07 +02:00
parent 32648027e2
commit 143794d701
19 changed files with 177 additions and 192 deletions

View file

@ -1,15 +1,18 @@
class Devise::Mailer < Devise.parent_mailer.constantize
include Devise::Mailers::Helpers
def confirmation_instructions(record, opts={})
def confirmation_instructions(record, token, opts={})
@token = token
devise_mail(record, :confirmation_instructions, opts)
end
def reset_password_instructions(record, opts={})
def reset_password_instructions(record, token, opts={})
@token = token
devise_mail(record, :reset_password_instructions, opts)
end
def unlock_instructions(record, opts={})
def unlock_instructions(record, token, opts={})
@token = token
devise_mail(record, :unlock_instructions, opts)
end
end

View file

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

View file

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

View file

@ -4,4 +4,4 @@
<p>Click the link below to unlock your account:</p>
<p><%= link_to 'Unlock my account', unlock_url(@resource, :unlock_token => @resource.unlock_token) %></p>
<p><%= link_to 'Unlock my account', unlock_url(@resource, :unlock_token => @token) %></p>

View file

@ -14,6 +14,7 @@ module Devise
autoload :ParameterSanitizer, 'devise/parameter_sanitizer'
autoload :TestHelpers, 'devise/test_helpers'
autoload :TimeInflector, 'devise/time_inflector'
autoload :TokenGenerator, 'devise/token_generator'
module Controllers
autoload :Helpers, 'devise/controllers/helpers'
@ -49,6 +50,10 @@ module Devise
mattr_accessor :secret_key
@@secret_key = nil
# Secret key used by the key generator
mattr_accessor :token_generator
@@token_generator = nil
# Custom domain or key for cookies. Not set by default
mattr_accessor :rememberable_options
@@rememberable_options = {}

View file

@ -83,11 +83,8 @@ module Devise
devise_modules_hook! do
include Devise::Models::Authenticatable
selected_modules.each do |m|
if m == :encryptable && !(defined?(Devise::Models::Encryptable))
warn "[DEVISE] You're trying to include :encryptable in your model but it is not bundled with the Devise gem anymore. Please add `devise-encryptable` to your Gemfile to proceed.\n"
end
selected_modules.each do |m|
mod = Devise::Models.const_get(m.to_s.classify)
if mod.const_defined?("ClassMethods")

View file

@ -144,20 +144,20 @@ module Devise
#
# protected
#
# def send_devise_notification(notification, opts = {})
# # if the record is new or changed then delay the
# def send_devise_notification(notification, *args)
# # If the record is new or changed then delay the
# # delivery until the after_commit callback otherwise
# # send now because after_commit will not be called.
# if new_record? || changed?
# pending_notifications << [notification, opts]
# pending_notifications << [notification, args]
# else
# devise_mailer.send(notification, self, opts).deliver
# devise_mailer.send(notification, self, *args).deliver
# end
# end
#
# def send_pending_notifications
# pending_notifications.each do |n, opts|
# devise_mailer.send(n, self, opts).deliver
# pending_notifications.each do |notification, args|
# devise_mailer.send(notification, self, *args).deliver
# end
#
# # Empty the pending notifications array because the
@ -171,8 +171,8 @@ module Devise
# end
# end
#
def send_devise_notification(notification, opts={})
devise_mailer.send(notification, self, opts).deliver
def send_devise_notification(notification, *args)
devise_mailer.send(notification, self, *args).deliver
end
def downcase_keys
@ -279,14 +279,6 @@ module Devise
def devise_parameter_filter
@devise_parameter_filter ||= Devise::ParameterFilter.new(case_insensitive_keys, strip_whitespace_keys)
end
# Generate a token by looping and ensuring does not already exist.
def generate_token(column)
loop do
token = Devise.friendly_token
break token unless to_adapter.find_first({ column => token })
end
end
end
end
end

View file

@ -40,9 +40,10 @@ module Devise
end
def initialize(*args, &block)
@bypass_postpone = false
@bypass_confirmation_postpone = false
@reconfirmation_required = false
@skip_confirmation_notification = false
@raw_confirmation_token = nil
super
end
@ -93,10 +94,12 @@ module Devise
# Send confirmation instructions by email
def send_confirmation_instructions
ensure_confirmation_token!
unless @raw_confirmation_token
generate_confirmation_token!
end
opts = pending_reconfirmation? ? { :to => unconfirmed_email } : { }
send_devise_notification(:confirmation_instructions, opts)
send_devise_notification(:confirmation_instructions, @raw_confirmation_token, opts)
end
def send_reconfirmation_instructions
@ -109,17 +112,11 @@ module Devise
# Resend confirmation token.
# Regenerates the token if the period is expired.
def resend_confirmation_token
def resend_confirmation_instructions
pending_any_confirmation do
regenerate_confirmation_token! if confirmation_period_expired?
send_confirmation_instructions
end
end
# Generate a confirmation token unless already exists and save the record.
def ensure_confirmation_token!
generate_confirmation_token! if should_generate_confirmation_token?
end
# Overwrites active_for_authentication? for confirmation
# by verifying whether a user is active to sign in or not. If the user
@ -149,19 +146,16 @@ module Devise
# If you don't want reconfirmation to be sent, neither a code
# to be generated, call skip_reconfirmation!
def skip_reconfirmation!
@bypass_postpone = true
@bypass_confirmation_postpone = true
end
protected
def should_generate_confirmation_token?
confirmation_token.nil? || confirmation_period_expired?
end
# A callback method used to deliver confirmation
# instructions on creation. This can be overriden
# in models to map to a nice sign up e-mail.
def send_on_create_confirmation_instructions
send_devise_notification(:confirmation_instructions)
send_confirmation_instructions
end
# Callback to overwrite if confirmation is required or not.
@ -221,10 +215,12 @@ module Devise
end
end
# Generates a new random token for confirmation, and stores the time
# this token is being generated
# Generates a new random token for confirmation, and stores
# the time this token is being generated
def generate_confirmation_token
self.confirmation_token = self.class.confirmation_token
raw, enc = Devise.token_generator.generate(self.class, :confirmation_token)
@raw_confirmation_token = raw
self.confirmation_token = enc
self.confirmation_sent_at = Time.now.utc
end
@ -232,15 +228,6 @@ module Devise
generate_confirmation_token && save(:validate => false)
end
# Regenerates a new token.
def regenerate_confirmation_token
generate_confirmation_token
end
def regenerate_confirmation_token!
regenerate_confirmation_token && save(:validate => false)
end
def after_password_reset
super
confirm! unless confirmed?
@ -250,12 +237,12 @@ module Devise
@reconfirmation_required = true
self.unconfirmed_email = self.email
self.email = self.email_was
regenerate_confirmation_token
generate_confirmation_token
end
def postpone_email_change?
postpone = self.class.reconfirmable && email_changed? && !@bypass_postpone && !self.email.blank?
@bypass_postpone = false
postpone = self.class.reconfirmable && email_changed? && !@bypass_confirmation_postpone && !self.email.blank?
@bypass_confirmation_postpone = false
postpone
end
@ -280,7 +267,7 @@ module Devise
unless confirmable.try(:persisted?)
confirmable = find_or_initialize_with_errors(confirmation_keys, attributes, :not_found)
end
confirmable.resend_confirmation_token if confirmable.persisted?
confirmable.resend_confirmation_instructions if confirmable.persisted?
confirmable
end
@ -289,16 +276,16 @@ module Devise
# If the user is already confirmed, create an error for the user
# Options must have the confirmation_token
def confirm_by_token(confirmation_token)
original_token = confirmation_token
confirmation_token = Devise.token_generator.digest(self, :confirmation_token, confirmation_token)
confirmable = find_or_initialize_with_error_by(:confirmation_token, confirmation_token)
unless confirmable.persisted?
confirmable = find_or_initialize_with_error_by(:confirmation_token, original_token)
end
confirmable.confirm! if confirmable.persisted?
confirmable
end
# Generate a token checking if one does not already exist in the database.
def confirmation_token
generate_token(:confirmation_token)
end
# Find a record for confirmation by unconfirmed email field
def find_by_unconfirmed_email_with_errors(attributes = {})
unconfirmed_required_attributes = confirmation_keys.map { |k| k == :email ? :unconfirmed_email : k }

View file

@ -38,7 +38,6 @@ module Devise
self.locked_at = Time.now.utc
if unlock_strategy_enabled?(:email)
generate_unlock_token!
send_unlock_instructions
else
save(:validate => false)
@ -60,11 +59,15 @@ module Devise
# Send unlock instructions by email
def send_unlock_instructions
send_devise_notification(:unlock_instructions)
raw, enc = Devise.token_generator.generate(self.class, :unlock_token)
self.unlock_token = enc
self.save(:validate => false)
send_devise_notification(:unlock_instructions, raw, {})
raw
end
# Resend the unlock instructions if the user is locked.
def resend_unlock_token
def resend_unlock_instructions
if_access_locked { send_unlock_instructions }
end
@ -122,15 +125,6 @@ module Devise
self.failed_attempts > self.class.maximum_attempts
end
# Generates unlock token
def generate_unlock_token
self.unlock_token = self.class.unlock_token
end
def generate_unlock_token!
generate_unlock_token && save(:validate => false)
end
# Tells if the lock is expired if :time unlock strategy is active
def lock_expired?
if unlock_strategy_enabled?(:time)
@ -158,7 +152,7 @@ module Devise
# Options must contain the user's unlock keys
def send_unlock_instructions(attributes={})
lockable = find_or_initialize_with_errors(unlock_keys, attributes, :not_found)
lockable.resend_unlock_token if lockable.persisted?
lockable.resend_unlock_instructions if lockable.persisted?
lockable
end
@ -167,7 +161,14 @@ module Devise
# If the user is not locked, creates an error for the user
# Options must have the unlock_token
def unlock_access_by_token(unlock_token)
original_token = unlock_token
unlock_token = Devise.token_generator.digest(self, :unlock_token, unlock_token)
lockable = find_or_initialize_with_error_by(:unlock_token, unlock_token)
unless lockable.persisted?
lockable = find_or_initialize_with_error_by(:unlock_token, original_token)
end
lockable.unlock_access! if lockable.persisted?
lockable
end
@ -182,10 +183,6 @@ module Devise
self.lock_strategy == strategy
end
def unlock_token
Devise.friendly_token
end
Devise::Models.config(self, :maximum_attempts, :lock_strategy, :unlock_strategy, :unlock_in, :unlock_keys)
end
end

View file

@ -42,17 +42,19 @@ module Devise
save
end
# Resets reset password token and send reset password instructions by email
# Resets reset password token and send reset password instructions by email.
# Returns the token sent in the e-mail.
def send_reset_password_instructions
ensure_reset_password_token!
send_devise_notification(:reset_password_instructions)
raw, enc = Devise.token_generator.generate(self.class, :reset_password_token)
self.reset_password_token = enc
self.reset_password_sent_at = Time.now.utc
self.save(:validate => false)
send_devise_notification(:reset_password_instructions, raw, {})
raw
end
# Generate reset password token unless already exists and save the record.
def ensure_reset_password_token!
generate_reset_password_token! if should_generate_reset_token?
end
# Checks if the reset password token sent is within the limit time.
# We do this by calculating if the difference between today and the
# sending date does not exceed the confirm in time configured.
@ -79,23 +81,6 @@ module Devise
protected
def should_generate_reset_token?
reset_password_token.nil? || !reset_password_period_valid?
end
# Generates a new random token for reset password
def generate_reset_password_token
self.reset_password_token = self.class.reset_password_token
self.reset_password_sent_at = Time.now.utc
self.reset_password_token
end
# Resets the reset password token with and save the record without
# validating
def generate_reset_password_token!
generate_reset_password_token && save(:validate => false)
end
# Removes reset_password token
def clear_reset_password_token
self.reset_password_token = nil
@ -127,7 +112,14 @@ module Devise
# containing an error in reset_password_token attribute.
# Attributes must contain reset_password_token, password and confirmation
def reset_password_by_token(attributes={})
recoverable = find_or_initialize_with_error_by(:reset_password_token, attributes[:reset_password_token])
original_token = attributes[:reset_password_token]
reset_password_token = Devise.token_generator.digest(self, :reset_password_token, original_token)
recoverable = find_or_initialize_with_error_by(:reset_password_token, reset_password_token)
unless recoverable.persisted?
recoverable = find_or_initialize_with_error_by(:reset_password_token, original_token)
end
if recoverable.persisted?
if recoverable.reset_password_period_valid?
recoverable.reset_password!(attributes[:password], attributes[:password_confirmation])

View file

@ -79,7 +79,10 @@ module Devise
# Generate a token checking if one does not already exist in the database.
def authentication_token
generate_token(:authentication_token)
loop do
token = Devise.friendly_token
break token unless to_adapter.find_first({ :authentication_token => token })
end
end
Devise::Models.config(self, :token_authentication_key, :expire_auth_token_on_timeout)

View file

@ -30,7 +30,11 @@ module Devise
end
initializer "devise.secret_key" do
unless Devise.secret_key
if secret_key = Devise.secret_key
Devise.token_generator = Devise::TokenGenerator.new(
Devise::CachingKeyGenerator.new(Devise::KeyGenerator.new(secret_key))
)
else
raise <<-ERROR
Devise.secret_key was not set. Please add the following to your Devise initializer:

View file

@ -1,9 +1,36 @@
# Deprecate: Copied verbatim from Rails source, remove once we move to Rails 4 only.
require 'thread_safe'
require 'openssl'
require 'secure_random'
require 'securerandom'
module Devise
class TokenGenerator
def initialize(key_generator)
@key_generator = key_generator
end
def digest(klass, column, value)
value.present? && OpenSSL::HMAC.hexdigest("SHA1", key_for(klass, column), value.to_s)
end
def generate(klass, column)
adapter = klass.to_adapter
key = key_for(klass, column)
loop do
raw = Devise.friendly_token
enc = OpenSSL::HMAC.hexdigest("SHA1", key, raw)
break [raw, enc] unless adapter.find_first({ column => enc })
end
end
private
def key_for(klass, column)
@key_generator.generate_key("#{klass.name} #{column}")
end
end
# KeyGenerator is a simple wrapper around OpenSSL's implementation of PBKDF2
# It can be used to derive a number of keys for various purposes from a given secret.
# This lets Rails applications have a single secure secret, but avoid reusing that

View file

@ -84,8 +84,12 @@ 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\?confirmation_token=#{user.confirmation_token}">}
assert_match confirmation_url_regexp, mail.body.encoded
if mail.body.encoded =~ %r{<a href=\"http://#{host}/users/confirmation\?confirmation_token=([^"]+)">}
assert_equal Devise.token_generator.digest(user.class, :confirmation_token, $1), user.confirmation_token
else
flunk "expected confirmation url regex to match"
end
end
test 'renders a scoped if scoped_views is set to true' do

View file

@ -80,8 +80,12 @@ class ResetPasswordInstructionsTest < ActionMailer::TestCase
test 'body should have link to confirm the account' do
host = ActionMailer::Base.default_url_options[:host]
reset_url_regexp = %r{<a href=\"http://#{host}/users/password/edit\?reset_password_token=#{user.reset_password_token}">}
assert_match reset_url_regexp, mail.body.encoded
if mail.body.encoded =~ %r{<a href=\"http://#{host}/users/password/edit\?reset_password_token=([^"]+)">}
assert_equal Devise.token_generator.digest(user.class, :reset_password_token, $1), user.reset_password_token
else
flunk "expected reset password url regex to match"
end
end
test 'mailer sender accepts a proc' do

View file

@ -81,7 +81,11 @@ class UnlockInstructionsTest < ActionMailer::TestCase
test 'body should have link to unlock the account' do
host = ActionMailer::Base.default_url_options[:host]
unlock_url_regexp = %r{<a href=\"http://#{host}/users/unlock\?unlock_token=#{user.unlock_token}">}
assert_match unlock_url_regexp, mail.body.encoded
if mail.body.encoded =~ %r{<a href=\"http://#{host}/users/unlock\?unlock_token=([^"]+)">}
assert_equal Devise.token_generator.digest(user.class, :unlock_token, $1), user.unlock_token
else
flunk "expected unlock url regex to match"
end
end
end

View file

@ -51,13 +51,21 @@ class ConfirmableTest < ActiveSupport::TestCase
assert_equal "was already confirmed, please try signing in", user.errors[:email].join
end
test 'should find and confirm a user automatically' do
test 'DEPRECATED: should find and confirm a user automatically' do
user = create_user
confirmed_user = User.confirm_by_token(user.confirmation_token)
assert_equal confirmed_user, user
assert user.reload.confirmed?
end
test 'should find and confirm a user automatically based on the raw token' do
user = create_user
raw = user.instance_variable_get(:@raw_confirmation_token)
confirmed_user = User.confirm_by_token(raw)
assert_equal confirmed_user, user
assert user.reload.confirmed?
end
test 'should return a new record with errors when a invalid token is given' do
confirmed_user = User.confirm_by_token('invalid_confirmation_token')
assert_not confirmed_user.persisted?
@ -176,7 +184,7 @@ class ConfirmableTest < ActiveSupport::TestCase
test 'should not be able to send instructions if the user is already confirmed' do
user = create_user
user.confirm!
assert_not user.resend_confirmation_token
assert_not user.resend_confirmation_instructions
assert user.confirmed?
assert_equal 'was already confirmed, please try signing in', user.errors[:email].join
end
@ -285,32 +293,12 @@ class ConfirmableTest < ActiveSupport::TestCase
end
end
test 'should generate a new token if the previous one has expired' do
swap Devise, :confirm_within => 3.days do
user = create_user
user.update_attribute(:confirmation_sent_at, 4.days.ago)
old = user.confirmation_token
user.resend_confirmation_token
assert_not_equal user.confirmation_token, old
end
end
test 'should generate a new token when a valid one does not exist' do
swap Devise, :confirm_within => 3.days do
user = create_user
user.update_attribute(:confirmation_sent_at, 4.days.ago)
old = user.confirmation_token
user.ensure_confirmation_token!
assert_not_equal user.confirmation_token, old
end
end
test 'should not generate a new token when a valid one exists' do
test 'always generate a new token on resend' do
user = create_user
assert_not_nil user.confirmation_token
old = user.confirmation_token
user.ensure_confirmation_token!
assert_equal user.confirmation_token, old
old = user.confirmation_token
user = User.find(user.id)
user.resend_confirmation_instructions
assert_not_equal user.confirmation_token, old
end
test 'should call after_confirmation if confirmed' do

View file

@ -139,7 +139,7 @@ class LockableTest < ActiveSupport::TestCase
end
end
test 'should find and unlock a user automatically' do
test 'DEPRECATED: should find and unlock a user automatically' do
user = create_user
user.lock_access!
locked_user = User.unlock_access_by_token(user.unlock_token)
@ -147,6 +147,14 @@ class LockableTest < ActiveSupport::TestCase
assert_not user.reload.access_locked?
end
test 'should find and unlock a user automatically based on raw token' do
user = create_user
raw = user.send_unlock_instructions
locked_user = User.unlock_access_by_token(raw)
assert_equal locked_user, user
assert_not user.reload.access_locked?
end
test 'should return a new record with errors when a invalid token is given' do
locked_user = User.unlock_access_by_token('invalid_token')
assert_not locked_user.persisted?
@ -195,7 +203,7 @@ class LockableTest < ActiveSupport::TestCase
test 'should not be able to send instructions if the user is not locked' do
user = create_user
assert_not user.resend_unlock_token
assert_not user.resend_unlock_instructions
assert_not user.access_locked?
assert_equal 'was not locked', user.errors[:email].join
end
@ -203,7 +211,7 @@ class LockableTest < ActiveSupport::TestCase
test 'should not be able to send instructions if the user if not locked and have username as unlock key' do
swap Devise, :unlock_keys => [:username] do
user = create_user
assert_not user.resend_unlock_token
assert_not user.resend_unlock_instructions
assert_not user.access_locked?
assert_equal 'was not locked', user.errors[:username].join
end

View file

@ -108,14 +108,22 @@ class RecoverableTest < ActiveSupport::TestCase
end
end
test 'should find a user to reset his password based on reset_password_token' do
test 'DEPRECATED: should find a user to reset his password based on reset_password_token' do
user = create_user
user.ensure_reset_password_token!
user.send_reset_password_instructions
reset_password_user = User.reset_password_by_token(:reset_password_token => user.reset_password_token)
assert_equal reset_password_user, user
end
test 'should find a user to reset his password based on the raw token' do
user = create_user
raw = user.send_reset_password_instructions
reset_password_user = User.reset_password_by_token(:reset_password_token => raw)
assert_equal reset_password_user, user
end
test 'should return a new record with errors if no reset_password_token is found' do
reset_password_user = User.reset_password_by_token(:reset_password_token => 'invalid_token')
assert_not reset_password_user.persisted?
@ -130,9 +138,9 @@ class RecoverableTest < ActiveSupport::TestCase
test 'should return a new record with errors if password is blank' do
user = create_user
user.ensure_reset_password_token!
raw = user.send_reset_password_instructions
reset_password_user = User.reset_password_by_token(:reset_password_token => user.reset_password_token, :password => '')
reset_password_user = User.reset_password_by_token(:reset_password_token => raw, :password => '')
assert_not reset_password_user.errors.empty?
assert_match "can't be blank", reset_password_user.errors[:password].join
end
@ -140,10 +148,10 @@ class RecoverableTest < ActiveSupport::TestCase
test 'should reset successfully user password given the new password and confirmation' do
user = create_user
old_password = user.password
user.ensure_reset_password_token!
raw = user.send_reset_password_instructions
User.reset_password_by_token(
:reset_password_token => user.reset_password_token,
:reset_password_token => raw,
:password => 'new_password',
:password_confirmation => 'new_password'
)
@ -153,38 +161,17 @@ class RecoverableTest < ActiveSupport::TestCase
assert user.valid_password?('new_password')
end
test 'should not reset reset password token during reset_password_within time' do
swap Devise, :reset_password_within => 1.hour do
user = create_user
user.send_reset_password_instructions
3.times do
token = user.reset_password_token
user.send_reset_password_instructions
assert_equal token, user.reset_password_token
end
end
end
test 'should reset reset password token after reset_password_within time' do
swap Devise, :reset_password_within => 1.hour do
user = create_user
user.reset_password_sent_at = 2.days.ago
token = user.reset_password_token
user.send_reset_password_instructions
assert_not_equal token, user.reset_password_token
end
end
test 'should not reset password after reset_password_within time' do
swap Devise, :reset_password_within => 1.hour do
user = create_user
raw = user.send_reset_password_instructions
old_password = user.password
user.ensure_reset_password_token!
user.reset_password_sent_at = 2.days.ago
user.save!
reset_password_user = User.reset_password_by_token(
:reset_password_token => user.reset_password_token,
:reset_password_token => raw,
:password => 'new_password',
:password_confirmation => 'new_password'
)
@ -201,22 +188,5 @@ class RecoverableTest < ActiveSupport::TestCase
:reset_password_sent_at,
:reset_password_token
]
end
test 'should generate a new token when a valid one does not exist' do
user = create_user
assert_nil user.reset_password_token
user.ensure_reset_password_token!
assert_not_nil user.reset_password_token
end
test 'should not generate a new token when a valid one exists' do
user = create_user
user.send :generate_reset_password_token!
assert_not_nil user.reset_password_token
old = user.reset_password_token
user.ensure_reset_password_token!
assert_equal user.reset_password_token, old
end
end