From de7ec5f15d94588c61f0b78bd28678b83fe8db4f Mon Sep 17 00:00:00 2001 From: domaindrivendev Date: Wed, 15 Feb 2017 15:38:03 -0500 Subject: [PATCH] Leverage security definitions for headers in example requests --- Gemfile.lock | 12 ++-- README.md | 52 +++++++++++++++++ .../lib/rswag/specs/example_helpers.rb | 18 +++++- .../lib/rswag/specs/request_factory.rb | 57 ++++++++++++------- .../spec/rswag/specs/request_factory_spec.rb | 35 ++++++++++-- .../app/controllers/application_controller.rb | 2 +- .../app/controllers/auth_tests_controller.rb | 13 +++++ test-app/config/routes.rb | 2 + test-app/spec/integration/auth_tests_spec.rb | 22 +++++++ test-app/spec/swagger_helper.rb | 3 + test-app/swagger/v1/swagger.json | 27 +++++++++ 11 files changed, 209 insertions(+), 34 deletions(-) create mode 100644 test-app/app/controllers/auth_tests_controller.rb create mode 100644 test-app/spec/integration/auth_tests_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index 65cd78f..132eeed 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -81,24 +81,24 @@ GEM railties (>= 3.0.0) globalid (0.4.0) activesupport (>= 4.2.0) - i18n (0.8.1) + i18n (0.8.4) json (1.8.6) json-schema (2.8.0) addressable (>= 2.4) libv8 (3.16.14.15) loofah (2.0.3) nokogiri (>= 1.5.9) - mail (2.6.5) + mail (2.6.6) mime-types (>= 1.16, < 4) method_source (0.8.2) mime-types (3.1) mime-types-data (~> 3.2015) mime-types-data (3.2016.0521) - mini_portile2 (2.1.0) + mini_portile2 (2.2.0) minitest (5.10.2) - nio4r (2.0.0) - nokogiri (1.6.8.1) - mini_portile2 (~> 2.1.0) + nio4r (2.1.0) + nokogiri (1.8.0) + mini_portile2 (~> 2.2.0) power_assert (0.3.1) rack (2.0.3) rack-test (0.6.3) diff --git a/README.md b/README.md index 0b14a60..7e45f4d 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,58 @@ describe 'Blogs API', swagger_doc: 'v2/swagger.json' do end ``` +### Specifying/Testing API Security ### + +Swagger allows for the specification of different security schemes and their applicability to operations in an API. To leverage this in rswag, you define the schemes globally in _swagger_helper.rb_ and then use the "security" attribute at the operation level to specify which schemes, if any, are applicable to that operation. Swagger supports :basic, :apiKey and :oauth2 scheme types. See [the spec](http://swagger.io/specification/#security-definitions-object-109) for more info. + +```ruby +# spec/swagger_helper.rb +RSpec.configure do |config| + config.swagger_root = Rails.root.to_s + '/swagger' + + config.swagger_docs = { + 'v1/swagger.json' => { + ... + securityDefinitions: { + basic: { + type: :basic + }, + apiKey: { + type: :apiKey, + name: 'api_key', + in: :query + } + } + } + } +end + +# spec/integration/blogs_spec.rb +describe 'Blogs API' do + + path '/blogs' do + + post 'Creates a blog' do + tags 'Blogs' + security [ basic: [] ] + ... + + response '201', 'blog created' do + let(:Authorization) { "Basic #{::Base64.strict_encode64('jsmith:jspass')}" } + run_test! + end + + response '401', 'authentication failed' do + let(:Authorization) { "Basic #{::Base64.strict_encode64('bogus:bogus')}" } + run_test! + end + end + end +end +``` + +__NOTE:__ Depending on the scheme types, you'll be required to assign a corresponding parameter value with each example. For example, :basic auth is required above and so the :Authorization (header) parameter must be set accordingly + ## Configuration & Customization ## The steps described above will get you up and running with minimal setup. However, rswag offers a lot of flexibility to customize as you see fit. Before exploring the various options, you'll need to be aware of it's different components. The following table lists each of them and the files that get added/updated as part of a standard install. diff --git a/rswag-specs/lib/rswag/specs/example_helpers.rb b/rswag-specs/lib/rswag/specs/example_helpers.rb index b7185fb..d669c28 100644 --- a/rswag-specs/lib/rswag/specs/example_helpers.rb +++ b/rswag-specs/lib/rswag/specs/example_helpers.rb @@ -14,7 +14,7 @@ module Rswag api_metadata[:operation][:verb], factory.build_fullpath(self), factory.build_body(self), - factory.build_headers(self) + rackify_headers(factory.build_headers(self)) # Rails test infrastructure requires Rack headers ) else send( @@ -36,6 +36,22 @@ module Rswag private + def rackify_headers(headers) + name_value_pairs = headers.map do |name, value| + [ + case name + when 'Accept' then 'HTTP_ACCEPT' + when 'Content-Type' then 'CONTENT_TYPE' + when 'Authorization' then 'HTTP_AUTHORIZATION' + else name + end, + value + ] + end + + Hash[ name_value_pairs ] + end + def rswag_config ::Rswag::Specs.config end diff --git a/rswag-specs/lib/rswag/specs/request_factory.rb b/rswag-specs/lib/rswag/specs/request_factory.rb index daae7da..2d81aab 100644 --- a/rswag-specs/lib/rswag/specs/request_factory.rb +++ b/rswag-specs/lib/rswag/specs/request_factory.rb @@ -32,13 +32,20 @@ module Rswag end def build_headers(example) - headers = Hash[ parameters_in(:header).map { |p| [ p[:name], example.send(p[:name]).to_s ] } ] - headers.tap do |h| - produces = @api_metadata[:operation][:produces] || @global_metadata[:produces] - consumes = @api_metadata[:operation][:consumes] || @global_metadata[:consumes] - h['ACCEPT'] = produces.join(';') unless produces.nil? - h['CONTENT_TYPE'] = consumes.join(';') unless consumes.nil? + name_value_pairs = parameters_in(:header).map do |param| + [ + param[:name], + example.send(param[:name]).to_s + ] end + + # Add MIME type headers based on produces/consumes metadata + produces = @api_metadata[:operation][:produces] || @global_metadata[:produces] + consumes = @api_metadata[:operation][:consumes] || @global_metadata[:consumes] + name_value_pairs << [ 'Accept', produces.join(';') ] unless produces.nil? + name_value_pairs << [ 'Content-Type', consumes.join(';') ] unless consumes.nil? + + Hash[ name_value_pairs ] end private @@ -52,42 +59,48 @@ module Rswag applicable_params .map { |p| p['$ref'] ? resolve_parameter(p['$ref']) : p } # resolve any references - .concat(resolve_api_key_parameters) + .concat(security_parameters) .select { |p| p[:in] == location } end def resolve_parameter(ref) - defined_params = @global_metadata[:parameters] + defined_params = @global_metadata[:parameters] key = ref.sub('#/parameters/', '') raise "Referenced parameter '#{ref}' must be defined" unless defined_params && defined_params[key] defined_params[key] end - def resolve_api_key_parameters - @api_key_params ||= begin - # First figure out the security requirement applicable to the operation - global_requirements = (@global_metadata[:security] || [] ).map { |r| r.keys.first } - operation_requirements = (@api_metadata[:operation][:security] || [] ).map { |r| r.keys.first } - requirements = global_requirements | operation_requirements - - # Then obtain the scheme definitions for those requirements - definitions = (@global_metadata[:securityDefinitions] || {}).slice(*requirements) - definitions.values.select { |d| d[:type] == :apiKey } + def security_parameters + applicable_security_schemes.map do |scheme| + if scheme[:type] == :apiKey + { name: scheme[:name], type: :string, in: scheme[:in] } + else + { name: 'Authorization', type: :string, in: :header } # use auth header for basic & oauth2 + end end end + def applicable_security_schemes + # First figure out the security requirement applicable to the operation + requirements = @api_metadata[:operation][:security] || @global_metadata[:security] + scheme_names = requirements ? requirements.map { |r| r.keys.first } : [] + + # Then obtain the scheme definitions for those requirements + (@global_metadata[:securityDefinitions] || {}).slice(*scheme_names).values + end + def build_query_string_part(param, value) return "#{param[:name]}=#{value.to_s}" unless param[:type].to_sym == :array name = param[:name] case param[:collectionFormat] - when :ssv + when :ssv "#{name}=#{value.join(' ')}" - when :tsv + when :tsv "#{name}=#{value.join('\t')}" - when :pipes + when :pipes "#{name}=#{value.join('|')}" - when :multi + when :multi value.map { |v| "#{name}=#{v}" }.join('&') else "#{name}=#{value.join(',')}" # csv is default diff --git a/rswag-specs/spec/rswag/specs/request_factory_spec.rb b/rswag-specs/spec/rswag/specs/request_factory_spec.rb index c6791b1..3a0e430 100644 --- a/rswag-specs/spec/rswag/specs/request_factory_spec.rb +++ b/rswag-specs/spec/rswag/specs/request_factory_spec.rb @@ -193,6 +193,33 @@ module Rswag end end + context "global definition for 'basic auth'" do + before do + global_metadata[:securityDefinitions] = { basic_auth: { type: :basic} } + allow(example).to receive(:'Authorization').and_return('Basic foobar') + end + + context 'global requirement' do + before { global_metadata[:security] = [ { basic_auth: [] } ] } + + it "includes a corresponding Authorization header" do + expect(headers).to match( + 'Authorization' => 'Basic foobar' + ) + end + end + + context 'operation-specific requirement' do + before { api_metadata[:operation][:security] = [ { basic_auth: [] } ] } + + it "includes a corresponding Authorization header" do + expect(headers).to match( + 'Authorization' => 'Basic foobar' + ) + end + end + end + context 'consumes & produces' do before do api_metadata[:operation][:consumes] = [ 'application/json', 'application/xml' ] @@ -201,8 +228,8 @@ module Rswag it "includes corresponding 'Accept' & 'Content-Type' headers" do expect(headers).to match( - 'ACCEPT' => 'application/json;application/xml', - 'CONTENT_TYPE' => 'application/json;application/xml' + 'Accept' => 'application/json;application/xml', + 'Content-Type' => 'application/json;application/xml' ) end end @@ -217,8 +244,8 @@ module Rswag it "includes corresponding 'Accept' & 'Content-Type' headers" do expect(headers).to match( - 'ACCEPT' => 'application/json;application/xml', - 'CONTENT_TYPE' => 'application/json;application/xml' + 'Accept' => 'application/json;application/xml', + 'Content-Type' => 'application/json;application/xml' ) end end diff --git a/test-app/app/controllers/application_controller.rb b/test-app/app/controllers/application_controller.rb index a4506fc..4879440 100644 --- a/test-app/app/controllers/application_controller.rb +++ b/test-app/app/controllers/application_controller.rb @@ -1,7 +1,7 @@ class ApplicationController < ActionController::Base # Prevent CSRF attacks by raising an exception. # For APIs, you may want to use :null_session instead. - protect_from_forgery with: :exception + protect_from_forgery with: :null_session wrap_parameters format: [ :json ] end diff --git a/test-app/app/controllers/auth_tests_controller.rb b/test-app/app/controllers/auth_tests_controller.rb new file mode 100644 index 0000000..4f90b67 --- /dev/null +++ b/test-app/app/controllers/auth_tests_controller.rb @@ -0,0 +1,13 @@ +class AuthTestsController < ApplicationController + wrap_parameters Blog + respond_to :json + + # POST /auth-tests/basic + def basic + if authenticate_with_http_basic { |u, p| u == 'jsmith' && p == 'jspass' } + head :no_content + else + request_http_basic_authentication + end + end +end diff --git a/test-app/config/routes.rb b/test-app/config/routes.rb index ed6b6ed..d8c676e 100644 --- a/test-app/config/routes.rb +++ b/test-app/config/routes.rb @@ -1,6 +1,8 @@ TestApp::Application.routes.draw do resources :blogs, defaults: { :format => :json } + post 'auth-tests/basic', to: 'auth_tests#basic' + mount Rswag::Api::Engine => 'api-docs' mount Rswag::Ui::Engine => 'api-docs' end diff --git a/test-app/spec/integration/auth_tests_spec.rb b/test-app/spec/integration/auth_tests_spec.rb new file mode 100644 index 0000000..9a6a4f4 --- /dev/null +++ b/test-app/spec/integration/auth_tests_spec.rb @@ -0,0 +1,22 @@ +require 'swagger_helper' + +describe 'Auth Tests API', type: :request, swagger_doc: 'v1/swagger.json' do + + path '/auth-tests/basic' do + post 'Authenticates with basic auth' do + tags 'Auth Test' + operationId 'testBasicAuth' + security [ basic_auth: [] ] + + response '204', 'Valid credentials' do + let(:Authorization) { "Basic #{::Base64.strict_encode64('jsmith:jspass')}" } + run_test! + end + + response '401', 'Invalid credentials' do + let(:Authorization) { "Basic #{::Base64.strict_encode64('foo:bar')}" } + run_test! + end + end + end +end diff --git a/test-app/spec/swagger_helper.rb b/test-app/spec/swagger_helper.rb index f3202ec..91d506c 100644 --- a/test-app/spec/swagger_helper.rb +++ b/test-app/spec/swagger_helper.rb @@ -45,6 +45,9 @@ RSpec.configure do |config| } }, securityDefinitions: { + basic_auth: { + type: :basic + }, api_key: { type: :apiKey, name: 'api_key', diff --git a/test-app/swagger/v1/swagger.json b/test-app/swagger/v1/swagger.json index 7ee0753..0318f94 100644 --- a/test-app/swagger/v1/swagger.json +++ b/test-app/swagger/v1/swagger.json @@ -5,6 +5,30 @@ "version": "v1" }, "paths": { + "/auth-tests/basic": { + "post": { + "summary": "Authenticates with basic auth", + "tags": [ + "Auth Test" + ], + "operationId": "testBasicAuth", + "security": [ + { + "basic_auth": [ + + ] + } + ], + "responses": { + "204": { + "description": "Valid credentials" + }, + "401": { + "description": "Invalid credentials" + } + } + } + }, "/blogs": { "post": { "summary": "Creates a blog", @@ -158,6 +182,9 @@ } }, "securityDefinitions": { + "basic_auth": { + "type": "basic" + }, "api_key": { "type": "apiKey", "name": "api_key",