authentication-logic 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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