Extract encryptors into their own module for better bcrypt support.

This commit is contained in:
José Valim 2010-09-25 16:08:46 +02:00
parent 31d821c2e0
commit 09088706bb
20 changed files with 204 additions and 158 deletions

View File

@ -7,7 +7,7 @@ Devise is a flexible authentication solution for Rails based on Warden. It:
* Allows you to have multiple roles (or models/scopes) signed in at the same time;
* Is based on a modularity concept: use just what you really need.
It's composed of 11 modules:
It's composed of 12 modules:
* Database Authenticatable: encrypts and stores a password in the database to validate the authenticity of an user while signing in. The authentication can be done both through POST requests or HTTP Basic Authentication.
* Token Authenticatable: signs in a user based on an authentication token (also known as "single access token"). The token can be given both through query string or HTTP Basic Authentication.
@ -20,6 +20,7 @@ It's composed of 11 modules:
* Timeoutable: expires sessions that have no activity in a specified period of time.
* Validatable: provides validations of email and password. It's optional and can be customized, so you're able to define your own validations.
* Lockable: locks an account after a specified number of failed sign-in attempts. Can unlock via email or after a specified time period.
* Encryptable: allows support of other authentication mechanisms besides Bcrypt (the default).
== Information
@ -152,9 +153,9 @@ Finally, you need to set up default url options for the mailer in each environme
The devise method in your models also accepts some options to configure its modules. For example, you can choose which encryptor to use in database_authenticatable:
devise :database_authenticatable, :confirmable, :recoverable, :encryptor => :bcrypt
devise :database_authenticatable, :confirmable, :recoverable, :stretches => 20
Besides :encryptor, you can define :pepper, :stretches, :confirm_within, :remember_for, :timeout_in, :unlock_in and other values. For details, see the initializer file that was created when you invoked the "devise:install" generator described above.
Besides :stretches, you can define :pepper, :encryptor, :confirm_within, :remember_for, :timeout_in, :unlock_in and other values. For details, see the initializer file that was created when you invoked the "devise:install" generator described above.
=== Configuring multiple models
@ -308,7 +309,7 @@ Devise supports ActiveRecord (default) and Mongoid. To choose other ORM, you jus
=== Migrating from other solutions
Devise implements encryption strategies for Clearance, Authlogic and Restful-Authentication. To make use of these strategies, set the desired encryptor in the encryptor initializer config option. You might also need to rename your encrypted password and salt columns to match Devise's fields (encrypted_password and password_salt).
Devise implements encryption strategies for Clearance, Authlogic and Restful-Authentication. To make use of these strategies, you need set the desired encryptor in the encryptor initializer config option and add :encryptable to your model. You might also need to rename your encrypted password and salt columns to match Devise's fields (encrypted_password and password_salt).
== Additional information

View File

@ -48,18 +48,13 @@ module Devise
:sha512 => 128,
:clearance_sha1 => 40,
:restful_authentication_sha1 => 40,
:authlogic_sha512 => 128,
:bcrypt => 60
:authlogic_sha512 => 128
}
# Custom domain for cookies. Not set by default
mattr_accessor :cookie_domain
@@cookie_domain = false
# Used to encrypt password. Please generate one with rake secret.
mattr_accessor :pepper
@@pepper = nil
# The number of times to encrypt password.
mattr_accessor :stretches
@@stretches = 10
@ -121,9 +116,13 @@ module Devise
mattr_accessor :timeout_in
@@timeout_in = 30.minutes
# Used to encrypt password. Please generate one with rake secret.
mattr_accessor :pepper
@@pepper = nil
# Used to define the password encryption algorithm.
mattr_accessor :encryptor
@@encryptor = :bcrypt
@@encryptor = nil
# Tells if devise should apply the schema in ORMs where devise declaration
# and schema belongs to the same class (as Datamapper and Mongoid).

View File

@ -1,19 +0,0 @@
require "bcrypt"
module Devise
module Encryptors
# = BCrypt
# Uses the BCrypt hash algorithm to encrypt passwords.
class Bcrypt < Base
# Gererates a default password digest based on stretches, salt, pepper and the
# incoming password. We don't strech it ourselves since BCrypt does so internally.
def self.digest(password, stretches, salt, pepper)
::BCrypt::Engine.hash_secret([password, pepper].join, salt, stretches)
end
def self.salt(stretches)
::BCrypt::Engine.generate_salt(stretches)
end
end
end
end

View File

