diff --git a/CHANGELOG.md b/CHANGELOG.md index a720072c..4ef39d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Added - [#684](https://github.com/binarylogic/authlogic/pull/684) - Use cookies only when available. Support for `ActionController::API` + - [#728](https://github.com/binarylogic/authlogic/pull/728) - Allow + single_access_token to be supplied using Headers in addition to Params. - Fixed - [#725](https://github.com/binarylogic/authlogic/pull/725) - `NoMethodError` when setting `sign_cookie` or `encrypt_cookie` before `controller` is diff --git a/lib/authlogic/session/base.rb b/lib/authlogic/session/base.rb index 716b658a..2ba59f86 100644 --- a/lib/authlogic/session/base.rb +++ b/lib/authlogic/session/base.rb @@ -236,6 +236,20 @@ def initialize(session) # # You can modify all of this behavior with the Config sub module. # + # Headers + # ======= + # + # This module is responsible for authenticating the user via headers, which requires + # setting HTTP header, for example, the following curl command: + # + # curl -H "user_credentials: 4LiXF7FiGUppIPubBPey" https://www.domain.com + # + # Notice the token in the header parameter, this is a single access token. This header's + # method operates exactly as the params method. + # + # You can modify all of this behavior with the Config sub module. + # + # Disabled by default. To enable, set a non-nil headers_key. # Perishable Token # ================ # @@ -410,6 +424,7 @@ def self.#{method}(*filter_list, &block) # `persist` callbacks, in order of priority persist :persist_by_params + persist :persist_by_headers persist :persist_by_cookie persist :persist_by_session persist :persist_by_http_auth, if: :persist_by_http_auth? @@ -857,6 +872,24 @@ def params_key(value = nil) end alias params_key= params_key + # Works exactly like cookie_key, but for headers. So a user can login via + # headers just like a cookie or a session. Your URL would look like: + # + # curl -H "user_credentials: 4LiXF7FiGUppIPubBPey" https://www.domain.com + # + # You can change the "user_credentials" key above with this + # configuration option. Keep in mind, just like cookie_key, if you + # supply an id the id will be appended to the front. Check out + # cookie_key for more details. Also checkout the "Single Access / + # Private Feeds Access" section in the README. + # + # * Default: nil + # * Accepts: String + def headers_key(value = nil) + rw_config(:headers_key, value) + end + alias headers_key= headers_key + # Works exactly like login_field, but for the password instead. Returns # :password if a login_field exists. # @@ -1876,21 +1909,33 @@ def params_credentials controller.params[params_key] end + def headers_credentials + # Setting headers_key to nil is the accepted way to disable + # single_access_token in headers. + return nil if headers_key.nil? + controller.request.headers[headers_key] + end + def params_enabled? - if !params_credentials || !klass.column_names.include?("single_access_token") - return false - end - if controller.responds_to_single_access_allowed? - return controller.single_access_allowed? - end - params_enabled_by_allowed_request_types? + params_credentials && single_access_token_enabled? + end + + def headers_enabled? + headers_credentials && single_access_token_enabled? + end + + def single_access_token_enabled? + return false unless klass.column_names.include?("single_access_token") + return controller.single_access_allowed? if controller.responds_to_single_access_allowed? + + single_access_token_allowed_by_request_type? end - def params_enabled_by_allowed_request_types? - case single_access_allowed_request_types - when Array - single_access_allowed_request_types.include?(controller.request_content_type) || - single_access_allowed_request_types.include?(:all) + def single_access_token_allowed_by_request_type? + if single_access_allowed_request_types.is_a?(Array) + Set.new(single_access_allowed_request_types).intersect?( + Set[controller.request_content_type, :all] + ) else %i[all any].include?(single_access_allowed_request_types) end @@ -1900,6 +1945,16 @@ def params_key build_key(self.class.params_key) end + def headers_key + # Rack servers uppercase header names, convert hyphens to underscores, + # and prefix with "HTTP_" to comply with CGI. We transform the key in + # the same way Rails does: + # https://github.com/rails/rails/blob/6-0-stable/actionpack/lib/action_dispatch/http/headers.rb#L123-L126 + key = build_key(self.class.headers_key) + return nil if key.nil? + "HTTP_" + key.upcase.tr("-", "_") + end + def password_field self.class.password_field end @@ -1919,10 +1974,17 @@ def persist_by_cookie end def persist_by_params - return false unless params_enabled? + persist_by_single_access_token(params_credentials) if params_enabled? + end + + def persist_by_headers + persist_by_single_access_token(headers_credentials) if headers_enabled? + end + + def persist_by_single_access_token(credentials) self.unauthorized_record = search_for_record( "find_by_single_access_token", - params_credentials + credentials ) self.single_access = valid? end diff --git a/lib/authlogic/test_case/mock_api_controller.rb b/lib/authlogic/test_case/mock_api_controller.rb index 3fe4e38e..852c50cb 100644 --- a/lib/authlogic/test_case/mock_api_controller.rb +++ b/lib/authlogic/test_case/mock_api_controller.rb @@ -26,6 +26,10 @@ def params @params ||= {} end + def headers + @headers ||= {} + end + def request @request ||= MockRequest.new(self) end diff --git a/lib/authlogic/test_case/mock_request.rb b/lib/authlogic/test_case/mock_request.rb index 6848d17f..1d8b6b14 100644 --- a/lib/authlogic/test_case/mock_request.rb +++ b/lib/authlogic/test_case/mock_request.rb @@ -19,6 +19,10 @@ def format controller.request_content_type if controller.respond_to? :request_content_type end + def headers + @headers ||= {} + end + def ip controller&.respond_to?(:env) && controller.env.is_a?(Hash) && diff --git a/test/session_test/params_test.rb b/test/session_test/single_access_token_test.rb similarity index 71% rename from test/session_test/params_test.rb rename to test/session_test/single_access_token_test.rb index 5d280f13..6a7843b5 100644 --- a/test/session_test/params_test.rb +++ b/test/session_test/single_access_token_test.rb @@ -3,9 +3,11 @@ require "test_helper" module SessionTest - module ParamsTest + module SingleAccessTokenTest class ConfigTest < ActiveSupport::TestCase def test_params_key + assert_equal UserSession.cookie_key, UserSession.params_key + UserSession.params_key = "my_params_key" assert_equal "my_params_key", UserSession.params_key @@ -13,6 +15,16 @@ def test_params_key assert_equal "user_credentials", UserSession.params_key end + def test_headers_key + assert_equal nil, UserSession.headers_key + + UserSession.headers_key = "my_headers_key" + assert_equal "my_headers_key", UserSession.headers_key + + UserSession.headers_key "user_credentials" + assert_equal "user_credentials", UserSession.headers_key + end + def test_single_access_allowed_request_types UserSession.single_access_allowed_request_types = ["my request type"] assert_equal ["my request type"], UserSession.single_access_allowed_request_types @@ -28,11 +40,21 @@ def test_single_access_allowed_request_types class InstanceMethodsTest < ActiveSupport::TestCase def test_persist_persist_by_params + assert_persist_by(:params) + end + + def test_persist_persist_by_headers + # Since default headers_key is nil, set for the test. + UserSession.send("headers_key=", "user_credentials") + assert_persist_by(:headers) + end + + def assert_persist_by(headers_or_params) ben = users(:ben) session = UserSession.new refute session.persisting? - set_params_for(ben) + send("set_#{headers_or_params}_for", ben) refute session.persisting? refute session.unauthorized_record diff --git a/test/test_helper.rb b/test/test_helper.rb index f2e3de8c..d55d36c2 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -274,6 +274,14 @@ def unset_params controller.params["user_credentials"] = nil end + def set_headers_for(user) + controller.request.headers["HTTP_USER_CREDENTIALS"] = user.single_access_token + end + + def unset_headers + controller.request.headers["HTTP_USER_CREDENTIALS"] = nil + end + def set_request_content_type(type) controller.request_content_type = type end