authentication-logic 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/bin/console +11 -0
  3. data/bin/setup +8 -0
  4. data/lib/auth/logic/acts_as_authentic/base.rb +118 -0
  5. data/lib/auth/logic/acts_as_authentic/email.rb +32 -0
  6. data/lib/auth/logic/acts_as_authentic/logged_in_status.rb +87 -0
  7. data/lib/auth/logic/acts_as_authentic/login.rb +65 -0
  8. data/lib/auth/logic/acts_as_authentic/magic_columns.rb +40 -0
  9. data/lib/auth/logic/acts_as_authentic/password.rb +362 -0
  10. data/lib/auth/logic/acts_as_authentic/perishable_token.rb +125 -0
  11. data/lib/auth/logic/acts_as_authentic/persistence_token.rb +72 -0
  12. data/lib/auth/logic/acts_as_authentic/queries/case_sensitivity.rb +55 -0
  13. data/lib/auth/logic/acts_as_authentic/queries/find_with_case.rb +85 -0
  14. data/lib/auth/logic/acts_as_authentic/session_maintenance.rb +189 -0
  15. data/lib/auth/logic/acts_as_authentic/single_access_token.rb +85 -0
  16. data/lib/auth/logic/config.rb +41 -0
  17. data/lib/auth/logic/controller_adapters/abstract_adapter.rb +121 -0
  18. data/lib/auth/logic/controller_adapters/rack_adapter.rb +74 -0
  19. data/lib/auth/logic/controller_adapters/rails_adapter.rb +49 -0
  20. data/lib/auth/logic/controller_adapters/sinatra_adapter.rb +69 -0
  21. data/lib/auth/logic/cookie_credentials.rb +65 -0
  22. data/lib/auth/logic/crypto_providers/bcrypt.rb +116 -0
  23. data/lib/auth/logic/crypto_providers/md5/v2.rb +37 -0
  24. data/lib/auth/logic/crypto_providers/md5.rb +38 -0
  25. data/lib/auth/logic/crypto_providers/scrypt.rb +96 -0
  26. data/lib/auth/logic/crypto_providers/sha1/v2.rb +42 -0
  27. data/lib/auth/logic/crypto_providers/sha1.rb +43 -0
  28. data/lib/auth/logic/crypto_providers/sha256/v2.rb +60 -0
  29. data/lib/auth/logic/crypto_providers/sha256.rb +61 -0
  30. data/lib/auth/logic/crypto_providers/sha512/v2.rb +41 -0
  31. data/lib/auth/logic/crypto_providers/sha512.rb +40 -0
  32. data/lib/auth/logic/crypto_providers.rb +89 -0
  33. data/lib/auth/logic/errors.rb +52 -0
  34. data/lib/auth/logic/i18n/translator.rb +20 -0
  35. data/lib/auth/logic/i18n.rb +100 -0
  36. data/lib/auth/logic/random.rb +18 -0
  37. data/lib/auth/logic/session/base.rb +2205 -0
  38. data/lib/auth/logic/session/magic_column/assigns_last_request_at.rb +49 -0
  39. data/lib/auth/logic/test_case/mock_api_controller.rb +53 -0
  40. data/lib/auth/logic/test_case/mock_controller.rb +59 -0
  41. data/lib/auth/logic/test_case/mock_cookie_jar.rb +112 -0
  42. data/lib/auth/logic/test_case/mock_logger.rb +14 -0
  43. data/lib/auth/logic/test_case/mock_request.rb +36 -0
  44. data/lib/auth/logic/test_case/rails_request_adapter.rb +40 -0
  45. data/lib/auth/logic/test_case.rb +216 -0
  46. data/lib/auth/logic/version.rb +7 -0
  47. data/lib/auth/logic.rb +46 -0
  48. metadata +426 -0