@ -1,4 +1,5 @@
require 'devise/strategies/database_authenticatable'
require 'bcrypt'
module Devise
module Models
@ -9,14 +10,7 @@ module Devise
#
# DatabaseAuthenticable adds the following options to devise_for:
#
# * +pepper+: encryption key used for creating encrypted password. Each time
# password changes, it's gonna be encrypted again, and this key is added
# to the password and salt to create a secure hash. Always use `rake secret'
# to generate a new key.
#
# * +stretches+: defines how many times the password will be encrypted.
#
# * +encryptor+: the encryptor going to be used. By default :sha1.
# * +stretches+: the cost given to bcrypt.
#
# == Examples
#
@ -30,20 +24,15 @@ module Devise
attr_accessor :password_confirmation
end
# Regenerates password salt and encrypted password each time password is set,
# and then trigger any "after_changed_password"-callbacks.
# Generators password encryption based on the value given.
def password=(new_password)
@password = new_password
if @password.present?
self.password_salt = self.class.password_salt
self.encrypted_password = password_digest(@password)
end
self.encrypted_password = password_digest(@password) if @password.present?
end
# Verifies whether an incoming_password (ie from sign in) is the user password.
def valid_password?(incoming_password)
password_digest(incoming_password) == self.encrypted_password
::BCrypt::Password.new(self.encrypted_password) == incoming_password
end
# Set password and password confirmation to nil
@ -77,26 +66,20 @@ module Devise
def after_database_authentication
end
# A reliable way to expose the salt regardless of the implementation.
def authenticatable_salt
self.encrypted_password[0,29]
end
protected
# Digests the password using the configured encryptor.
# Digests the password using bcrypt.
def password_digest(password)
if self.password_salt.present?
self.class.encryptor_class.digest(password, self.class.stretches, self.password_salt, self.class.pepper)
end
::BCrypt::Password.create(password, :cost => self.class.stretches).to_s
end
module ClassMethods
Devise::Models.config(self, :pepper, :stretches, :encryptor)
# Returns the class for the configured encryptor.
def encryptor_class
@encryptor_class ||= ::Devise::Encryptors.const_get(encryptor.to_s.classify)
end
def password_salt
self.encryptor_class.salt(self.stretches)
end
Devise::Models.config(self, :stretches)
# We assume this method already gets the sanitized values from the
# DatabaseAuthenticatable strategy. If you are using this method on

View File

@ -0,0 +1,65 @@
require 'devise/strategies/database_authenticatable'
module Devise
module Models
# Encryptable Module adds support to several encryptors.
#
# == Options
#
# Encryptable adds the following options to devise_for:
#
# * +pepper+: a random string used to provide a more secure hash.
#
# * +encryptor+: the encryptor going to be used. By default is nil.
#
# == Examples
#
# User.find(1).valid_password?('password123') # returns true/false
#
module Encryptable
extend ActiveSupport::Concern
included do
attr_reader :password, :current_password
attr_accessor :password_confirmation
end
# Generates password salt.
def password=(new_password)
self.password_salt = self.class.password_salt if new_password.present?
super
end
def authenticatable_salt
self.password_salt
end
# Verifies whether an incoming_password (ie from sign in) is the user password.
def valid_password?(incoming_password)
password_digest(incoming_password) == self.encrypted_password
end
protected
# Digests the password using the configured encryptor.
def password_digest(password)
if self.password_salt.present?
self.class.encryptor_class.digest(password, self.class.stretches, self.password_salt, self.class.pepper)
end
end
module ClassMethods
Devise::Models.config(self, :pepper, :encryptor)
# Returns the class for the configured encryptor.
def encryptor_class
@encryptor_class ||= ::Devise::Encryptors.const_get(encryptor.to_s.classify)
end
def password_salt
self.encryptor_class.salt(self.stretches)
end
end
end
end
end

View File

@ -82,7 +82,7 @@ module Devise
end
def rememberable_value
respond_to?(:remember_token) ? self.remember_token : self.password_salt
respond_to?(:remember_token) ? self.remember_token : self.authenticatable_salt
end
protected

View File

@ -10,6 +10,7 @@ Devise.with_options :model => true do |d|
end
# Other authentications
d.add_module :encryptable
d.add_module :oauthable, :controller => :oauth_callbacks, :route => :oauth_callback
# Misc after

View File

