diff options
| -rw-r--r-- | Gemfile | 1 | ||||
| -rw-r--r-- | Gemfile.lock | 3 | ||||
| -rw-r--r-- | app/models/user.rb | 31 | ||||
| -rw-r--r-- | lib/authentication.rb | 10 | ||||
| -rw-r--r-- | test/models/user_test.rb | 56 |
5 files changed, 94 insertions, 7 deletions
| @@ -38,6 +38,7 @@ gem 'rails_icons' | |||
| 38 | gem 'globalize', '~> 7.0' # translated model attributes (Page title/abstract/body) | 38 | gem 'globalize', '~> 7.0' # translated model attributes (Page title/abstract/body) |
| 39 | gem 'acts_as_list' # page revision ordering | 39 | gem 'acts_as_list' # page revision ordering |
| 40 | gem 'will_paginate', '~> 3.0' | 40 | gem 'will_paginate', '~> 3.0' |
| 41 | gem 'bcrypt', '~> 3.1' | ||
| 41 | 42 | ||
| 42 | # Pinned to git until a release widens the activerecord < 8.1 ceiling. | 43 | # Pinned to git until a release widens the activerecord < 8.1 ceiling. |
| 43 | # Both gems work correctly on Rails 8.1; the gemspec constraint is overly conservative. | 44 | # Both gems work correctly on Rails 8.1; the gemspec constraint is overly conservative. |
diff --git a/Gemfile.lock b/Gemfile.lock index e113726..8e4d5af 100644 --- a/Gemfile.lock +++ b/Gemfile.lock | |||
| @@ -95,6 +95,7 @@ GEM | |||
| 95 | activerecord (>= 6.1) | 95 | activerecord (>= 6.1) |
| 96 | activesupport (>= 6.1) | 96 | activesupport (>= 6.1) |
| 97 | base64 (0.3.0) | 97 | base64 (0.3.0) |
| 98 | bcrypt (3.1.22) | ||
| 98 | bigdecimal (4.1.2) | 99 | bigdecimal (4.1.2) |
| 99 | builder (3.3.0) | 100 | builder (3.3.0) |
| 100 | coffee-rails (4.2.2) | 101 | coffee-rails (4.2.2) |
| @@ -334,6 +335,7 @@ PLATFORMS | |||
| 334 | DEPENDENCIES | 335 | DEPENDENCIES |
| 335 | acts-as-taggable-on! | 336 | acts-as-taggable-on! |
| 336 | acts_as_list | 337 | acts_as_list |
| 338 | bcrypt (~> 3.1) | ||
| 337 | chaos_calendar! | 339 | chaos_calendar! |
| 338 | coffee-rails (~> 4.0) | 340 | coffee-rails (~> 4.0) |
| 339 | concurrent-ruby (~> 1.3) | 341 | concurrent-ruby (~> 1.3) |
| @@ -375,6 +377,7 @@ CHECKSUMS | |||
| 375 | acts-as-taggable-on (13.0.0) | 377 | acts-as-taggable-on (13.0.0) |
| 376 | acts_as_list (1.2.6) sha256=8345380900b7bee620c07ad00991ccee59af3d8c9e8574f426e321da2865fdc8 | 378 | acts_as_list (1.2.6) sha256=8345380900b7bee620c07ad00991ccee59af3d8c9e8574f426e321da2865fdc8 |
| 377 | base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b | 379 | base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b |
| 380 | bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 | ||
| 378 | bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd | 381 | bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd |
| 379 | builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f | 382 | builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f |
| 380 | bundler (4.0.15) sha256=a4ceb882fe94a0e0ac63cd0813932bbfd631a14e5ac0b7975189b19a4d28d9e7 | 383 | bundler (4.0.15) sha256=a4ceb882fe94a0e0ac63cd0813932bbfd631a14e5ac0b7975189b19a4d28d9e7 |
diff --git a/app/models/user.rb b/app/models/user.rb index 92ac33a..5e47ae7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb | |||
| @@ -1,6 +1,10 @@ | |||
| 1 | require 'digest/sha1' | 1 | require 'digest/sha1' |
| 2 | 2 | ||
| 3 | class User < ApplicationRecord | 3 | class User < ApplicationRecord |
| 4 | has_secure_password(validations: false) | ||
| 5 | |||
| 6 | alias_method :authenticate_bcrypt, :authenticate | ||
| 7 | |||
| 4 | # Mixins and Plugins | 8 | # Mixins and Plugins |
| 5 | include Authentication | 9 | include Authentication |
| 6 | include Authentication::ByPassword | 10 | include Authentication::ByPassword |
| @@ -23,9 +27,30 @@ class User < ApplicationRecord | |||
| 23 | 27 | ||
| 24 | # Authenticates a user by their login name and unencrypted password. Returns the user or nil. | 28 | # Authenticates a user by their login name and unencrypted password. Returns the user or nil. |
| 25 | def self.authenticate(login, password) | 29 | def self.authenticate(login, password) |
| 26 | return nil if login.blank? || password.blank? | 30 | return if login.blank? || password.blank? |
| 27 | u = find_by_login(login) # need to get the salt | 31 | |
| 28 | u && u.authenticated?(password) ? u : nil | 32 | user = find_by(login: login) |
| 33 | return unless user | ||
| 34 | |||
| 35 | user&.authenticate(password) | ||
| 36 | end | ||
| 37 | |||
| 38 | def authenticate(password) | ||
| 39 | if password_digest.present? | ||
| 40 | return self if authenticate_bcrypt(password) | ||
| 41 | return nil | ||
| 42 | end | ||
| 43 | |||
| 44 | return nil unless authenticate_legacy(password) | ||
| 45 | |||
| 46 | transaction do | ||
| 47 | self.password = password | ||
| 48 | self.crypted_password = nil | ||
| 49 | self.salt = nil | ||
| 50 | save!(validate: false) | ||
| 51 | end | ||
| 52 | |||
| 53 | self | ||
| 29 | end | 54 | end |
| 30 | 55 | ||
| 31 | # TODO: Do we really want to have downcase logins only? | 56 | # TODO: Do we really want to have downcase logins only? |
diff --git a/lib/authentication.rb b/lib/authentication.rb index 4a3ad46..56df391 100644 --- a/lib/authentication.rb +++ b/lib/authentication.rb | |||
| @@ -46,7 +46,6 @@ module Authentication | |||
| 46 | include ModelInstanceMethods | 46 | include ModelInstanceMethods |
| 47 | 47 | ||
| 48 | # Virtual attribute for the unencrypted password | 48 | # Virtual attribute for the unencrypted password |
| 49 | attr_accessor :password | ||
| 50 | validates_presence_of :password, :if => :password_required? | 49 | validates_presence_of :password, :if => :password_required? |
| 51 | validates_presence_of :password_confirmation, :if => :password_required? | 50 | validates_presence_of :password_confirmation, :if => :password_required? |
| 52 | validates_confirmation_of :password, :if => :password_required? | 51 | validates_confirmation_of :password, :if => :password_required? |
| @@ -85,16 +84,19 @@ module Authentication | |||
| 85 | self.class.password_digest(password, salt) | 84 | self.class.password_digest(password, salt) |
| 86 | end | 85 | end |
| 87 | 86 | ||
| 88 | def authenticated?(password) | 87 | def authenticate_legacy(password) |
| 89 | crypted_password == encrypt(password) | 88 | crypted_password == encrypt(password) |
| 90 | end | 89 | end |
| 91 | 90 | ||
| 92 | # before filter | 91 | # before filter |
| 93 | def encrypt_password | 92 | def encrypt_password |
| 94 | return if password.blank? | 93 | return if password.blank? |
| 95 | self.salt = self.class.make_token if new_record? | 94 | return if password_digest.present? |
| 95 | |||
| 96 | self.salt ||= self.class.make_token | ||
| 96 | self.crypted_password = encrypt(password) | 97 | self.crypted_password = encrypt(password) |
| 97 | end | 98 | end |
| 99 | |||
| 98 | def password_required? | 100 | def password_required? |
| 99 | crypted_password.blank? || !password.blank? | 101 | crypted_password.blank? || !password.blank? |
| 100 | end | 102 | end |
diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 049892e..514bdea 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb | |||
| @@ -62,6 +62,62 @@ class UserTest < ActiveSupport::TestCase | |||
| 62 | def test_should_not_authenticate_unknown_user | 62 | def test_should_not_authenticate_unknown_user |
| 63 | assert_nil User.authenticate("nosuchuser", "monkey") | 63 | assert_nil User.authenticate("nosuchuser", "monkey") |
| 64 | end | 64 | end |
| 65 | |||
| 66 | def test_user_with_crypted_password_is_migrated_on_login | ||
| 67 | user = users(:quentin) | ||
| 68 | |||
| 69 | assert_nil user.password_digest | ||
| 70 | |||
| 71 | assert User.authenticate("quentin", "monkey") | ||
| 72 | |||
| 73 | user.reload | ||
| 74 | |||
| 75 | assert_not_nil user.password_digest | ||
| 76 | assert_nil user.crypted_password | ||
| 77 | assert_nil user.salt | ||
| 78 | end | ||
| 79 | |||
| 80 | def test_new_user_uses_password_digest | ||
| 81 | user = create_user | ||
| 82 | |||
| 83 | assert_not_nil user.password_digest | ||
| 84 | assert_nil user.crypted_password | ||
| 85 | assert_nil user.salt | ||
| 86 | |||
| 87 | assert_equal user, User.authenticate("quire", "quire69") | ||
| 88 | end | ||
| 89 | |||
| 90 | def test_legacy_user_is_migrated_on_login | ||
| 91 | user = users(:quentin) | ||
| 92 | |||
| 93 | assert_nil user.password_digest | ||
| 94 | assert_not_nil user.crypted_password | ||
| 95 | assert_not_nil user.salt | ||
| 96 | |||
| 97 | assert_equal user, User.authenticate("quentin", "monkey") | ||
| 98 | |||
| 99 | user.reload | ||
| 100 | |||
| 101 | assert_not_nil user.password_digest | ||
| 102 | assert_nil user.crypted_password | ||
| 103 | assert_nil user.salt | ||
| 104 | end | ||
| 105 | |||
| 106 | def test_migrated_user_authenticates_using_password_digest | ||
| 107 | user = users(:quentin) | ||
| 108 | |||
| 109 | # Trigger automatic migration. | ||
| 110 | assert_equal user, User.authenticate("quentin", "monkey") | ||
| 111 | |||
| 112 | user.reload | ||
| 113 | |||
| 114 | assert_not_nil user.password_digest | ||
| 115 | assert_nil user.crypted_password | ||
| 116 | assert_nil user.salt | ||
| 117 | |||
| 118 | # Second login should now use password_digest. | ||
| 119 | assert_equal user, User.authenticate("quentin", "monkey") | ||
| 120 | end | ||
| 65 | 121 | ||
| 66 | protected | 122 | protected |
| 67 | def create_user(options = {}) | 123 | def create_user(options = {}) |
