diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b5a8e2e..acaed7bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ #### Features +* [#493](https://github.com/ruby-grape/grape-swagger/pull/493): Swagger UI endpoint authorization. - [@texpert](https://github.com/texpert). * [#492](https://github.com/ruby-grape/grape/pull/492): Define security requirements on endpoint methods - [@tomregelink](https://github.com/tomregelink). * [#497](https://github.com/ruby-grape/grape-swagger/pull/497): Use ruby-grape-danger in Dangerfile - [@dblock](https://github.com/dblock). * Your contribution here. diff --git a/README.md b/README.md index 9e40c705..97c5440c 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ * [Model Parsers](#model_parsers) * [Configure](#configure) * [Routes Configuration](#routes) +* [Securing the Swagger UI](#oauth) * [Markdown](#md_usage) * [Response documentation](#response) * [Extensions](#extensions) @@ -192,6 +193,9 @@ end * [add_version](#add_version) * [doc_version](#doc_version) * [markdown](#markdown) +* [endpoint_auth_wrapper](#endpoint_auth_wrapper) +* [swagger_endpoint_guard](#swagger_endpoint_guard) +* [oauth_token](#oauth_token) * [security_definitions](#security_definitions) * [models](#models) * [hide_documentation_path](#hide_documentation_path) @@ -273,6 +277,33 @@ add_swagger_documentation \ markdown: GrapeSwagger::Markdown::RedcarpetAdapter.new ``` + +#### endpoint_auth_wrapper: +Specify the middleware to use for securing endpoints. + +```ruby +add_swagger_documentation \ + endpoint_auth_wrapper: WineBouncer::OAuth2 +``` + + +#### swagger_endpoint_guard: +Specify the method and auth scopes, used by the middleware for securing endpoints. + +```ruby +add_swagger_documentation \ + swagger_endpoint_guard: 'oauth2 false' +``` + + +#### oauth_token: +Specify the method to get the oauth_token, provided by the middleware. + +```ruby +add_swagger_documentation \ + oauth_token: 'doorkeeper_access_token' +``` + #### security_definitions: Specify the [Security Definitions Object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#security-definitions-object) @@ -765,6 +796,75 @@ module API end ``` + +## Securing the Swagger UI + + +The Swagger UI on Grape could be secured from unauthorized access using any middleware, which provides certain methods: + +- a *before* method to be run in the Grape controller for authorization purpose; +- some guard method, which could receive as argument a string or an array of authorization scopes; +- a method which processes and returns the access token received in the HTTP request headers (usually in the 'HTTP_AUTHORIZATION' header). + +Below are some examples of securing the Swagger UI on Grape installed along with Ruby on Rails: + +- The WineBouncer and Doorkeeper gems are used in the examples; +- 'rails' and 'wine_bouncer' gems should be required prior to 'grape-swagger' in boot.rb; +- This works with a fresh PR to WineBouncer which is yet unmerged - [WineBouncer PR](https://github.com/antek-drzewiecki/wine_bouncer/pull/64). + +This is how to configure the grape_swagger documentation: + +```ruby + add_swagger_documentation base_path: '/', + title: 'My API', + doc_version: '0.0.1', + hide_documentation_path: true, + hide_format: true, + endpoint_auth_wrapper: WineBouncer::OAuth2, # This is the middleware for securing the Swagger UI + swagger_endpoint_guard: 'oauth2 false', # this is the guard method and scope + oauth_token: 'doorkeeper_access_token' # This is the method returning the access_token +``` + +The guard method should inject the Security Requirement Object into the endpoint's route settings (see Grape::DSL::Settings.route_setting method). + +The 'oauth2 false' added to swagger_documentation is making the main Swagger endpoint protected with OAuth, i.e. it +is retreiving the access_token from the HTTP request, but the 'false' scope is for skipping authorization and showing + the UI for everyone. If the scope would be set to something else, like 'oauth2 admin', for example, than the UI + wouldn't be displayed at all to unauthorized users. + +Further on, the guard could be used, where necessary, for endpoint access protection. Put it prior to the endpoint's method: + +```ruby + resource :users do + oauth2 'read, write' + get do + render_users + end + + oauth2 'admin' + post do + User.create!... + end + end +``` + +And, finally, if you want to not only restrict the access, but to completely hide the endpoint from unauthorized +users, you could pass a lambda to the :hidden key of a endpoint's description: + +```ruby + not_admins = lambda { |token=nil| token.nil? || !User.find(token.resource_owner_id).admin? } + + resource :users do + desc 'Create user', hidden: not_admins + oauth2 'admin' + post do + User.create!... + end + end +``` + +The lambda is checking whether the user is authenticated (if not, the token is nil by default), and has the admin +role - only admins can see this endpoint. ### Markdown in Detail diff --git a/lib/grape-swagger.rb b/lib/grape-swagger.rb index b1aea2d5..42f2edf3 100644 --- a/lib/grape-swagger.rb +++ b/lib/grape-swagger.rb @@ -31,6 +31,11 @@ def add_swagger_documentation(options = {}) version_for(options) options = { target_class: self }.merge(options) @target_class = options[:target_class] + auth_wrapper = options[:endpoint_auth_wrapper] + + if auth_wrapper && auth_wrapper.method_defined?(:before) && !middleware.flatten.include?(auth_wrapper) + use auth_wrapper + end documentation_class.setup(options) mount(documentation_class) diff --git a/lib/grape-swagger/doc_methods.rb b/lib/grape-swagger/doc_methods.rb index 63dd0601..5713d55c 100644 --- a/lib/grape-swagger/doc_methods.rb +++ b/lib/grape-swagger/doc_methods.rb @@ -31,37 +31,38 @@ def setup(options) # options could be set on #add_swagger_documentation call, # for available options see #defaults target_class = options[:target_class] - api_doc = options[:api_documentation].dup - specific_api_doc = options[:specific_api_documentation].dup + guard = options[:swagger_endpoint_guard] + formatter = options[:format] class_variables_from(options) [:format, :default_format, :default_error_formatter].each do |method| - send(method, options[:format]) - end if options[:format] - # getting of the whole swagger2.0 spec file - desc api_doc.delete(:desc), api_doc - get mount_path do - header['Access-Control-Allow-Origin'] = '*' - header['Access-Control-Request-Method'] = '*' + send(method, formatter) + end if formatter - output = swagger_object( + send(guard.split.first.to_sym, *guard.split(/[\s,]+/).drop(1)) unless guard.nil? + + output_path_definitions = proc do |combi_routes, endpoint| + output = endpoint.swagger_object( target_class, - request, + endpoint.request, options ) - target_routes = target_class.combined_namespace_routes - paths, definitions = path_and_definition_objects(target_routes, options) + paths, definitions = endpoint.path_and_definition_objects(combi_routes, options) output[:paths] = paths unless paths.blank? output[:definitions] = definitions unless definitions.blank? output end - # getting of a specific/named route of the swagger2.0 spec file - desc specific_api_doc.delete(:desc), { params: - specific_api_doc.delete(:params) || {} }.merge(specific_api_doc) + get mount_path do + header['Access-Control-Allow-Origin'] = '*' + header['Access-Control-Request-Method'] = '*' + + output_path_definitions.call(target_class.combined_namespace_routes, self) + end + params do requires :name, type: String, desc: 'Resource name of mounted API' optional :locale, type: Symbol, desc: 'Locale of API documentation' @@ -72,18 +73,7 @@ def setup(options) combined_routes = target_class.combined_namespace_routes[params[:name]] error!({ error: 'named resource not exist' }, 400) if combined_routes.nil? - output = swagger_object( - target_class, - request, - options - ) - - target_routes = { params[:name] => combined_routes } - paths, definitions = path_and_definition_objects(target_routes, options) - output[:paths] = paths unless paths.blank? - output[:definitions] = definitions unless definitions.blank? - - output + output_path_definitions.call({ params[:name] => combined_routes }, self) end end @@ -104,7 +94,10 @@ def defaults authorizations: nil, security_definitions: nil, api_documentation: { desc: 'Swagger compatible API description' }, - specific_api_documentation: { desc: 'Swagger compatible API description for specific API' } + specific_api_documentation: { desc: 'Swagger compatible API description for specific API' }, + endpoint_auth_wrapper: nil, + swagger_endpoint_guard: nil, + oauth_token: nil } end diff --git a/lib/grape-swagger/endpoint.rb b/lib/grape-swagger/endpoint.rb index b646a148..a3f338df 100644 --- a/lib/grape-swagger/endpoint.rb +++ b/lib/grape-swagger/endpoint.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'active_support' require 'active_support/core_ext/string/inflections.rb' @@ -84,7 +86,7 @@ def add_definitions_from(models) # path object def path_item(routes, options) routes.each do |route| - next if hidden?(route) + next if hidden?(route, options) @item, path = GrapeSwagger::DocMethods::PathString.build(route, options) @entity = route.entity || route.options[:success] @@ -287,10 +289,10 @@ def model_name(name) name.respond_to?(:name) ? name.name.demodulize.camelize : name.split('::').last end - def hidden?(route) + def hidden?(route, options) route_hidden = route.options[:hidden] - route_hidden = route_hidden.call if route_hidden.is_a?(Proc) - route_hidden + return route_hidden unless route_hidden.is_a?(Proc) + options[:oauth_token] ? route_hidden.call(send(options[:oauth_token].to_sym)) : route_hidden.call end def public_parameter?(param) diff --git a/spec/swagger_v2/guarded_endpoint_spec.rb b/spec/swagger_v2/guarded_endpoint_spec.rb new file mode 100644 index 00000000..d9dd7a79 --- /dev/null +++ b/spec/swagger_v2/guarded_endpoint_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'spec_helper' + +class SampleAuth < Grape::Middleware::Base + module AuthMethods + attr_accessor :access_token + + def protected_endpoint=(protected) + @protected_endpoint = protected + end + + def protected_endpoint? + @protected_endpoint || false + end + + def access_token + @_access_token + end + + def access_token=(token) + @_access_token = token + end + end + + def context + env['api.endpoint'] + end + + def before + context.extend(SampleAuth::AuthMethods) + context.protected_endpoint = context.options[:route_options][:auth].present? + + return unless context.protected_endpoint? + scopes = context.options[:route_options][:auth][:scopes].map(&:to_sym) + authorize!(*scopes) unless scopes.include? :false + context.access_token = env['HTTP_AUTHORIZATION'] + end +end + +module Extension + def sample_auth(*scopes) + description = route_setting(:description) || route_setting(:description, {}) + description[:auth] = { scopes: scopes } + end + + Grape::API.extend self +end + +describe 'a guarded api endpoint' do + before :all do + class GuardedMountedApi < Grape::API + access_token_valid = proc { |token = nil| token.nil? || token != '12345' } + + desc 'Show endpoint if authenticated', hidden: access_token_valid + get '/auth' do + { foo: 'bar' } + end + end + + class GuardedApi < Grape::API + mount GuardedMountedApi + add_swagger_documentation endpoint_auth_wrapper: SampleAuth, + swagger_endpoint_guard: 'sample_auth false', + oauth_token: 'access_token' + end + end + + def app + GuardedApi + end + + context 'when a correct token is passed with the request' do + subject do + get '/swagger_doc.json', {}, 'HTTP_AUTHORIZATION' => '12345' + JSON.parse(last_response.body) + end + + it 'retrieves swagger-documentation for the endpoint' do + expect(subject).to eq( + 'info' => { 'title' => 'API title', 'version' => '0.0.1' }, + 'swagger' => '2.0', + 'produces' => ['application/xml', 'application/json', 'application/octet-stream', 'text/plain'], + 'host' => 'example.org', + 'paths' => { + '/auth' => { + 'get' => { + 'summary' => 'Show endpoint if authenticated', + 'description' => 'Show endpoint if authenticated', + 'produces' => ['application/json'], + 'tags' => ['auth'], + 'operationId' => 'getAuth', + 'responses' => { '200' => { 'description' => 'Show endpoint if authenticated' } } + } + } + } + ) + end + end + + context 'when a bad token is passed with the request' do + subject do + get '/swagger_doc.json', {}, 'HTTP_AUTHORIZATION' => '123456' + JSON.parse(last_response.body) + end + + it 'does not retrieve swagger-documentation for the endpoint - only the info_object' do + expect(subject).to eq( + 'info' => { 'title' => 'API title', 'version' => '0.0.1' }, + 'swagger' => '2.0', + 'produces' => ['application/xml', 'application/json', 'application/octet-stream', 'text/plain'], + 'host' => 'example.org' + ) + end + end +end