@ -33,6 +33,23 @@ module Devise
end
end
initializer "devise.encryptor_check" do
case Devise.encryptor
when :bcrypt
puts "[DEVISE] From version 1.2, there is no need to set your encryptor to bcrypt " <<
"since encryptors are only enabled if you include :encryptable in your models. " <<
"With this change, we can integrate better with bcrypt and get rid of the " <<
"password_salt column (since bcrypt stores the salt with password). " <<
"Please comment config.encryptor in your initializer to get rid of this warning."
when nil
# Nothing to say
else
puts "[DEVISE] You are using #{Devise.encryptor} as encryptor. From version 1.2, " <<
"you need to explicitly add :encryptable to your models in order for this " <<
"configuration value to work."
end
end
# Check all available mappings and only load related controllers.
def eager_load!
mappings = Devise.mappings.values.map(&:modules).flatten.uniq

View File

@ -18,9 +18,13 @@ module Devise
apply_devise_schema :email, String, :null => null, :default => default
apply_devise_schema :encrypted_password, String, :null => null, :default => default, :limit => 128
apply_devise_schema :password_salt, String, :null => null, :default => default
end
# Creates password salt for encryption support.
def encryptable
apply_devise_schema :password_salt, String
end
# Creates authentication_token.
def token_authenticatable(options={})
apply_devise_schema :authentication_token, String

View File

@ -6,6 +6,7 @@ class DeviseCreate<%= table_name.camelize %> < ActiveRecord::Migration
t.rememberable
t.trackable
# t.encryptable
# t.confirmable
# t.lockable :lock_strategy => :<%= Devise.lock_strategy %>, :unlock_strategy => :<%= Devise.unlock_strategy %>
# t.token_authenticatable

View File

@ -4,7 +4,7 @@ module Devise
def model_contents
<<-CONTENT
# Include default devise modules. Others available are:
# :token_authenticatable, :confirmable, :lockable, :timeoutable and :oauthable
# :token_authenticatable, :encryptable, :confirmable, :lockable, :timeoutable and :oauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable

View File

@ -44,19 +44,10 @@ Devise.setup do |config|
# config.http_authentication_realm = "Application"
# ==> Configuration for :database_authenticatable
# Define which will be the encryption algorithm. Devise also supports encryptors
# from others authentication tools as :clearance_sha1, :authlogic_sha512 (then
# you should set stretches above to 20 for default behavior) and :restful_authentication_sha1
# (then you should set stretches to 10, and copy REST_AUTH_SITE_KEY to pepper)
config.encryptor = :bcrypt
# For bcrypt, this is the cost for hashing the password and defaults to 10. If
# using other encryptors, it sets how many times you want the password re-encrypted.
config.stretches = 10
# Setup a pepper to generate the encrypted password.
config.pepper = <%= ActiveSupport::SecureRandom.hex(64).inspect %>
# ==> Configuration for :confirmable
# The time you want to give your user to confirm his account. During this time
# he will be able to access your application without confirming. Default is nil.
@ -112,6 +103,17 @@ Devise.setup do |config|
# Time interval to unlock the account if :time is enabled as unlock_strategy.
# config.unlock_in = 1.hour
# ==> Configuration for :encryptable
# Allow you to use another encryption algorithm besides bcrypt (default). You can use
# :sha1, :sha512 or encryptors from others authentication tools as :clearance_sha1,
# :authlogic_sha512 (then you should set stretches above to 20 for default behavior)
# and :restful_authentication_sha1 (then you should set stretches to 10, and copy
# REST_AUTH_SITE_KEY to pepper)
# config.encryptor = :sha512
# Setup a pepper to generate the encrypted password.
# config.pepper = <%= ActiveSupport::SecureRandom.hex(64).inspect %>
# ==> Configuration for :token_authenticatable
# Defines name of the authentication token params key
# config.token_authentication_key = :auth_token

View File

