From 132205193cc54a9dfa17b3a8f5ddcf7aa1f4c727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Mangano?= Date: Mon, 1 Aug 2022 17:10:32 +0200 Subject: [PATCH] Write a middleware for Faraday MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unlike other HTTP client libraries, Faraday does not expose request objets too easily. Instead, it provides a framework for configuring connections. Authorization in Faraday is meant to be handled through the use of middlewares. By convention, Faraday middlewares are defined under the Faraday namespace, even when they’re not official. Like other middlewares, requiring `faraday/api_auth` registers the middleware into Faraday with a name. Since that side effect depends on the presence of Faraday, it cannot be part of ApiAuth directly. The previously-existing Faraday integration still exists for whoever managed to use it, though I suggest deprecating it. --- .rubocop_todo.yml | 7 +- README.md | 13 ++ lib/api_auth.rb | 1 + lib/api_auth/headers.rb | 2 + lib/api_auth/request_drivers/faraday_env.rb | 102 +++++++++++ lib/faraday/api_auth.rb | 8 + lib/faraday/api_auth/middleware.rb | 35 ++++ spec/faraday_middleware_spec.rb | 17 ++ spec/request_drivers/faraday_env_spec.rb | 188 ++++++++++++++++++++ 9 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 lib/api_auth/request_drivers/faraday_env.rb create mode 100644 lib/faraday/api_auth.rb create mode 100644 lib/faraday/api_auth/middleware.rb create mode 100644 spec/faraday_middleware_spec.rb create mode 100644 spec/request_drivers/faraday_env_spec.rb diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 4422a52e..99f0e725 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2022-03-17 08:35:06 UTC using RuboCop version 1.26.0. +# on 2022-08-03 07:19:11 UTC using RuboCop version 1.32.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -36,15 +36,16 @@ Lint/Void: # Offense count: 2 # Configuration parameters: IgnoredMethods. Metrics/CyclomaticComplexity: - Max: 15 + Max: 16 -# Offense count: 10 +# Offense count: 11 Naming/AccessorMethodName: Exclude: - 'lib/api_auth/railtie.rb' - 'lib/api_auth/request_drivers/action_controller.rb' - 'lib/api_auth/request_drivers/curb.rb' - 'lib/api_auth/request_drivers/faraday.rb' + - 'lib/api_auth/request_drivers/faraday_env.rb' - 'lib/api_auth/request_drivers/grape_request.rb' - 'lib/api_auth/request_drivers/http.rb' - 'lib/api_auth/request_drivers/httpi.rb' diff --git a/README.md b/README.md index b9ed5577..84838713 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,19 @@ Simply add this configuration to your Flexirest initializer in your app and it w Flexirest::Base.api_auth_credentials(@access_id, @secret_key) ``` +### Faraday + +ApiAuth provides a middleware for adding authentication to a Faraday connection: + +```ruby +require 'faraday/api_auth' +Faraday.new do |f| + f.request :api_auth, @access_id, @secret_key +end +``` + +The order of middlewares is important. You should make sure api_auth is last. + ## Server ApiAuth provides some built in methods to help you generate API keys for your diff --git a/lib/api_auth.rb b/lib/api_auth.rb index a8a0e089..13d863cc 100644 --- a/lib/api_auth.rb +++ b/lib/api_auth.rb @@ -14,6 +14,7 @@ require 'api_auth/request_drivers/rack' require 'api_auth/request_drivers/httpi' require 'api_auth/request_drivers/faraday' +require 'api_auth/request_drivers/faraday_env' require 'api_auth/request_drivers/http' require 'api_auth/headers' diff --git a/lib/api_auth/headers.rb b/lib/api_auth/headers.rb index 1ae9a0fa..2aae0a5a 100644 --- a/lib/api_auth/headers.rb +++ b/lib/api_auth/headers.rb @@ -36,6 +36,8 @@ def initialize_request_driver(request, authorize_md5: false) HttpiRequest.new(request) when /Faraday::Request/ FaradayRequest.new(request) + when /Faraday::Env/ + FaradayEnv.new(request) when /HTTP::Request/ HttpRequest.new(request) end diff --git a/lib/api_auth/request_drivers/faraday_env.rb b/lib/api_auth/request_drivers/faraday_env.rb new file mode 100644 index 00000000..16bc7b3f --- /dev/null +++ b/lib/api_auth/request_drivers/faraday_env.rb @@ -0,0 +1,102 @@ +module ApiAuth + module RequestDrivers # :nodoc: + # Internally, Faraday uses the class Faraday::Env to represent requests. The class is not meant + # to be directly exposed to users, but this is what Faraday middlewares work with. See + # . + class FaradayEnv + include ApiAuth::Helpers + + def initialize(env) + @env = env + end + + def set_auth_header(header) + @env.request_headers['Authorization'] = header + @env + end + + def calculated_hash + sha256_base64digest(body) + end + + def populate_content_hash + return unless %w[POST PUT PATCH].include?(http_method) + + @env.request_headers['X-Authorization-Content-SHA256'] = calculated_hash + end + + def content_hash_mismatch? + if %w[POST PUT PATCH].include?(http_method) + calculated_hash != content_hash + else + false + end + end + + def http_method + @env.method.to_s.upcase + end + + def content_type + type = find_header(%w[CONTENT-TYPE CONTENT_TYPE HTTP_CONTENT_TYPE]) + + # When sending a body-less POST request, the Content-Type is set at the last minute by the + # Net::HTTP adapter, which states in the documentation for Net::HTTP#post: + # + # > You should set Content-Type: header field for POST. If no Content-Type: field given, + # > this method uses “application/x-www-form-urlencoded” by default. + # + # The same applies to PATCH and PUT. Hopefully the other HTTP adapters behave similarly. + # + type ||= 'application/x-www-form-urlencoded' if %w[POST PATCH PUT].include?(http_method) + + type + end + + def content_hash + find_header(%w[X-AUTHORIZATION-CONTENT-SHA256]) + end + + def original_uri + find_header(%w[X-ORIGINAL-URI X_ORIGINAL_URI HTTP_X_ORIGINAL_URI]) + end + + def request_uri + @env.url.request_uri + end + + def set_date + @env.request_headers['Date'] = Time.now.utc.httpdate + end + + def timestamp + find_header(%w[DATE HTTP_DATE]) + end + + def authorization_header + find_header(%w[Authorization AUTHORIZATION HTTP_AUTHORIZATION]) + end + + def body + body_source = @env.request_body + if body_source.respond_to?(:read) + result = body_source.read + body_source.rewind + result + else + body_source.to_s + end + end + + def fetch_headers + capitalize_keys @env.request_headers + end + + private + + def find_header(keys) + keys.map { |key| @env.request_headers[key] }.compact.first + end + end + end +end diff --git a/lib/faraday/api_auth.rb b/lib/faraday/api_auth.rb new file mode 100644 index 00000000..52956896 --- /dev/null +++ b/lib/faraday/api_auth.rb @@ -0,0 +1,8 @@ +require_relative 'api_auth/middleware' + +module Faraday + # Integrate ApiAuth into Faraday. + module ApiAuth + Faraday::Request.register_middleware(api_auth: Middleware) + end +end diff --git a/lib/faraday/api_auth/middleware.rb b/lib/faraday/api_auth/middleware.rb new file mode 100644 index 00000000..df9e921f --- /dev/null +++ b/lib/faraday/api_auth/middleware.rb @@ -0,0 +1,35 @@ +require 'api_auth' + +module Faraday + module ApiAuth + # Request middleware for Faraday. It takes the same arguments as ApiAuth.sign!. + # + # You will usually need to include it after the other middlewares since ApiAuth needs to hash + # the final request. + # + # Usage: + # + # ```ruby + # require 'faraday/api_auth' + # + # conn = Faraday.new do |f| + # f.request :api_auth, access_id, secret_key + # # Alternatively: + # # f.use Faraday::ApiAuth::Middleware, access_id, secret_key + # end + # ``` + # + class Middleware < Faraday::Middleware + def initialize(app, access_id, secret_key, options = {}) + super(app) + @access_id = access_id + @secret_key = secret_key + @options = options + end + + def on_request(env) + ::ApiAuth.sign!(env, @access_id, @secret_key, @options) + end + end + end +end diff --git a/spec/faraday_middleware_spec.rb b/spec/faraday_middleware_spec.rb new file mode 100644 index 00000000..fb039668 --- /dev/null +++ b/spec/faraday_middleware_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' +require 'faraday/api_auth' + +describe Faraday::ApiAuth::Middleware do + it 'adds the Authorization headers' do + conn = Faraday.new('http://localhost/') do |f| + f.request :api_auth, 'foo', 'secret', digest: 'sha256' + f.adapter :test do |stub| + stub.get('http://localhost/test') do |env| + [200, {}, env.request_headers['Authorization']] + end + end + end + response = conn.get('test', nil, { 'Date' => 'Tue, 02 Aug 2022 09:29:24 GMT' }) + expect(response.body).to eq 'APIAuth-HMAC-SHA256 foo:Tn/lIZ9kphcO32DwG4wFHenqBt37miDEIkA5ykLgGiQ=' + end +end diff --git a/spec/request_drivers/faraday_env_spec.rb b/spec/request_drivers/faraday_env_spec.rb new file mode 100644 index 00000000..f5592388 --- /dev/null +++ b/spec/request_drivers/faraday_env_spec.rb @@ -0,0 +1,188 @@ +require 'spec_helper' + +describe ApiAuth::RequestDrivers::FaradayEnv do + let(:timestamp) { Time.now.utc.httpdate } + + let(:request) do + Faraday::Env.new(verb, body, URI(uri), {}, Faraday::Utils::Headers.new(headers)) + end + + let(:verb) { :put } + let(:uri) { 'https://localhost/resource.xml?foo=bar&bar=foo' } + let(:body) { "hello\nworld" } + + let(:headers) do + { + 'Authorization' => 'APIAuth 1044:12345', + 'X-Authorization-Content-SHA256' => '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', + 'content-type' => 'text/plain', + 'date' => timestamp + } + end + + subject(:driven_request) { described_class.new(request) } + + describe 'getting headers correctly' do + it 'gets the content_type' do + expect(driven_request.content_type).to eq('text/plain') + end + + context 'without Content-Type' do + let(:headers) { {} } + + it 'defaults to url-encoded' do + expect(driven_request.content_type).to eq 'application/x-www-form-urlencoded' + end + end + + it 'gets the content_hash' do + expect(driven_request.content_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') + end + + it 'gets the request_uri' do + expect(driven_request.request_uri).to eq('/resource.xml?foo=bar&bar=foo') + end + + it 'gets the timestamp' do + expect(driven_request.timestamp).to eq(timestamp) + end + + it 'gets the authorization_header' do + expect(driven_request.authorization_header).to eq('APIAuth 1044:12345') + end + + describe '#calculated_hash' do + it 'calculates hash from the body' do + expect(driven_request.calculated_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') + expect(driven_request.body.bytesize).to eq(11) + end + + context 'no body' do + let(:body) { nil } + + it 'treats no body as empty string' do + expect(driven_request.calculated_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') + expect(driven_request.body.bytesize).to eq(0) + end + end + + context 'multipart content' do + let(:body) { File.new('spec/fixtures/upload.png') } + + it 'calculates correctly for multipart content' do + expect(driven_request.calculated_hash).to eq('AlKDe7kjMQhuKgKuNG8I7GA93MasHcaVJkJLaUT7+dY=') + expect(driven_request.body.bytesize).to eq(5112) + end + end + end + + describe 'http_method' do + context 'when put request' do + let(:verb) { :put } + + it 'returns upcased put' do + expect(driven_request.http_method).to eq('PUT') + end + end + + context 'when get request' do + let(:verb) { :get } + + it 'returns upcased get' do + expect(driven_request.http_method).to eq('GET') + end + end + end + end + + describe 'setting headers correctly' do + let(:headers) do + { + 'content-type' => 'text/plain' + } + end + + describe '#populate_content_hash' do + context 'when request type has no body' do + let(:verb) { :get } + + it "doesn't populate content hash" do + driven_request.populate_content_hash + expect(request.request_headers['X-Authorization-Content-SHA256']).to be_nil + end + end + + context 'when request type has a body' do + let(:verb) { :put } + + it 'populates content hash' do + driven_request.populate_content_hash + expect(request.request_headers['X-Authorization-Content-SHA256']).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') + end + + it 'refreshes the cached headers' do + driven_request.populate_content_hash + expect(driven_request.content_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') + end + end + end + + describe '#set_date' do + before do + allow(Time).to receive_message_chain(:now, :utc, :httpdate).and_return(timestamp) + end + + it 'sets the date header of the request' do + driven_request.set_date + expect(request.request_headers['DATE']).to eq(timestamp) + end + end + + describe '#set_auth_header' do + it 'sets the auth header' do + driven_request.set_auth_header('APIAuth 1044:54321') + expect(request.request_headers['Authorization']).to eq('APIAuth 1044:54321') + end + end + end + + describe 'content_hash_mismatch?' do + context 'when request type has no body' do + let(:verb) { :get } + + it 'is false' do + expect(driven_request.content_hash_mismatch?).to be false + end + end + + context 'when request type has a body' do + let(:verb) { :put } + + context 'when calculated matches sent' do + before do + request.request_headers['X-Authorization-Content-SHA256'] = 'JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=' + end + + it 'is false' do + expect(driven_request.content_hash_mismatch?).to be false + end + end + + context "when calculated doesn't match sent" do + before do + request['X-Authorization-Content-SHA256'] = '3' + end + + it 'is true' do + expect(driven_request.content_hash_mismatch?).to be true + end + end + end + end + + describe 'fetch_headers' do + it 'returns request headers' do + expect(driven_request.fetch_headers).to include('CONTENT-TYPE' => 'text/plain') + end + end +end