From 86a48b4da3cbd925d30b0fbe472edbda7171ea9e Mon Sep 17 00:00:00 2001 From: Unathi Chonco Date: Wed, 12 Oct 2016 01:01:01 +0800 Subject: This addition will now allow configuring an attribute name for the existing `#has_secure_password`. This can be useful when one would like to store some secure field as a digest, just like a password. The method still defaults to `password`. It now also allows using the same `#authenticate` method which now accepts a second argument for specifying the attribute to be authenticated, or will default to 'password`. A new method is also added for generating a new token for an attribute by calling `#regenerate_XXXX` where `XXXX` is the attribute name. --- activemodel/CHANGELOG.md | 21 ++++++ activemodel/lib/active_model/secure_password.rb | 97 +++++++++++++++---------- activemodel/test/cases/secure_password_test.rb | 13 ++++ activemodel/test/models/user.rb | 3 +- 4 files changed, 93 insertions(+), 41 deletions(-) diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md index 10f1de6706..0721655a9c 100644 --- a/activemodel/CHANGELOG.md +++ b/activemodel/CHANGELOG.md @@ -1,3 +1,24 @@ +* Allows configurable attribute name for `#has_secure_password`. This + still defaults to an attribute named 'password', causing no breaking + change. Also includes a convenience method `#regenerate_XXX` where + +XXX+ is the name of the custom attribute name, eg: + + class User < ActiveRecord::Base + has_secure_password :activation_token, validations: false + end + + user = User.new() + user.regenerate_activation_token + user.activation_token # => "ME7abXFGvzZWJRVrD6Et0YqAS6Pg2eDo" + user.activation_token_digest # => "$2a$10$0Budk0Fi/k2CDm2PEwa3Be..." + + The existing `#authenticate` method now allows specifying the attribute + to be authenticated, but defaults to 'password', eg: + + user.authenticate('ME7abXFGvzZWJRVrD6Et0YqAS6Pg2eDo', :activation_token) # => user + + *Unathi Chonco* + * Removed deprecated `:tokenizer` in the length validator. *Rafael Mendonça França* diff --git a/activemodel/lib/active_model/secure_password.rb b/activemodel/lib/active_model/secure_password.rb index 1c0fe92bc0..5c7322698a 100644 --- a/activemodel/lib/active_model/secure_password.rb +++ b/activemodel/lib/active_model/secure_password.rb @@ -14,15 +14,16 @@ module ActiveModel module ClassMethods # Adds methods to set and authenticate against a BCrypt password. - # This mechanism requires you to have a +password_digest+ attribute. + # This mechanism requires you to have a +XXX_digest+ attribute. + # Where +XXX+ is the attribute name of your desired password/token or defaults to +password+ # # The following validations are added automatically: # * Password must be present on creation # * Password length should be less than or equal to 72 characters - # * Confirmation of password (using a +password_confirmation+ attribute) + # * Confirmation of password (using a +XXX_confirmation+ attribute) # - # If password confirmation validation is not needed, simply leave out the - # value for +password_confirmation+ (i.e. don't provide a form field for + # If confirmation validation is not needed, simply leave out the + # value for +XXX_confirmation+ (i.e. don't provide a form field for # it). When this attribute has a +nil+ value, the validation will not be # triggered. # @@ -35,9 +36,10 @@ module ActiveModel # # Example using Active Record (which automatically includes ActiveModel::SecurePassword): # - # # Schema: User(name:string, password_digest:string) + # # Schema: User(name:string, password_digest:string, activation_token_digest:string) # class User < ActiveRecord::Base # has_secure_password + # has_secure_password :activation_token, validations: false # end # # user = User.new(name: 'david', password: '', password_confirmation: 'nomatch') @@ -46,11 +48,16 @@ module ActiveModel # user.save # => false, confirmation doesn't match # user.password_confirmation = 'mUc3m00RsqyRe' # user.save # => true + # user.regenerate_activation_token + # user.activation_token # => "ME7abXFGvzZWJRVrD6Et0YqAS6Pg2eDo" + # user.activation_token_digest # => "$2a$10$0Budk0Fi/k2CDm2PEwa3BeXO5tPOA85b6xazE9rp8nF2MIJlsUik." + # user.save # => true # user.authenticate('notright') # => false # user.authenticate('mUc3m00RsqyRe') # => user # User.find_by(name: 'david').try(:authenticate, 'notright') # => false # User.find_by(name: 'david').try(:authenticate, 'mUc3m00RsqyRe') # => user - def has_secure_password(options = {}) + # user.authenticate('ME7abXFGvzZWJRVrD6Et0YqAS6Pg2eDo', :activation_token) # => user + def has_secure_password(attribute = :password, validations: true) # Load bcrypt gem only when has_secure_password is used. # This is to avoid ActiveModel (and by extension the entire framework) # being dependent on a binary library. @@ -61,9 +68,41 @@ module ActiveModel raise end + attr_reader attribute + + # Encrypts the password into the +password_digest+ attribute, only if the + # new password is not empty. + # + # class User < ActiveRecord::Base + # has_secure_password validations: false + # end + # + # user = User.new + # user.password = nil + # user.password_digest # => nil + # user.password = 'mUc3m00RsqyRe' + # user.password_digest # => "$2a$10$4LEA7r4YmNHtvlAvHhsYAeZmk/xeUVtMTYqwIvYY76EW5GUqDiP4." + define_method("#{attribute}=") do |unencrypted_password| + if unencrypted_password.nil? + self.send("#{attribute}_digest=", nil) + elsif !unencrypted_password.empty? + instance_variable_set("@#{attribute}", unencrypted_password) + cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost + self.send("#{attribute}_digest=", BCrypt::Password.create(unencrypted_password, cost: cost)) + end + end + + define_method("#{attribute}_confirmation=") do |unencrypted_password| + instance_variable_set("@#{attribute}_confirmation", unencrypted_password) + end + + define_method("regenerate_#{attribute}") do + self.send("#{attribute}=", self.class.generate_unique_secure_token) + end + include InstanceMethodsOnActivation - if options.fetch(:validations, true) + if validations include ActiveModel::Validations # This ensures the model has a password by checking whether the password_digest @@ -71,13 +110,18 @@ module ActiveModel # when there is an error, the message is added to the password attribute instead # so that the error message will make sense to the end-user. validate do |record| - record.errors.add(:password, :blank) unless record.password_digest.present? + record.errors.add(attribute, :blank) unless record.send("#{attribute}_digest").present? end - validates_length_of :password, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED - validates_confirmation_of :password, allow_blank: true + validates_length_of attribute, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED + validates_confirmation_of attribute, allow_blank: true end end + + # SecureRandom::base64 is used to generate a 24-character unique token, so collisions are highly unlikely. + def generate_unique_secure_token + SecureRandom.base64(24) + end end module InstanceMethodsOnActivation @@ -91,36 +135,9 @@ module ActiveModel # user.save # user.authenticate('notright') # => false # user.authenticate('mUc3m00RsqyRe') # => user - def authenticate(unencrypted_password) - BCrypt::Password.new(password_digest).is_password?(unencrypted_password) && self - end - - attr_reader :password - - # Encrypts the password into the +password_digest+ attribute, only if the - # new password is not empty. - # - # class User < ActiveRecord::Base - # has_secure_password validations: false - # end - # - # user = User.new - # user.password = nil - # user.password_digest # => nil - # user.password = 'mUc3m00RsqyRe' - # user.password_digest # => "$2a$10$4LEA7r4YmNHtvlAvHhsYAeZmk/xeUVtMTYqwIvYY76EW5GUqDiP4." - def password=(unencrypted_password) - if unencrypted_password.nil? - self.password_digest = nil - elsif !unencrypted_password.empty? - @password = unencrypted_password - cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost - self.password_digest = BCrypt::Password.create(unencrypted_password, cost: cost) - end - end - - def password_confirmation=(unencrypted_password) - @password_confirmation = unencrypted_password + def authenticate(unencrypted_password, attribute = :password) + attribute_digest = send("#{attribute}_digest") + BCrypt::Password.new(attribute_digest).is_password?(unencrypted_password) && self end end end diff --git a/activemodel/test/cases/secure_password_test.rb b/activemodel/test/cases/secure_password_test.rb index 9423df2c09..abbd182f66 100644 --- a/activemodel/test/cases/secure_password_test.rb +++ b/activemodel/test/cases/secure_password_test.rb @@ -184,9 +184,13 @@ class SecurePasswordTest < ActiveModel::TestCase test "authenticate" do @user.password = "secret" + @user.activation_token = "new_token" assert !@user.authenticate("wrong") assert @user.authenticate("secret") + + assert !@user.authenticate("wrong", :activation_token) + assert @user.authenticate("new_token", :activation_token) end test "Password digest cost defaults to bcrypt default cost when min_cost is false" do @@ -215,4 +219,13 @@ class SecurePasswordTest < ActiveModel::TestCase @user.password = "secret" assert_equal BCrypt::Engine::MIN_COST, @user.password_digest.cost end + + test "regenerate attribute method" do + old_digest = @user.activation_token_digest + @user.regenerate_activation_token + + assert_not_nil @user.activation_token + assert_not_nil @user.activation_token_digest + assert_not_equal old_digest, @user.activation_token_digest + end end diff --git a/activemodel/test/models/user.rb b/activemodel/test/models/user.rb index 6556b1a7d9..ab424efb9b 100644 --- a/activemodel/test/models/user.rb +++ b/activemodel/test/models/user.rb @@ -5,6 +5,7 @@ class User define_model_callbacks :create has_secure_password + has_secure_password :activation_token, validations: false - attr_accessor :password_digest + attr_accessor :password_digest, :activation_token_digest end -- cgit v1.2.3 From 9b63bf1dfda36c61802165b2683761d2bb0d2110 Mon Sep 17 00:00:00 2001 From: Unathi Chonco Date: Wed, 12 Oct 2016 09:01:57 +0800 Subject: Remove method for regenerating a token, and update `#authenticate`. This change now creates a method `#authenticate_XXX` where XXX is the configured attribute name on `#has_secure_password`. `#authenticate` is now an alias to this method when the attribute name is the default 'password' --- activemodel/CHANGELOG.md | 17 ++++---- activemodel/lib/active_model/secure_password.rb | 56 +++++++------------------ activemodel/test/cases/secure_password_test.rb | 13 +----- 3 files changed, 25 insertions(+), 61 deletions(-) diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md index 0721655a9c..21ebe7e9ec 100644 --- a/activemodel/CHANGELOG.md +++ b/activemodel/CHANGELOG.md @@ -1,21 +1,18 @@ * Allows configurable attribute name for `#has_secure_password`. This still defaults to an attribute named 'password', causing no breaking - change. Also includes a convenience method `#regenerate_XXX` where - +XXX+ is the name of the custom attribute name, eg: + change. There is a new method `#authenticate_XXX` where XXX is the + configured attribute name, making the existing `#authenticate` now an + alias for this when the attribute is the default 'password'. + Example: class User < ActiveRecord::Base has_secure_password :activation_token, validations: false end user = User.new() - user.regenerate_activation_token - user.activation_token # => "ME7abXFGvzZWJRVrD6Et0YqAS6Pg2eDo" - user.activation_token_digest # => "$2a$10$0Budk0Fi/k2CDm2PEwa3Be..." - - The existing `#authenticate` method now allows specifying the attribute - to be authenticated, but defaults to 'password', eg: - - user.authenticate('ME7abXFGvzZWJRVrD6Et0YqAS6Pg2eDo', :activation_token) # => user + user.activation_token = "a_new_token" + user.activation_token_digest # => "$2a$10$0Budk0Fi/k2CDm2PEwa3Be..." + user.authenticate_activation_token('a_new_token') # => user *Unathi Chonco* diff --git a/activemodel/lib/active_model/secure_password.rb b/activemodel/lib/active_model/secure_password.rb index 5c7322698a..0172ddc97e 100644 --- a/activemodel/lib/active_model/secure_password.rb +++ b/activemodel/lib/active_model/secure_password.rb @@ -48,15 +48,14 @@ module ActiveModel # user.save # => false, confirmation doesn't match # user.password_confirmation = 'mUc3m00RsqyRe' # user.save # => true - # user.regenerate_activation_token - # user.activation_token # => "ME7abXFGvzZWJRVrD6Et0YqAS6Pg2eDo" + # user.activation_token = "a_new_token" # user.activation_token_digest # => "$2a$10$0Budk0Fi/k2CDm2PEwa3BeXO5tPOA85b6xazE9rp8nF2MIJlsUik." # user.save # => true # user.authenticate('notright') # => false # user.authenticate('mUc3m00RsqyRe') # => user + # user.authenticate_activation_token('a_new_token') # => user # User.find_by(name: 'david').try(:authenticate, 'notright') # => false # User.find_by(name: 'david').try(:authenticate, 'mUc3m00RsqyRe') # => user - # user.authenticate('ME7abXFGvzZWJRVrD6Et0YqAS6Pg2eDo', :activation_token) # => user def has_secure_password(attribute = :password, validations: true) # Load bcrypt gem only when has_secure_password is used. # This is to avoid ActiveModel (and by extension the entire framework) @@ -70,18 +69,6 @@ module ActiveModel attr_reader attribute - # Encrypts the password into the +password_digest+ attribute, only if the - # new password is not empty. - # - # class User < ActiveRecord::Base - # has_secure_password validations: false - # end - # - # user = User.new - # user.password = nil - # user.password_digest # => nil - # user.password = 'mUc3m00RsqyRe' - # user.password_digest # => "$2a$10$4LEA7r4YmNHtvlAvHhsYAeZmk/xeUVtMTYqwIvYY76EW5GUqDiP4." define_method("#{attribute}=") do |unencrypted_password| if unencrypted_password.nil? self.send("#{attribute}_digest=", nil) @@ -96,11 +83,22 @@ module ActiveModel instance_variable_set("@#{attribute}_confirmation", unencrypted_password) end - define_method("regenerate_#{attribute}") do - self.send("#{attribute}=", self.class.generate_unique_secure_token) + # Returns +self+ if the password is correct, otherwise +false+. + # + # class User < ActiveRecord::Base + # has_secure_password validations: false + # end + # + # user = User.new(name: 'david', password: 'mUc3m00RsqyRe') + # user.save + # user.authenticate_password('notright') # => false + # user.authenticate_password('mUc3m00RsqyRe') # => user + define_method("authenticate_#{attribute}") do |unencrypted_password| + attribute_digest = send("#{attribute}_digest") + BCrypt::Password.new(attribute_digest).is_password?(unencrypted_password) && self end - include InstanceMethodsOnActivation + alias_method :authenticate, :authenticate_password if attribute == :password if validations include ActiveModel::Validations @@ -117,28 +115,6 @@ module ActiveModel validates_confirmation_of attribute, allow_blank: true end end - - # SecureRandom::base64 is used to generate a 24-character unique token, so collisions are highly unlikely. - def generate_unique_secure_token - SecureRandom.base64(24) - end - end - - module InstanceMethodsOnActivation - # Returns +self+ if the password is correct, otherwise +false+. - # - # class User < ActiveRecord::Base - # has_secure_password validations: false - # end - # - # user = User.new(name: 'david', password: 'mUc3m00RsqyRe') - # user.save - # user.authenticate('notright') # => false - # user.authenticate('mUc3m00RsqyRe') # => user - def authenticate(unencrypted_password, attribute = :password) - attribute_digest = send("#{attribute}_digest") - BCrypt::Password.new(attribute_digest).is_password?(unencrypted_password) && self - end end end end diff --git a/activemodel/test/cases/secure_password_test.rb b/activemodel/test/cases/secure_password_test.rb index abbd182f66..279a908aeb 100644 --- a/activemodel/test/cases/secure_password_test.rb +++ b/activemodel/test/cases/secure_password_test.rb @@ -189,8 +189,8 @@ class SecurePasswordTest < ActiveModel::TestCase assert !@user.authenticate("wrong") assert @user.authenticate("secret") - assert !@user.authenticate("wrong", :activation_token) - assert @user.authenticate("new_token", :activation_token) + assert !@user.authenticate_activation_token("wrong") + assert @user.authenticate_activation_token("new_token") end test "Password digest cost defaults to bcrypt default cost when min_cost is false" do @@ -219,13 +219,4 @@ class SecurePasswordTest < ActiveModel::TestCase @user.password = "secret" assert_equal BCrypt::Engine::MIN_COST, @user.password_digest.cost end - - test "regenerate attribute method" do - old_digest = @user.activation_token_digest - @user.regenerate_activation_token - - assert_not_nil @user.activation_token - assert_not_nil @user.activation_token_digest - assert_not_equal old_digest, @user.activation_token_digest - end end -- cgit v1.2.3