@ -2,55 +2,17 @@ require 'test_helper'
require 'digest/sha1'
class DatabaseAuthenticatableTest < ActiveSupport::TestCase
def encrypt_password(user, pepper=User.pepper, stretches=User.stretches, encryptor=User.encryptor_class)
encryptor.digest('123456', stretches, user.password_salt, pepper)
end
def swap_with_encryptor(klass, encryptor, options={})
klass.instance_variable_set(:@encryptor_class, nil)
swap klass, options.merge(:encryptor => encryptor) do
begin
yield
ensure
klass.instance_variable_set(:@encryptor_class, nil)
end
end
end
test 'should respond to password and password confirmation' do
user = new_user
assert user.respond_to?(:password)
assert user.respond_to?(:password_confirmation)
end
test 'should generate encrypted password and salt while setting password' do
test 'should generate encrypted password while setting password' do
user = new_user
assert_present user.password_salt
assert_present user.encrypted_password
end
test 'should not change password salt when updating' do
user = create_user
salt = user.password_salt
user.expects(:password_salt=).never
user.save!
assert_equal salt, user.password_salt
end
test 'should generate a base64 hash using SecureRandom for password salt' do
swap_with_encryptor User, :sha1 do
ActiveSupport::SecureRandom.expects(:base64).with(44).returns('friendly_token')
assert_equal 'friendly_token', new_user.password_salt
end
end
test 'should not generate salt if password is blank' do
assert_blank new_user(:password => nil).password_salt
assert_blank new_user(:password => '').password_salt
end
test 'should not generate encrypted password if password is blank' do
assert_blank new_user(:password => nil).encrypted_password
assert_blank new_user(:password => '').encrypted_password
@ -64,47 +26,12 @@ class DatabaseAuthenticatableTest < ActiveSupport::TestCase
assert_not_equal encrypted_password, user.encrypted_password
end
test 'should fallback to sha1 as default encryption' do
user = new_user
assert_equal encrypt_password(user), user.encrypted_password
end
test 'should fallback to devise pepper default configuration' do
begin
Devise.pepper = ''
user = new_user
assert_equal encrypt_password(user), user.encrypted_password
assert_not_equal encrypt_password(user, 'another_pepper'), user.encrypted_password
Devise.pepper = 'new_pepper'
user = new_user
assert_equal encrypt_password(user, 'new_pepper'), user.encrypted_password
assert_not_equal encrypt_password(user, 'another_pepper'), user.encrypted_password
ensure
Devise.pepper = nil
end
end
test 'should respect encryptor configuration' do
swap_with_encryptor User, :sha512 do
user = create_user
assert_equal user.encrypted_password, encrypt_password(user, User.pepper, User.stretches, ::Devise::Encryptors::Sha512)
end
end
test 'should test for a valid password' do
user = create_user
assert user.valid_password?('123456')
assert_not user.valid_password?('654321')
end
test 'should not validate password when salt is nil' do
admin = create_admin
admin.password_salt = nil
admin.save
assert_not admin.valid_password?('123456')
end
test 'should respond to current password' do
assert new_user.respond_to?(:current_password)
end

View File

@ -0,0 +1,65 @@
require 'test_helper'
class EncryptableTest < ActiveSupport::TestCase
def encrypt_password(admin, pepper=Admin.pepper, stretches=Admin.stretches, encryptor=Admin.encryptor_class)
encryptor.digest('123456', stretches, admin.password_salt, pepper)
end
def swap_with_encryptor(klass, encryptor, options={})
klass.instance_variable_set(:@encryptor_class, nil)
swap klass, options.merge(:encryptor => encryptor) do
begin
yield
ensure
klass.instance_variable_set(:@encryptor_class, nil)
end
end
end
test 'should generate salt while setting password' do
assert_present create_admin.password_salt
end
test 'should not change password salt when updating' do
admin = create_admin
salt = admin.password_salt
admin.expects(:password_salt=).never
admin.save!
assert_equal salt, admin.password_salt
end
test 'should generate a base64 hash using SecureRandom for password salt' do
swap_with_encryptor Admin, :sha1 do
ActiveSupport::SecureRandom.expects(:base64).with(44).returns('friendly_token')
assert_equal 'friendly_token', create_admin.password_salt
end
end
test 'should not generate salt if password is blank' do
assert_blank create_admin(:password => nil).password_salt
assert_blank create_admin(:password => '').password_salt
end
test 'should encrypt password again if password has changed' do
admin = create_admin
encrypted_password = admin.encrypted_password
admin.password = admin.password_confirmation = 'new_password'
admin.save!
assert_not_equal encrypted_password, admin.encrypted_password
end
test 'should respect encryptor configuration' do
swap_with_encryptor Admin, :sha512 do
admin = create_admin
assert_equal admin.encrypted_password, encrypt_password(admin, Admin.pepper, Admin.stretches, ::Devise::Encryptors::Sha512)
end
end
test 'should not validate password when salt is nil' do
admin = create_admin
admin.password_salt = nil
admin.save
assert_not admin.valid_password?('123456')
end
end

View File

