Skip to content

Commit

Permalink
Swagger UI endpoint authorization. (ruby-grape#493)
Browse files Browse the repository at this point in the history
  • Loading branch information
texpert authored and LeFnord committed Sep 8, 2016
1 parent 9854e56 commit cd8e6a8
Show file tree
Hide file tree
Showing 6 changed files with 250 additions and 33 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -273,6 +277,33 @@ add_swagger_documentation \
markdown: GrapeSwagger::Markdown::RedcarpetAdapter.new
```
<a name="endpoint_auth_wrapper" />
#### endpoint_auth_wrapper:
Specify the middleware to use for securing endpoints.
```ruby
add_swagger_documentation \
endpoint_auth_wrapper: WineBouncer::OAuth2
```
<a name="swagger_endpoint_guard" />
#### 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'
```
<a name="oauth_token" />
#### oauth_token:
Specify the method to get the oauth_token, provided by the middleware.
```ruby
add_swagger_documentation \
oauth_token: 'doorkeeper_access_token'
```
<a name="security_definitions" />
#### security_definitions:
Specify the [Security Definitions Object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#security-definitions-object)
Expand Down Expand Up @@ -765,6 +796,75 @@ module API
end
```
<a name="oauth" />
## 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.
<a name="md_usage" />
### Markdown in Detail
Expand Down
5 changes: 5 additions & 0 deletions lib/grape-swagger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
51 changes: 22 additions & 29 deletions lib/grape-swagger/doc_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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

Expand All @@ -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

Expand Down
10 changes: 6 additions & 4 deletions lib/grape-swagger/endpoint.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

require 'active_support'
require 'active_support/core_ext/string/inflections.rb'

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down
116 changes: 116 additions & 0 deletions spec/swagger_v2/guarded_endpoint_spec.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit cd8e6a8

Please sign in to comment.