diff --git a/.gitignore b/.gitignore index ac2a95781c..71aec37b11 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ pkg log test/tmp/* gemfiles/*.lock +.DS_Store \ No newline at end of file diff --git a/lib/devise.rb b/lib/devise.rb index ede8038679..0971e78e97 100644 --- a/lib/devise.rb +++ b/lib/devise.rb @@ -39,6 +39,7 @@ module Mailers module Strategies autoload :Base, 'devise/strategies/base' + autoload :PasswordAuthenticatable, 'devise/strategies/password_authenticatable' autoload :Authenticatable, 'devise/strategies/authenticatable' end diff --git a/lib/devise/models/database_authenticatable.rb b/lib/devise/models/database_authenticatable.rb index 8c0e22613d..c8d7abf520 100644 --- a/lib/devise/models/database_authenticatable.rb +++ b/lib/devise/models/database_authenticatable.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'devise/strategies/database_authenticatable' +require 'devise/strategies/database_password_authenticatable' module Devise module Models diff --git a/lib/devise/models/rememberable.rb b/lib/devise/models/rememberable.rb index a66979ad59..d137cf5246 100644 --- a/lib/devise/models/rememberable.rb +++ b/lib/devise/models/rememberable.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'devise/strategies/rememberable' +require 'devise/strategies/password_rememberable' require 'devise/hooks/rememberable' require 'devise/hooks/forgetable' diff --git a/lib/devise/strategies/authenticatable.rb b/lib/devise/strategies/authenticatable.rb index 2af7a741cf..84d04ded0b 100644 --- a/lib/devise/strategies/authenticatable.rb +++ b/lib/devise/strategies/authenticatable.rb @@ -1,178 +1,15 @@ # frozen_string_literal: true -require 'devise/strategies/base' +require 'devise/strategies/password_authenticatable' module Devise module Strategies - # This strategy should be used as basis for authentication strategies. It retrieves - # parameters both from params or from http authorization headers. See database_authenticatable - # for an example. - class Authenticatable < Base - attr_accessor :authentication_hash, :authentication_type, :password - - def store? - super && !mapping.to.skip_session_storage.include?(authentication_type) - end - - def valid? - valid_for_params_auth? || valid_for_http_auth? - end - - # Override and set to false for things like OmniAuth that technically - # run through Authentication (user_set) very often, which would normally - # reset CSRF data in the session - def clean_up_csrf? - true - end - - private - - # Receives a resource and check if it is valid by calling valid_for_authentication? - # A block that will be triggered while validating can be optionally - # given as parameter. Check Devise::Models::Authenticatable.valid_for_authentication? - # for more information. - # - # In case the resource can't be validated, it will fail with the given - # unauthenticated_message. - def validate(resource, &block) - result = resource && resource.valid_for_authentication?(&block) - - if result - true - else - if resource - fail!(resource.unauthenticated_message) - end - false - end - end - - # Get values from params and set in the resource. - def remember_me(resource) - resource.remember_me = remember_me? if resource.respond_to?(:remember_me=) - end - - # Should this resource be marked to be remembered? - def remember_me? - valid_params? && Devise::TRUE_VALUES.include?(params_auth_hash[:remember_me]) - end - - # Check if this is a valid strategy for http authentication by: - # - # * Validating if the model allows http authentication; - # * If any of the authorization headers were sent; - # * If all authentication keys are present; - # - def valid_for_http_auth? - http_authenticatable? && request.authorization && with_authentication_hash(:http_auth, http_auth_hash) - end - - # Check if this is a valid strategy for params authentication by: - # - # * Validating if the model allows params authentication; - # * If the request hits the sessions controller through POST; - # * If the params[scope] returns a hash with credentials; - # * If all authentication keys are present; - # - def valid_for_params_auth? - params_authenticatable? && valid_params_request? && - valid_params? && with_authentication_hash(:params_auth, params_auth_hash) - end - - # Check if the model accepts this strategy as http authenticatable. - def http_authenticatable? - mapping.to.http_authenticatable?(authenticatable_name) - end - - # Check if the model accepts this strategy as params authenticatable. - def params_authenticatable? - mapping.to.params_authenticatable?(authenticatable_name) - end - - # Extract the appropriate subhash for authentication from params. - def params_auth_hash - params[scope] - end - - # Extract a hash with attributes:values from the http params. - def http_auth_hash - keys = [http_authentication_key, :password] - Hash[*keys.zip(decode_credentials).flatten] - end - - # By default, a request is valid if the controller set the proper env variable. - def valid_params_request? - !!env["devise.allow_params_authentication"] - end - - # If the request is valid, finally check if params_auth_hash returns a hash. - def valid_params? - params_auth_hash.is_a?(Hash) - end - - # Note: unlike `Model.valid_password?`, this method does not actually - # ensure that the password in the params matches the password stored in - # the database. It only checks if the password is *present*. Do not rely - # on this method for validating that a given password is correct. - def valid_password? - password.present? - end - - # Helper to decode credentials from HTTP. - def decode_credentials - return [] unless request.authorization && request.authorization =~ /^Basic (.*)/mi - Base64.decode64($1).split(/:/, 2) - end - - # Sets the authentication hash and the password from params_auth_hash or http_auth_hash. - def with_authentication_hash(auth_type, auth_values) - self.authentication_hash, self.authentication_type = {}, auth_type - self.password = auth_values[:password] - - parse_authentication_key_values(auth_values, authentication_keys) && - parse_authentication_key_values(request_values, request_keys) - end - - def authentication_keys - @authentication_keys ||= mapping.to.authentication_keys - end - - def http_authentication_key - @http_authentication_key ||= mapping.to.http_authentication_key || case authentication_keys - when Array then authentication_keys.first - when Hash then authentication_keys.keys.first - end - end - - def request_keys - @request_keys ||= mapping.to.request_keys - end - - def request_values - keys = request_keys.respond_to?(:keys) ? request_keys.keys : request_keys - values = keys.map { |k| self.request.send(k) } - Hash[keys.zip(values)] - end - - def parse_authentication_key_values(hash, keys) - keys.each do |key, enforce| - value = hash[key].presence - if value - self.authentication_hash[key] = value - else - return false unless enforce == false - end - end - true - end - - # Holds the authenticatable name for this class. Devise::Strategies::DatabaseAuthenticatable - # becomes simply :database. - def authenticatable_name - @authenticatable_name ||= - ActiveSupport::Inflector.underscore(self.class.name.split("::").last). - sub("_authenticatable", "").to_sym - end + class Authenticatable < PasswordAuthenticatable + ActiveSupport::Deprecation.warn <<-DEPRECATION.strip_heredoc + [Devise] `Devise::Strategies::Authenticatable` is deprecated and will be + removed in the next major version. + Use `Devise::Strategies::PasswordAuthenticatable` instead. + DEPRECATION end end -end +end \ No newline at end of file diff --git a/lib/devise/strategies/database_authenticatable.rb b/lib/devise/strategies/database_authenticatable.rb index f7e007d144..6b3b1413f5 100644 --- a/lib/devise/strategies/database_authenticatable.rb +++ b/lib/devise/strategies/database_authenticatable.rb @@ -1,31 +1,15 @@ # frozen_string_literal: true -require 'devise/strategies/authenticatable' +require 'devise/strategies/database_password_authenticatable' module Devise module Strategies - # Default strategy for signing in a user, based on their email and password in the database. - class DatabaseAuthenticatable < Authenticatable - def authenticate! - resource = password.present? && mapping.to.find_for_database_authentication(authentication_hash) - hashed = false - - if validate(resource){ hashed = true; resource.valid_password?(password) } - remember_me(resource) - resource.after_database_authentication - success!(resource) - end - - # In paranoid mode, hash the password even when a resource doesn't exist for the given authentication key. - # This is necessary to prevent enumeration attacks - e.g. the request is faster when a resource doesn't - # exist in the database if the password hashing algorithm is not called. - mapping.to.new.password = password if !hashed && Devise.paranoid - unless resource - Devise.paranoid ? fail(:invalid) : fail(:not_found_in_database) - end - end + class DatabaseAuthenticatable < DatabasePasswordAuthenticatable + ActiveSupport::Deprecation.warn <<-DEPRECATION.strip_heredoc + [Devise] `Devise::Strategies::DatabaseAuthenticatable` is deprecated and will be + removed in the next major version. + Use `Devise::Strategies::DatabasePasswordAuthenticatable` instead. + DEPRECATION end end -end - -Warden::Strategies.add(:database_authenticatable, Devise::Strategies::DatabaseAuthenticatable) +end \ No newline at end of file diff --git a/lib/devise/strategies/database_password_authenticatable.rb b/lib/devise/strategies/database_password_authenticatable.rb new file mode 100644 index 0000000000..04357944af --- /dev/null +++ b/lib/devise/strategies/database_password_authenticatable.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'devise/strategies/password_authenticatable' + +module Devise + module Strategies + # Default strategy for signing in a user, based on their email and password in the database. + class DatabasePasswordAuthenticatable < PasswordAuthenticatable + def authenticate! + resource = password.present? && mapping.to.find_for_database_authentication(authentication_hash) + hashed = false + + if validate(resource){ hashed = true; resource.valid_password?(password) } + remember_me(resource) + resource.after_database_authentication + success!(resource) + end + + # In paranoid mode, hash the password even when a resource doesn't exist for the given authentication key. + # This is necessary to prevent enumeration attacks - e.g. the request is faster when a resource doesn't + # exist in the database if the password hashing algorithm is not called. + mapping.to.new.password = password if !hashed && Devise.paranoid + unless resource + Devise.paranoid ? fail(:invalid) : fail(:not_found_in_database) + end + end + end + end +end + +Warden::Strategies.add(:database_authenticatable, Devise::Strategies::DatabasePasswordAuthenticatable) diff --git a/lib/devise/strategies/password_authenticatable.rb b/lib/devise/strategies/password_authenticatable.rb new file mode 100644 index 0000000000..7ed0ce6594 --- /dev/null +++ b/lib/devise/strategies/password_authenticatable.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require 'devise/strategies/base' + +module Devise + module Strategies + # This strategy should be used as basis for authentication strategies. It retrieves + # parameters both from params or from http authorization headers. See database_authenticatable + # for an example. + class PasswordAuthenticatable < Base + attr_accessor :authentication_hash, :authentication_type, :password + + def store? + super && !mapping.to.skip_session_storage.include?(authentication_type) + end + + def valid? + valid_for_params_auth? || valid_for_http_auth? + end + + # Override and set to false for things like OmniAuth that technically + # run through Authentication (user_set) very often, which would normally + # reset CSRF data in the session + def clean_up_csrf? + true + end + + private + + # Receives a resource and check if it is valid by calling valid_for_authentication? + # A block that will be triggered while validating can be optionally + # given as parameter. Check Devise::Models::Authenticatable.valid_for_authentication? + # for more information. + # + # In case the resource can't be validated, it will fail with the given + # unauthenticated_message. + def validate(resource, &block) + result = resource && resource.valid_for_authentication?(&block) + + if result + true + else + if resource + fail!(resource.unauthenticated_message) + end + false + end + end + + # Get values from params and set in the resource. + def remember_me(resource) + resource.remember_me = remember_me? if resource.respond_to?(:remember_me=) + end + + # Should this resource be marked to be remembered? + def remember_me? + valid_params? && Devise::TRUE_VALUES.include?(params_auth_hash[:remember_me]) + end + + # Check if this is a valid strategy for http authentication by: + # + # * Validating if the model allows http authentication; + # * If any of the authorization headers were sent; + # * If all authentication keys are present; + # + def valid_for_http_auth? + http_authenticatable? && request.authorization && with_authentication_hash(:http_auth, http_auth_hash) + end + + # Check if this is a valid strategy for params authentication by: + # + # * Validating if the model allows params authentication; + # * If the request hits the sessions controller through POST; + # * If the params[scope] returns a hash with credentials; + # * If all authentication keys are present; + # + def valid_for_params_auth? + params_authenticatable? && valid_params_request? && + valid_params? && with_authentication_hash(:params_auth, params_auth_hash) + end + + # Check if the model accepts this strategy as http authenticatable. + def http_authenticatable? + mapping.to.http_authenticatable?(authenticatable_name) + end + + # Check if the model accepts this strategy as params authenticatable. + def params_authenticatable? + mapping.to.params_authenticatable?(authenticatable_name) + end + + # Extract the appropriate subhash for authentication from params. + def params_auth_hash + params[scope] + end + + # Extract a hash with attributes:values from the http params. + def http_auth_hash + keys = [http_authentication_key, :password] + Hash[*keys.zip(decode_credentials).flatten] + end + + # By default, a request is valid if the controller set the proper env variable. + def valid_params_request? + !!env["devise.allow_params_authentication"] + end + + # If the request is valid, finally check if params_auth_hash returns a hash. + def valid_params? + params_auth_hash.is_a?(Hash) + end + + # Note: unlike `Model.valid_password?`, this method does not actually + # ensure that the password in the params matches the password stored in + # the database. It only checks if the password is *present*. Do not rely + # on this method for validating that a given password is correct. + def valid_password? + password.present? + end + + # Helper to decode credentials from HTTP. + def decode_credentials + return [] unless request.authorization && request.authorization =~ /^Basic (.*)/mi + Base64.decode64($1).split(/:/, 2) + end + + # Sets the authentication hash and the password from params_auth_hash or http_auth_hash. + def with_authentication_hash(auth_type, auth_values) + self.authentication_hash, self.authentication_type = {}, auth_type + self.password = auth_values[:password] + + parse_authentication_key_values(auth_values, authentication_keys) && + parse_authentication_key_values(request_values, request_keys) + end + + def authentication_keys + @authentication_keys ||= mapping.to.authentication_keys + end + + def http_authentication_key + @http_authentication_key ||= mapping.to.http_authentication_key || case authentication_keys + when Array then authentication_keys.first + when Hash then authentication_keys.keys.first + end + end + + def request_keys + @request_keys ||= mapping.to.request_keys + end + + def request_values + keys = request_keys.respond_to?(:keys) ? request_keys.keys : request_keys + values = keys.map { |k| self.request.send(k) } + Hash[keys.zip(values)] + end + + def parse_authentication_key_values(hash, keys) + keys.each do |key, enforce| + value = hash[key].presence + if value + self.authentication_hash[key] = value + else + return false unless enforce == false + end + end + true + end + + # Holds the authenticatable name for this class. Devise::Strategies::DatabaseAuthenticatable + # becomes simply :database. + def authenticatable_name + @authenticatable_name ||= + ActiveSupport::Inflector.underscore(self.class.name.split("::").last). + sub("_authenticatable", "").to_sym + end + end + end +end diff --git a/lib/devise/strategies/password_rememberable.rb b/lib/devise/strategies/password_rememberable.rb new file mode 100644 index 0000000000..aefb067a35 --- /dev/null +++ b/lib/devise/strategies/password_rememberable.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'devise/strategies/password_authenticatable' + +module Devise + module Strategies + # Remember the user through the remember token. This strategy is responsible + # to verify whether there is a cookie with the remember token, and to + # recreate the user from this cookie if it exists. Must be called *before* + # authenticatable. + class PasswordRememberable < PasswordAuthenticatable + # A valid strategy for rememberable needs a remember token in the cookies. + def valid? + @remember_cookie = nil + remember_cookie.present? + end + + # To authenticate a user we deserialize the cookie and attempt finding + # the record in the database. If the attempt fails, we pass to another + # strategy handle the authentication. + def authenticate! + resource = mapping.to.serialize_from_cookie(*remember_cookie) + + unless resource + cookies.delete(remember_key) + return pass + end + + if validate(resource) + remember_me(resource) if extend_remember_me?(resource) + resource.after_remembered + success!(resource) + end + end + + # No need to clean up the CSRF when using rememberable. + # In fact, cleaning it up here would be a bug because + # rememberable is triggered on GET requests which means + # we would render a page on first access with all csrf + # tokens expired. + def clean_up_csrf? + false + end + + private + + def extend_remember_me?(resource) + resource.respond_to?(:extend_remember_period) && resource.extend_remember_period + end + + def remember_me? + true + end + + def remember_key + mapping.to.rememberable_options.fetch(:key, "remember_#{scope}_token") + end + + def remember_cookie + @remember_cookie ||= cookies.signed[remember_key] + end + + end + end +end + +Warden::Strategies.add(:rememberable, Devise::Strategies::PasswordRememberable) diff --git a/lib/devise/strategies/rememberable.rb b/lib/devise/strategies/rememberable.rb index fe20804f68..7425dc743d 100644 --- a/lib/devise/strategies/rememberable.rb +++ b/lib/devise/strategies/rememberable.rb @@ -1,67 +1,15 @@ # frozen_string_literal: true -require 'devise/strategies/authenticatable' +require 'devise/strategies/password_rememberable' module Devise module Strategies - # Remember the user through the remember token. This strategy is responsible - # to verify whether there is a cookie with the remember token, and to - # recreate the user from this cookie if it exists. Must be called *before* - # authenticatable. - class Rememberable < Authenticatable - # A valid strategy for rememberable needs a remember token in the cookies. - def valid? - @remember_cookie = nil - remember_cookie.present? - end - - # To authenticate a user we deserialize the cookie and attempt finding - # the record in the database. If the attempt fails, we pass to another - # strategy handle the authentication. - def authenticate! - resource = mapping.to.serialize_from_cookie(*remember_cookie) - - unless resource - cookies.delete(remember_key) - return pass - end - - if validate(resource) - remember_me(resource) if extend_remember_me?(resource) - resource.after_remembered - success!(resource) - end - end - - # No need to clean up the CSRF when using rememberable. - # In fact, cleaning it up here would be a bug because - # rememberable is triggered on GET requests which means - # we would render a page on first access with all csrf - # tokens expired. - def clean_up_csrf? - false - end - - private - - def extend_remember_me?(resource) - resource.respond_to?(:extend_remember_period) && resource.extend_remember_period - end - - def remember_me? - true - end - - def remember_key - mapping.to.rememberable_options.fetch(:key, "remember_#{scope}_token") - end - - def remember_cookie - @remember_cookie ||= cookies.signed[remember_key] - end - + class Rememberable < PasswordRememberable + ActiveSupport::Deprecation.warn <<-DEPRECATION.strip_heredoc + [Devise] `Devise::Strategies::Rememberable` is deprecated and will be + removed in the next major version. + Use `Devise::Strategies::PasswordRememberable` instead. + DEPRECATION end end -end - -Warden::Strategies.add(:rememberable, Devise::Strategies::Rememberable) +end \ No newline at end of file