@ -260,12 +260,12 @@ class WithSaltRememberableTest < ActiveSupport::TestCase
test 'serialize into cookie' do
user = create_user
user.remember_me!
assert_equal [user.id, user.password_salt], User.serialize_into_cookie(user)
assert_equal [user.id, user.authenticatable_salt], User.serialize_into_cookie(user)
end
test 'serialize from cookie' do
user = create_user
user.remember_me!
assert_equal user, User.serialize_from_cookie(user.id, user.password_salt)
assert_equal user, User.serialize_from_cookie(user.id, user.authenticatable_salt)
end
end

View File

@ -1,7 +1,7 @@
require 'test_helper'
class Configurable < User
devise :database_authenticatable, :confirmable, :rememberable, :timeoutable, :lockable,
devise :database_authenticatable, :encryptable, :confirmable, :rememberable, :timeoutable, :lockable,
:stretches => 15, :pepper => 'abcdef', :confirm_within => 5.days,
:remember_for => 7.days, :timeout_in => 15.minutes, :unlock_in => 10.days
end
@ -26,16 +26,16 @@ class ActiveRecordTest < ActiveSupport::TestCase
end
test 'can cherry pick modules' do
assert_include_modules Admin, :database_authenticatable, :registerable, :timeoutable, :recoverable, :lockable, :rememberable
assert_include_modules Admin, :database_authenticatable, :registerable, :timeoutable, :recoverable, :lockable, :rememberable, :encryptable
end
test 'chosen modules are inheritable' do
assert_include_modules Inheritable, :database_authenticatable, :registerable, :timeoutable, :recoverable, :lockable, :rememberable
assert_include_modules Inheritable, :database_authenticatable, :registerable, :timeoutable, :recoverable, :lockable, :rememberable, :encryptable
end
test 'order of module inclusion' do
correct_module_order = [:database_authenticatable, :rememberable, :recoverable, :registerable, :lockable, :timeoutable]
incorrect_module_order = [:database_authenticatable, :timeoutable, :registerable, :recoverable, :lockable, :rememberable]
correct_module_order = [:database_authenticatable, :rememberable, :encryptable, :recoverable, :registerable, :lockable, :timeoutable]
incorrect_module_order = [:database_authenticatable, :timeoutable, :registerable, :recoverable, :lockable, :encryptable, :rememberable]
assert_include_modules Admin, *incorrect_module_order

View File

@ -40,7 +40,7 @@ Devise.setup do |config|
# from others authentication tools as :clearance_sha1, :authlogic_sha512 (then
# you should set stretches above to 20 for default behavior) and :restful_authentication_sha1
# (then you should set stretches to 10, and copy REST_AUTH_SITE_KEY to pepper)
config.encryptor = :bcrypt
config.encryptor = :sha512
# Setup a pepper to generate the encrypted password.
config.pepper = "d142367154e5beacca404b1a6a4f8bc52c6fdcfa3ccc3cf8eb49f3458a688ee6ac3b9fae488432a3bfca863b8a90008368a9f3a3dfbe5a962e64b6ab8f3a3a1a"

View File

@ -15,7 +15,8 @@ class CreateTables < ActiveRecord::Migration
end
create_table :admins do |t|
t.database_authenticatable :null => true, :encryptor => :bcrypt
t.database_authenticatable :null => true
t.encryptable
t.rememberable
t.recoverable
t.lockable

View File

@ -15,7 +15,7 @@ ActiveRecord::Schema.define(:version => 20100401102949) do
create_table "admins", :force => true do |t|
t.string "email", :default => ""
t.string "encrypted_password", :limit => 128, :default => ""
t.string "password_salt", :default => ""
t.string "password_salt"
t.string "reset_password_token"
t.integer "failed_attempts", :default => 0
t.string "unlock_token"
@ -29,7 +29,6 @@ ActiveRecord::Schema.define(:version => 20100401102949) do
t.string "facebook_token"
t.string "email", :default => "", :null => false
t.string "encrypted_password", :limit => 128, :default => "", :null => false
t.string "password_salt", :default => "", :null => false
t.string "confirmation_token"
t.datetime "confirmed_at"
t.datetime "confirmation_sent_at"

View File

@ -2,6 +2,6 @@ module SharedAdmin
extend ActiveSupport::Concern
included do
devise :database_authenticatable, :registerable, :timeoutable, :recoverable, :rememberable, :lockable, :unlock_strategy => :time
devise :database_authenticatable, :encryptable, :registerable, :timeoutable, :recoverable, :rememberable, :lockable, :unlock_strategy => :time
end
end