@@ -0,0 +1,362 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Authentication
4
+ module Logic
5
+ module ActsAsAuthentic
6
+ # This module has a lot of neat functionality. It is responsible for encrypting your
7
+ # password, salting it, and verifying it. It can also help you transition to a new
8
+ # encryption algorithm. See the Config sub module for configuration options.
9
+ module Password
10
+ def self.included(klass)
11
+ klass.class_eval do
12
+ extend Config
13
+ add_acts_as_authentic_module(Callbacks)
14
+ add_acts_as_authentic_module(Methods)
15
+ end
16
+ end
17
+
18
+ # All configuration for the password aspect of acts_as_authentic.
19
+ module Config
20
+ # The name of the crypted_password field in the database.
21
+ #
22
+ # * <tt>Default:</tt> :crypted_password, :encrypted_password, :password_hash, or :pw_hash
23
+ # * <tt>Accepts:</tt> Symbol
24
+ def crypted_password_field(value = nil)
25
+ rw_config(
26
+ :crypted_password_field,
27
+ value,
28
+ first_column_to_exist(
29
+ nil,
30
+ :crypted_password,
31
+ :encrypted_password,
32
+ :password_hash,
33
+ :pw_hash
34
+ )
35
+ )
36
+ end
37
+ alias crypted_password_field= crypted_password_field
38
+
39
+ # The name of the password_salt field in the database.
40
+ #
41
+ # * <tt>Default:</tt> :password_salt, :pw_salt, :salt, nil if none exist
42
+ # * <tt>Accepts:</tt> Symbol
43
+ def password_salt_field(value = nil)
44
+ rw_config(
45
+ :password_salt_field,
46
+ value,
47
+ first_column_to_exist(nil, :password_salt, :pw_salt, :salt)
48
+ )
49
+ end
50
+ alias password_salt_field= password_salt_field
51
+
52
+ # Whether or not to require a password confirmation. If you don't want your users
53
+ # to confirm their password just set this to false.
54
+ #
55
+ # * <tt>Default:</tt> true
56
+ # * <tt>Accepts:</tt> Boolean
57
+ def require_password_confirmation(value = nil)
58
+ rw_config(:require_password_confirmation, value, true)
59
+ end
60
+ alias require_password_confirmation= require_password_confirmation
61
+
62
+ # By default passwords are required when a record is new or the crypted_password
63
+ # is blank, but if both of these things are met a password is not required. In
64
+ # this case, blank passwords are ignored.
65
+ #
66
+ # Think about a profile page, where the user can edit all of their information,
67
+ # including changing their password. If they do not want to change their password
68
+ # they just leave the fields blank. This will try to set the password to a blank
69
+ # value, in which case is incorrect behavior. As such, Authentication::Logic ignores this. But
70
+ # let's say you have a completely separate page for resetting passwords, you might
71
+ # not want to ignore blank passwords. If this is the case for you, then just set
72
+ # this value to false.
73
+ #
74
+ # * <tt>Default:</tt> true
75
+ # * <tt>Accepts:</tt> Boolean
76
+ def ignore_blank_passwords(value = nil)
77
+ rw_config(:ignore_blank_passwords, value, true)
78
+ end
79
+ alias ignore_blank_passwords= ignore_blank_passwords
80
+
81
+ # When calling valid_password?("some pass") do you want to check that password
82
+ # against what's in that object or whats in the database. Take this example:
83
+ #
84
+ # u = User.first
85
+ # u.password = "new pass"
86
+ # u.valid_password?("old pass")
87
+ #
88
+ # Should the last line above return true or false? The record hasn't been saved
89
+ # yet, so most would assume true. Other would assume false. So I let you decide by
90
+ # giving you this option.
91
+ #
92
+ # * <tt>Default:</tt> true
93
+ # * <tt>Accepts:</tt> Boolean
94
+ def check_passwords_against_database(value = nil)
95
+ rw_config(:check_passwords_against_database, value, true)
96
+ end
97
+ alias check_passwords_against_database= check_passwords_against_database
98
+
99
+ # The class you want to use to encrypt and verify your encrypted
100
+ # passwords. See the Authentication::Logic::CryptoProviders module for more info on
101
+ # the available methods and how to create your own.
102
+ #
103
+ # The family of adaptive hash functions (BCrypt, SCrypt, PBKDF2) is the
104
+ # best choice for password storage today. We recommend SCrypt. Other
105
+ # one-way functions like SHA512 are inferior, but widely used.
106
+ # Reversible functions like AES256 are the worst choice, and we no
107
+ # longer support them.
108
+ #
109
+ # You can use the `transition_from_crypto_providers` option to gradually
110
+ # transition to a better crypto provider without causing your users any
111
+ # pain.
112
+ #
113
+ # * <tt>Default:</tt> There is no longer a default value. Prior to
114
+ # Authentication::Logic 6, the default was `CryptoProviders::SCrypt`. If you try
115
+ # to read this config option before setting it, it will raise a
116
+ # `NilCryptoProvider` error. See that error's message for further
117
+ # details, and rationale for this change.
118
+ # * <tt>Accepts:</tt> Class
119
+ def crypto_provider
120
+ acts_as_authentic_config[:crypto_provider].tap do |provider|
121
+ raise NilCryptoProvider if provider.nil?
122
+ end
123
+ end
124
+
125
+ def crypto_provider=(value)
126
+ raise NilCryptoProvider if value.nil?
127
+
128
+ CryptoProviders::Guidance.new(value).impart_wisdom
129
+ rw_config(:crypto_provider, value)
130
+ end
131
+
132
+ # Let's say you originally encrypted your passwords with Sha1. Sha1 is
133
+ # starting to join the party with MD5 and you want to switch to
134
+ # something stronger. No problem, just specify your new and improved
135
+ # algorithm with the crypt_provider option and then let Authentication::Logic know
136
+ # you are transitioning from Sha1 using this option. Authentication::Logic will take
137
+ # care of everything, including transitioning your users to the new
138
+ # algorithm. The next time a user logs in, they will be granted access
139
+ # using the old algorithm and their password will be resaved with the
140
+ # new algorithm. All new users will obviously use the new algorithm as
141
+ # well.
142
+ #
143
+ # Lastly, if you want to transition again, you can pass an array of
144
+ # crypto providers. So you can transition from as many algorithms as you
145
+ # want.
146
+ #
147
+ # * <tt>Default:</tt> nil
148
+ # * <tt>Accepts:</tt> Class or Array
149
+ def transition_from_crypto_providers(value = nil)
150
+ rw_config(
151
+ :transition_from_crypto_providers,
152
+ (!value.nil? && [value].flatten.compact) || value,
153
+ []
154
+ )
155
+ end
156
+ alias transition_from_crypto_providers= transition_from_crypto_providers
157
+ end
158
+
159
+ # Callbacks / hooks to allow other modules to modify the behavior of this module.
160
+ module Callbacks
161
+ # Does the order of this array matter?
162
+ METHODS = %w[
163
+ password_set
164
+ password_verification
165
+ ].freeze
166
+
167
+ def self.included(klass)
168
+ return if klass.crypted_password_field.nil?
169
+
170
+ klass.send :extend, ActiveModel::Callbacks
171
+ METHODS.each do |method|
172
+ klass.define_model_callbacks method, only: %i[before after]
173
+ end
174
+ end
175
+ end
176
+
177
+ # The methods related to the password field.
178
+ module Methods
179
+ def self.included(klass)
180
+ return if klass.crypted_password_field.nil?
181
+
182
+ klass.class_eval do
183
+ include InstanceMethods
184
+ after_save :reset_password_changed
185
+ end
186
+ end
187
+
188
+ # :nodoc:
189
+ module InstanceMethods
190
+ # The password
191
+ def password
192
+ return nil unless defined?(@password)
193
+
194
+ @password
195
+ end
196
+
197
+ # This is a virtual method. Once a password is passed to it, it will
198
+ # create new password salt as well as encrypt the password.
199
+ def password=(pass)
200
+ return if ignore_blank_passwords? && pass.blank?
201
+
202
+ run_callbacks :password_set do
203
+ @password = pass
204
+ send("#{password_salt_field}=", Authentication::Logic::Random.friendly_token) if password_salt_field
205
+ send(
206
+ "#{crypted_password_field}=",
207
+ crypto_provider.encrypt(*encrypt_arguments(@password, false))
208
+ )
209
+ @password_changed = true
210
+ end
211
+ end
212
+
213
+ # Accepts a raw password to determine if it is the correct password.
214
+ #
215
+ # - attempted_password [String] - password entered by user
216
+ # - check_against_database [boolean] - Should we check the password
217
+ # against the value in the database or the value in the object?
218
+ # Default taken from config option check_passwords_against_database.
219
+ # See config method for more information.
220
+ def valid_password?(
221
+ attempted_password,
222
+ check_against_database = check_passwords_against_database?
223
+ )
224
+ crypted = crypted_password_to_validate_against(check_against_database)
225
+ return false if attempted_password.blank? || crypted.blank?
226
+
227
+ run_callbacks :password_verification do
228
+ crypto_providers.each_with_index.any? do |encryptor, index|
229
+ if encryptor_matches?(
230
+ crypted,
231
+ encryptor,
232
+ attempted_password,
233
+ check_against_database
234
+ )
235
+ if transition_password?(index, encryptor, check_against_database)
236
+ transition_password(attempted_password)
237
+ end
238
+ true
239
+ else
240
+ false
241
+ end
242
+ end
243
+ end
244
+ end
245
+
246
+ # Resets the password to a random friendly token.
247
+ def reset_password
248
+ friendly_token = Authentication::Logic::Random.friendly_token
249
+ self.password = friendly_token
250
+ self.password_confirmation = friendly_token if self.class.require_password_confirmation
251
+ end
252
+ alias randomize_password reset_password
253
+
254
+ # Resets the password to a random friendly token and then saves the record.
255
+ def reset_password!
256
+ reset_password
257
+ save_without_session_maintenance(validate: false)
258
+ end
259
+ alias randomize_password! reset_password!
260
+
261
+ private
262
+
263
+ def crypted_password_to_validate_against(check_against_database)
264
+ if check_against_database && send("will_save_change_to_#{crypted_password_field}?")
265
+ send("#{crypted_password_field}_in_database")
266
+ else
267
+ send(crypted_password_field)
268
+ end
269
+ end
270
+
271
+ def check_passwords_against_database?
272
+ self.class.check_passwords_against_database == true
273
+ end
274
+
275
+ def crypto_providers
276
+ [crypto_provider] + transition_from_crypto_providers
277
+ end
278
+
279
+ # Returns an array of arguments to be passed to a crypto provider, either its
280
+ # `matches?` or its `encrypt` method.
281
+ def encrypt_arguments(raw_password, check_against_database)
282
+ salt = nil
283
+ if password_salt_field
284
+ salt =
285
+ if check_against_database && send("will_save_change_to_#{password_salt_field}?")
286
+ send("#{password_salt_field}_in_database")
287
+ else
288
+ send(password_salt_field)
289
+ end
290
+ end
291
+ [raw_password, salt].compact
292
+ end
293
+
294
+ # Given `encryptor`, does `attempted_password` match the `crypted` password?
295
+ def encryptor_matches?(crypted, encryptor, attempted_password, check_against_database)
296
+ encryptor_args = encrypt_arguments(attempted_password, check_against_database)
297
+ encryptor.matches?(crypted, *encryptor_args)
298
+ end
299
+
300
+ # Determines if we need to transition the password.
301
+ #
302
+ # - If the index > 0 then we are using a "transition from" crypto
303
+ # provider.
304
+ # - If the encryptor has a cost and the cost it outdated.
305
+ # - If we aren't using database values
306
+ # - If we are using database values, only if the password hasn't
307
+ # changed so we don't overwrite any changes
308
+ def transition_password?(index, encryptor, check_against_database)
309
+ (
310
+ index.positive? ||
311
+ (encryptor.respond_to?(:cost_matches?) &&
312
+ !encryptor.cost_matches?(send(crypted_password_field)))
313
+ ) &&
314
+ (
315
+ !check_against_database ||
316
+ !send("will_save_change_to_#{crypted_password_field}?")
317
+ )
318
+ end
319
+
320
+ def transition_password(attempted_password)
321
+ self.password = attempted_password
322
+ save(validate: false)
323
+ end
324
+
325
+ def require_password?
326
+ # this is _not_ the activemodel changed? method, see below
327
+ new_record? || password_changed? || send(crypted_password_field).blank?
328
+ end
329
+
330
+ def ignore_blank_passwords?
331
+ self.class.ignore_blank_passwords == true
332
+ end
333
+
334
+ def password_changed?
335
+ defined?(@password_changed) && @password_changed == true
336
+ end
337
+
338
+ def reset_password_changed
339
+ @password_changed = nil
340
+ end
341
+
342
+ def crypted_password_field
343
+ self.class.crypted_password_field
344
+ end
345
+
346
+ def password_salt_field
347
+ self.class.password_salt_field
348
+ end
349
+
350
+ def crypto_provider
351
+ self.class.crypto_provider
352
+ end
353
+
354
+ def transition_from_crypto_providers
355
+ self.class.transition_from_crypto_providers
356
+ end
357
+ end
358
+ end
359
+ end
360
+ end
361
+ end
362
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Authentication
4
+ module Logic
5
+ module ActsAsAuthentic
6
+ # This provides a handy token that is "perishable", meaning the token is
7
+ # only good for a certain amount of time.
8
+ #
9
+ # This is useful for resetting password, confirming accounts, etc. Typically
10
+ # during these actions you send them this token in an email. Once they use
11
+ # the token and do what they need to do, that token should expire.
12
+ #
13
+ # Don't worry about maintaining the token, changing it, or expiring it
14
+ # yourself. Authentication::Logic does all of this for you. See the sub modules for all
15
+ # of the tools Authentication::Logic provides to you.
16
+ module PerishableToken
17
+ def self.included(klass)
18
+ klass.class_eval do
19
+ extend Config
20
+ add_acts_as_authentic_module(Methods)
21
+ end
22
+ end
23
+
24
+ # Configure the perishable token.
25
+ module Config
26
+ # When using the find_using_perishable_token method the token can
27
+ # expire. If the token is expired, no record will be returned. Use this
28
+ # option to specify how long the token is valid for.
29
+ #
30
+ # * <tt>Default:</tt> 10.minutes
31
+ # * <tt>Accepts:</tt> Fixnum
32
+ def perishable_token_valid_for(value = nil)
33
+ rw_config(
34
+ :perishable_token_valid_for,
35
+ (!value.nil? && value.to_i) || value,
36
+ 10.minutes.to_i
37
+ )
38
+ end
39
+ alias perishable_token_valid_for= perishable_token_valid_for
40
+
41
+ # Authentication::Logic tries to expire and change the perishable token as much as
42
+ # possible, without compromising its purpose. If you want to manage it
43
+ # yourself, set this to true.
44
+ #
45
+ # * <tt>Default:</tt> false
46
+ # * <tt>Accepts:</tt> Boolean
47
+ def disable_perishable_token_maintenance(value = nil)
48
+ rw_config(:disable_perishable_token_maintenance, value, false)
49
+ end
50
+ alias disable_perishable_token_maintenance= disable_perishable_token_maintenance
51
+ end
52
+
53
+ # All methods relating to the perishable token.
54
+ module Methods
55
+ def self.included(klass)
56
+ return unless klass.column_names.include?("perishable_token")
57
+
58
+ klass.class_eval do
59
+ extend ClassMethods
60
+ include InstanceMethods
61
+
62
+ validates_uniqueness_of :perishable_token, case_sensitive: true,
63
+ if: :will_save_change_to_perishable_token?
64
+ before_save :reset_perishable_token, unless: :disable_perishable_token_maintenance?
65
+ end
66
+ end
67
+
68
+ # :nodoc:
69
+ module ClassMethods
70
+ # Use this method to find a record with a perishable token. This
71
+ # method does 2 things for you:
72
+ #
73
+ # 1. It ignores blank tokens
74
+ # 2. It enforces the perishable_token_valid_for configuration option.
75
+ #
76
+ # If you want to use a different timeout value, just pass it as the
77
+ # second parameter:
78
+ #
79
+ # User.find_using_perishable_token(token, 1.hour)
80
+ def find_using_perishable_token(token, age = perishable_token_valid_for)
81
+ return if token.blank?
82
+
83
+ age = age.to_i
84
+
85
+ conditions_sql = "perishable_token = ?"
86
+ conditions_subs = [token.to_s]
87
+
88
+ if column_names.include?("updated_at") && age.positive?
89
+ conditions_sql += " and updated_at > ?"
90
+ conditions_subs << age.seconds.ago
91
+ end
92
+
93
+ where(conditions_sql, *conditions_subs).first
94
+ end
95
+
96
+ # This method will raise ActiveRecord::NotFound if no record is found.
97
+ def find_using_perishable_token!(token, age = perishable_token_valid_for)
98
+ find_using_perishable_token(token, age) || raise(ActiveRecord::RecordNotFound)
99
+ end
100
+ end
101
+
102
+ # :nodoc:
103
+ module InstanceMethods
104
+ # Resets the perishable token to a random friendly token.
105
+ def reset_perishable_token
106
+ self.perishable_token = Random.friendly_token
107
+ end
108
+
109
+ # Same as reset_perishable_token, but then saves the record afterwards.
110
+ def reset_perishable_token!
111
+ reset_perishable_token
112
+ save_without_session_maintenance(validate: false)
113
+ end
114
+
115
+ # A convenience method based on the
116
+ # disable_perishable_token_maintenance configuration option.
117
+ def disable_perishable_token_maintenance?
118
+ self.class.disable_perishable_token_maintenance == true
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Authentication
4
+ module Logic
5
+ module ActsAsAuthentic
6
+ # Maintains the persistence token, the token responsible for persisting sessions. This token
7
+ # gets stored in the session and the cookie.
8
+ module PersistenceToken
9
+ def self.included(klass)
10
+ klass.class_eval do
11
+ add_acts_as_authentic_module(Methods)
12
+ end
13
+ end
14
+
15
+ # Methods for the persistence token.
16
+ module Methods
17
+ def self.included(klass)
18
+ klass.class_eval do
19
+ extend ClassMethods
20
+ include InstanceMethods
21
+
22
+ # If the table does not have a password column, then
23
+ # `after_password_set` etc. will not be defined. See
24
+ # `Authentication::Logic::ActsAsAuthentic::Password::Callbacks.included`
25
+ if respond_to?(:after_password_set) && respond_to?(:after_password_verification)
26
+ after_password_set :reset_persistence_token
27
+ after_password_verification :reset_persistence_token!, if: :reset_persistence_token?
28
+ end
29
+
30
+ validates_presence_of :persistence_token
31
+ validates_uniqueness_of :persistence_token, case_sensitive: true,
32
+ if: :will_save_change_to_persistence_token?
33
+
34
+ before_validation :reset_persistence_token, if: :reset_persistence_token?
35
+ end
36
+ end
37
+
38
+ # :nodoc:
39
+ module ClassMethods
40
+ # Resets ALL persistence tokens in the database, which will require
41
+ # all users to re-authenticate.
42
+ def forget_all
43
+ # Paginate these to save on memory
44
+ find_each(batch_size: 50, &:forget!)
45
+ end
46
+ end
47
+
48
+ # :nodoc:
49
+ module InstanceMethods
50
+ # Resets the persistence_token field to a random hex value.
51
+ def reset_persistence_token
52
+ self.persistence_token = Authentication::Logic::Random.hex_token
53
+ end
54
+
55
+ # Same as reset_persistence_token, but then saves the record.
56
+ def reset_persistence_token!
57
+ reset_persistence_token
58
+ save_without_session_maintenance(validate: false)
59
+ end
60
+ alias forget! reset_persistence_token!
61
+
62
+ private
63
+
64
+ def reset_persistence_token?
65
+ persistence_token.blank?
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Authentication
4
+ module Logic
5
+ module ActsAsAuthentic
6
+ module Queries
7
+ # @api private
8
+ class CaseSensitivity
9
+ E_UNABLE_TO_DETERMINE_SENSITIVITY = <<~EOS
10
+ Authentication::Logic was unable to determine what case-sensitivity to use when
11
+ searching for email/login. To specify a sensitivity, validate the
12
+ uniqueness of the email/login and use the `case_sensitive` option,
13
+ like this:
14
+
15
+ validates :email, uniqueness: { case_sensitive: false }
16
+
17
+ Authentication::Logic will now perform a case-insensitive query.
18
+ EOS
19
+
20
+ # @api private
21
+ def initialize(model_class, attribute)
22
+ @model_class = model_class
23
+ @attribute = attribute.to_sym
24
+ end
25
+
26
+ # @api private
27
+ def sensitive?
28
+ sensitive = uniqueness_validator_options[:case_sensitive]
29
+ if sensitive.nil?
30
+ ::Kernel.warn(E_UNABLE_TO_DETERMINE_SENSITIVITY)
31
+ false
32
+ else
33
+ sensitive
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ # @api private
40
+ def uniqueness_validator
41
+ @model_class.validators.select do |v|
42
+ v.is_a?(::ActiveRecord::Validations::UniquenessValidator) &&
43
+ v.attributes == [@attribute]
44
+ end.first
45
+ end
46
+
47
+ # @api private
48
+ def uniqueness_validator_options
49
+ uniqueness_validator&.options || {}
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Authentication
4
+ module Logic
5
+ module ActsAsAuthentic
6
+ module Queries
7
+ # The query used by public-API method `find_by_smart_case_login_field`.
8
+ #
9
+ # We use the rails methods `case_insensitive_comparison` and
10
+ # `case_sensitive_comparison`. These methods nicely take into account
11
+ # MySQL collations. (Consider the case where a user *says* they want a
12
+ # case-sensitive uniqueness validation, but then they configure their
13
+ # database to have an insensitive collation. Rails will handle this for
14
+ # us, by downcasing, see
15
+ # `active_record/connection_adapters/abstract_mysql_adapter.rb`) So that's
16
+ # great! But, these methods are not part of rails' public API, so there
17
+ # are no docs. So, everything we know about how to use the methods
18
+ # correctly comes from mimicing what we find in
19
+ # `active_record/validations/uniqueness.rb`.
20
+ #
21
+ # @api private
22
+ class FindWithCase
23
+ # Dup ActiveRecord.gem_version before freezing, in case someone
24
+ # else wants to modify it. Freezing modifies an object in place.
25
+ # https://github.com/binarylogic/authlogic/pull/590
26
+ AR_GEM_VERSION = ::ActiveRecord.gem_version.dup.freeze
27
+
28
+ # @api private
29
+ def initialize(model_class, field, value, sensitive)
30
+ @model_class = model_class
31
+ @field = field.to_s
32
+ @value = value
33
+ @sensitive = sensitive
34
+ end
35
+
36
+ # @api private
37
+ def execute
38
+ @model_class.where(comparison).first
39
+ end
40
+
41
+ private
42
+
43
+ # @api private
44
+ # @return Arel::Nodes::Equality
45
+ def comparison
46
+ @sensitive ? sensitive_comparison : insensitive_comparison
47
+ end
48
+
49
+ # @api private
50
+ def insensitive_comparison
51
+ if Gem::Version.new("5.3") < AR_GEM_VERSION
52
+ @model_class.connection.case_insensitive_comparison(
53
+ @model_class.arel_table[@field], @value
54
+ )
55
+ else
56
+ @model_class.connection.case_insensitive_comparison(
57
+ @model_class.arel_table,
58
+ @field,
59
+ @model_class.columns_hash[@field],
60
+ @value
61
+ )
62
+ end
63
+ end
64
+
65
+ # @api private
66
+ def sensitive_comparison
67
+ bound_value = @model_class.predicate_builder.build_bind_attribute(@field, @value)
68
+ if Gem::Version.new("5.3") < AR_GEM_VERSION
69
+ @model_class.connection.case_sensitive_comparison(
70
+ @model_class.arel_table[@field], bound_value
71
+ )
72
+ else
73
+ @model_class.connection.case_sensitive_comparison(
74
+ @model_class.arel_table,
75
+ @field,
76
+ @model_class.columns_hash[@field],
77
+ bound_value
78
+ )
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end