Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 48 additions & 21 deletions activemodel/lib/active_model/secure_password.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,43 +29,62 @@ module ClassMethods
# it). When this attribute has a +nil+ value, the validation will not be
# triggered.
#
# For further customizability, it is possible to suppress the default
# validations by passing <tt>validations: false</tt> as an argument.
# Additionally, a +XXX_challenge+ attribute is created. When set to a
# value other than +nil+, it will validate against the currently persisted
# password. This validation relies on dirty tracking, as provided by
# ActiveModel::Dirty; if dirty tracking methods are not defined, this
# validation will fail.
#
# Add bcrypt (~> 3.1.7) to Gemfile to use #has_secure_password:
# All of the above validations can be omitted by passing
# <tt>validations: false</tt> as an argument. This allows complete
# customizability of validation behavior.
#
# To use +has_secure_password+, add bcrypt (~> 3.1.7) to your Gemfile:
#
# gem 'bcrypt', '~> 3.1.7'
#
# Example using Active Record (which automatically includes ActiveModel::SecurePassword):
# ==== Examples
#
# Using Active Record, which automatically includes ActiveModel::SecurePassword:
#
# # Schema: User(name:string, password_digest:string, recovery_password_digest:string)
# class User < ActiveRecord::Base
# has_secure_password
# has_secure_password :recovery_password, validations: false
# end
#
# user = User.new(name: 'david', password: '', password_confirmation: 'nomatch')
# user.save # => false, password required
# user.password = 'mUc3m00RsqyRe'
# user.save # => false, confirmation doesn't match
# user.password_confirmation = 'mUc3m00RsqyRe'
# user.save # => true
# user = User.new(name: "david", password: "", password_confirmation: "nomatch")
#
# user.save # => false, password required
# user.password = "vr00m"
# user.save # => false, confirmation doesn't match
# user.password_confirmation = "vr00m"
# user.save # => true
#
# user.authenticate("notright") # => false
# user.authenticate("vr00m") # => user
# User.find_by(name: "david")&.authenticate("notright") # => false
# User.find_by(name: "david")&.authenticate("vr00m") # => user
#
# user.recovery_password = "42password"
# user.recovery_password_digest # => "$2a$04$iOfhwahFymCs5weB3BNH/uXkTG65HR.qpW.bNhEjFP3ftli3o5DQC"
# user.save # => true
# user.authenticate('notright') # => false
# user.authenticate('mUc3m00RsqyRe') # => user
# user.authenticate_recovery_password('42password') # => user
# User.find_by(name: 'david')&.authenticate('notright') # => false
# User.find_by(name: 'david')&.authenticate('mUc3m00RsqyRe') # => user
# user.recovery_password_digest # => "$2a$04$iOfhwahFymCs5weB3BNH/uXkTG65HR.qpW.bNhEjFP3ftli3o5DQC"
# user.save # => true
#
# user.authenticate_recovery_password("42password") # => user
#
# user.update(password: "pwn3d", password_challenge: "") # => false, challenge doesn't authenticate
# user.update(password: "nohack4u", password_challenge: "vr00m") # => true
#
# user.authenticate("vr00m") # => false, old password
# user.authenticate("nohack4u") # => 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.
begin
require "bcrypt"
rescue LoadError
$stderr.puts "You don't have bcrypt installed in your application. Please add it to your Gemfile and run bundle install"
$stderr.puts "You don't have bcrypt installed in your application. Please add it to your Gemfile and run bundle install."
raise
end

Expand All @@ -82,6 +101,16 @@ def has_secure_password(attribute = :password, validations: true)
record.errors.add(attribute, :blank) unless record.public_send("#{attribute}_digest").present?
end

validate do |record|
if challenge = record.public_send(:"#{attribute}_challenge")
digest_was = record.public_send(:"#{attribute}_digest_was") if record.respond_to?(:"#{attribute}_digest_was")

unless digest_was.present? && BCrypt::Password.new(digest_was).is_password?(challenge)
record.errors.add(:"#{attribute}_challenge")
end
end
end

validates_length_of attribute, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED
validates_confirmation_of attribute, allow_blank: true
end
Expand All @@ -103,9 +132,7 @@ def initialize(attribute)
end
end

define_method("#{attribute}_confirmation=") do |unencrypted_password|
instance_variable_set("@#{attribute}_confirmation", unencrypted_password)
end
attr_accessor :"#{attribute}_confirmation", :"#{attribute}_challenge"

# Returns +self+ if the password is correct, otherwise +false+.
#
Expand Down
42 changes: 42 additions & 0 deletions activemodel/test/cases/secure_password_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class SecurePasswordTest < ActiveModel::TestCase
# Simulate loading an existing user from the DB
@existing_user = User.new
@existing_user.password_digest = BCrypt::Password.create("password", cost: BCrypt::Engine::MIN_COST)
@existing_user.changes_applied
end

teardown do
Expand Down Expand Up @@ -166,6 +167,47 @@ class SecurePasswordTest < ActiveModel::TestCase
assert_equal ["doesn't match Password"], @existing_user.errors[:password_confirmation]
end

test "updating an existing user with validation and a correct password challenge" do
@existing_user.password = "new password"
@existing_user.password_challenge = "password"
assert @existing_user.valid?(:update), "user should be valid"
end

test "updating an existing user with validation and a nil password challenge" do
@existing_user.password = "new password"
@existing_user.password_challenge = nil
assert @existing_user.valid?(:update), "user should be valid"
end

test "updating an existing user with validation and a blank password challenge" do
@existing_user.password = "new password"
@existing_user.password_challenge = ""
assert_not @existing_user.valid?(:update), "user should be invalid"
assert_equal 1, @existing_user.errors.count
assert_equal ["is invalid"], @existing_user.errors[:password_challenge]
end

test "updating an existing user with validation and an incorrect password challenge" do
@existing_user.password = "new password"
@existing_user.password_challenge = "new password"
assert_not @existing_user.valid?(:update), "user should be invalid"
assert_equal 1, @existing_user.errors.count
assert_equal ["is invalid"], @existing_user.errors[:password_challenge]
end

test "updating a user without dirty tracking and a correct password challenge" do
validatable_visitor = Class.new(Visitor) do
attr_accessor :untracked_digest
has_secure_password :untracked
end.new

validatable_visitor.untracked = "password"
assert validatable_visitor.valid?(:update), "user should be valid"

validatable_visitor.untracked_challenge = "password"
assert_not validatable_visitor.valid?(:update), "user should be invalid"
end

test "updating an existing user with validation and a blank password digest" do
@existing_user.password_digest = ""
assert_not @existing_user.valid?(:update), "user should be invalid"
Expand Down
6 changes: 5 additions & 1 deletion activemodel/test/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@

class User
extend ActiveModel::Callbacks
include ActiveModel::Attributes
include ActiveModel::Dirty
include ActiveModel::SecurePassword

define_model_callbacks :create

attribute :password_digest
has_secure_password

attribute :recovery_password_digest
has_secure_password :recovery_password, validations: false

attr_accessor :password_digest, :recovery_password_digest
attr_accessor :password_called

def password=(unencrypted_password)
Expand Down