summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-16 19:45:42 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-16 19:45:42 +0200
commit220ed6c7914f5977d36b7617e9c38999317f57e5 (patch)
tree85508f95b1f5df52ff1edfd5530285278a00c839
parent45b130fd28f60a67552121b2dfab227279615a26 (diff)
Implement transparent password hash migration
Add has_secure_password and bcrypt while retaining compatibility with legacy SHA-1 password hashes. Existing users are upgraded to password_digest on their next successful login. Add regression tests covering both legacy and modern authentication paths.
-rw-r--r--Gemfile1
-rw-r--r--Gemfile.lock3
-rw-r--r--app/models/user.rb31
-rw-r--r--lib/authentication.rb10
-rw-r--r--test/models/user_test.rb56
5 files changed, 94 insertions, 7 deletions
diff --git a/Gemfile b/Gemfile
index 97257a9..cad34df 100644
--- a/Gemfile
+++ b/Gemfile
@@ -38,6 +38,7 @@ gem 'rails_icons'
38gem 'globalize', '~> 7.0' # translated model attributes (Page title/abstract/body) 38gem 'globalize', '~> 7.0' # translated model attributes (Page title/abstract/body)
39gem 'acts_as_list' # page revision ordering 39gem 'acts_as_list' # page revision ordering
40gem 'will_paginate', '~> 3.0' 40gem 'will_paginate', '~> 3.0'
41gem '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
334DEPENDENCIES 335DEPENDENCIES
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 @@
1require 'digest/sha1' 1require 'digest/sha1'
2 2
3class User < ApplicationRecord 3class 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
66protected 122protected
67 def create_user(options = {}) 123 def create_user(options = {})