diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b1fbeb58a..2d0c2f8045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ * [#691](https://github.com/intridea/grape/issues/691): Added `at_least_one_of` parameter validator - [@dblock](https://github.com/dblock). * [#687](https://github.com/intridea/grape/pull/687): Fix: `mutually_exclusive` and `exactly_one_of` validation error messages now label parameters as strings, consistently with `requires` and `optional` - [@dblock](https://github.com/dblock). * [#698](https://github.com/intridea/grape/pull/698): `error!` sets `status` for `Endpoint` too - [@dspaeth-faber](https://github.com/dspaeth-faber). +* [#703](https://github.com/intridea/grape/pull/703): Added support for Auth-Middleware extension - [@dspaeth-faber](https://github.com/dspaeth-faber). +* [#703](https://github.com/intridea/grape/pull/703): Removed `Grape::Middleware::Auth::Basic` - [@dspaeth-faber](https://github.com/dspaeth-faber). +* [#703](https://github.com/intridea/grape/pull/703): Removed `Grape::Middleware::Auth::Digest` - [@dspaeth-faber](https://github.com/dspaeth-faber). +* [#703](https://github.com/intridea/grape/pull/703): Removed `Grape::Middleware::Auth::OAuth2` - [@dspaeth-faber](https://github.com/dspaeth-faber). * Your contribution here. 0.8.0 (7/10/2014) diff --git a/README.md b/README.md index f5b26aa3b2..a74f725f67 100644 --- a/README.md +++ b/README.md @@ -1468,7 +1468,8 @@ formatter. ### Basic and Digest Auth -Grape has built-in Basic and Digest authentication. +Grape has built-in Basic and Digest authentication (the given `block` +is executed in the context of the current `Endpoint`). ```ruby http_basic do |username, password| @@ -1484,6 +1485,33 @@ http_digest({ realm: 'Test Api', opaque: 'app secret' }) do |username| end ``` +### Register custom middleware for authentication + +Grape can use custom Middleware for authentication. How to implement these +Middleware have a look at `Rack::Auth::Basic` or similar implementations. + + +For registering a Middlewar you need the following options: + +* `label` - the name for your authenticator to use it later +* `MiddlewareClass` - the MiddlewareClass to use for authentication +* `option_lookup_proc` - A Proc with one Argument to lookup the options at +runtime (return value is an `Array` as Paramter for the Middleware). + +Example: + +```ruby + +Grape::Middleware::Auth::Strategies.add(:my_auth, AuthMiddleware, ->(options) { [options[:realm]] } ) + + +auth :my_auth ,{ real: 'Test Api'} do |credentials| + # lookup the user's password here + { 'user1' => 'password1' }[username] +end + +``` + Use [warden-oauth2](https://github.com/opperator/warden-oauth2) or [rack-oauth2](https://github.com/nov/rack-oauth2) for OAuth2 support. ## Describing and Inspecting an API diff --git a/lib/grape.rb b/lib/grape.rb index bb151c9e48..41c1980819 100644 --- a/lib/grape.rb +++ b/lib/grape.rb @@ -77,10 +77,10 @@ module Middleware autoload :Error, 'grape/middleware/error' module Auth - autoload :OAuth2, 'grape/middleware/auth/oauth2' - autoload :Base, 'grape/middleware/auth/base' - autoload :Basic, 'grape/middleware/auth/basic' - autoload :Digest, 'grape/middleware/auth/digest' + autoload :Base, 'grape/middleware/auth/base' + autoload :DSL, 'grape/middleware/auth/dsl' + autoload :StrategyInfo, 'grape/middleware/auth/strategy_info' + autoload :Strategies, 'grape/middleware/auth/strategies' end module Versioner diff --git a/lib/grape/api.rb b/lib/grape/api.rb index 336c9266f0..0a270eb755 100644 --- a/lib/grape/api.rb +++ b/lib/grape/api.rb @@ -4,6 +4,7 @@ module Grape # class in order to build an API. class API extend Validations::ClassMethods + extend Grape::Middleware::Auth::DSL class << self attr_reader :endpoints, :instance, :routes, :route_set, :settings, :versions @@ -301,31 +302,6 @@ def helpers(new_mod = nil, &block) end end - # Add an authentication type to the API. Currently - # only `:http_basic`, `:http_digest` and `:oauth2` are supported. - def auth(type = nil, options = {}, &block) - if type - set(:auth, { type: type.to_sym, proc: block }.merge(options)) - else - settings[:auth] - end - end - - # Add HTTP Basic authorization to the API. - # - # @param [Hash] options A hash of options. - # @option options [String] :realm "API Authorization" The HTTP Basic realm. - def http_basic(options = {}, &block) - options[:realm] ||= "API Authorization" - auth :http_basic, options, &block - end - - def http_digest(options = {}, &block) - options[:realm] ||= "API Authorization" - options[:opaque] ||= "secret" - auth :http_digest, options, &block - end - def mount(mounts) mounts = { mounts => '/' } unless mounts.respond_to?(:each_pair) mounts.each_pair do |app, path| diff --git a/lib/grape/endpoint.rb b/lib/grape/endpoint.rb index 2b1f9fd045..9f4271120c 100644 --- a/lib/grape/endpoint.rb +++ b/lib/grape/endpoint.rb @@ -456,22 +456,6 @@ def build_middleware end end - if settings[:auth] - auth_proc = settings[:auth][:proc] - auth_proc_context = self - auth_middleware = { - http_basic: { class: Rack::Auth::Basic, args: [settings[:auth][:realm]] }, - http_digest: { class: Rack::Auth::Digest::MD5, args: [settings[:auth][:realm], settings[:auth][:opaque]] } - }[settings[:auth][:type]] - - # evaluate auth proc in context of endpoint - if auth_middleware - b.use auth_middleware[:class], *auth_middleware[:args] do |*args| - auth_proc_context.instance_exec(*args, &auth_proc) - end - end - end - if settings[:version] b.use Grape::Middleware::Versioner.using(settings[:version_options][:using]), versions: settings[:version] ? settings[:version].flatten : nil, diff --git a/lib/grape/middleware/auth/base.rb b/lib/grape/middleware/auth/base.rb index 4fdc485972..246ce620f4 100644 --- a/lib/grape/middleware/auth/base.rb +++ b/lib/grape/middleware/auth/base.rb @@ -3,25 +3,41 @@ module Grape module Middleware module Auth - class Base < Grape::Middleware::Base - attr_reader :authenticator + class Base + attr_accessor :options, :app, :env - def initialize(app, options = {}, &authenticator) - super(app, options) - @authenticator = authenticator + def initialize(app, options = {}) + @app = app + @options = options || {} end - def base_request - raise NotImplementedError, "You must implement base_request." + def context + env['api.endpoint'] end - def credentials - base_request.provided? ? base_request.credentials : [nil, nil] + def call(env) + dup._call(env) end - def before - unless authenticator.call(*credentials) - throw :error, status: 401, message: "API Authorization Failed." + def _call(env) + self.env = env + + if options.key?(:type) + auth_proc = options[:proc] + auth_proc_context = context + + strategy_info = Grape::Middleware::Auth::Strategies[options[:type]] + + throw(:error, status: 401, message: "API Authorization Failed.") unless strategy_info.present? + + strategy = strategy_info.create(@app, options) do |*args| + auth_proc_context.instance_exec(*args, &auth_proc) + end + + strategy.call(env) + + else + app.call(env) end end end diff --git a/lib/grape/middleware/auth/basic.rb b/lib/grape/middleware/auth/basic.rb deleted file mode 100644 index 4c815c0332..0000000000 --- a/lib/grape/middleware/auth/basic.rb +++ /dev/null @@ -1,13 +0,0 @@ -require 'rack/auth/basic' - -module Grape - module Middleware - module Auth - class Basic < Grape::Middleware::Auth::Base - def base_request - Rack::Auth::Basic::Request.new(env) - end - end - end - end -end diff --git a/lib/grape/middleware/auth/digest.rb b/lib/grape/middleware/auth/digest.rb deleted file mode 100644 index 8b2e633861..0000000000 --- a/lib/grape/middleware/auth/digest.rb +++ /dev/null @@ -1,13 +0,0 @@ -require 'rack/auth/digest/md5' - -module Grape - module Middleware - module Auth - class Digest < Grape::Middleware::Auth::Base - def base_request - Rack::Auth::Digest::Request.new(env) - end - end - end - end -end diff --git a/lib/grape/middleware/auth/dsl.rb b/lib/grape/middleware/auth/dsl.rb new file mode 100644 index 0000000000..06ea4326c6 --- /dev/null +++ b/lib/grape/middleware/auth/dsl.rb @@ -0,0 +1,35 @@ +require 'rack/auth/basic' + +module Grape + module Middleware + module Auth + module DSL + # Add an authentication type to the API. Currently + # only `:http_basic`, `:http_digest` are supported. + def auth(type = nil, options = {}, &block) + if type + set(:auth, { type: type.to_sym, proc: block }.merge(options)) + use Grape::Middleware::Auth::Base, settings[:auth] + else + settings[:auth] + end + end + + # Add HTTP Basic authorization to the API. + # + # @param [Hash] options A hash of options. + # @option options [String] :realm "API Authorization" The HTTP Basic realm. + def http_basic(options = {}, &block) + options[:realm] ||= "API Authorization" + auth :http_basic, options, &block + end + + def http_digest(options = {}, &block) + options[:realm] ||= "API Authorization" + options[:opaque] ||= "secret" + auth :http_digest, options, &block + end + end + end + end +end diff --git a/lib/grape/middleware/auth/oauth2.rb b/lib/grape/middleware/auth/oauth2.rb deleted file mode 100644 index 4120c280bd..0000000000 --- a/lib/grape/middleware/auth/oauth2.rb +++ /dev/null @@ -1,83 +0,0 @@ -module Grape - module Middleware - module Auth - # OAuth 2.0 authorization for Grape APIs. - class OAuth2 < Grape::Middleware::Base - def default_options - { - token_class: 'AccessToken', - realm: 'OAuth API', - parameter: %w(bearer_token oauth_token access_token), - accepted_headers: %w(HTTP_AUTHORIZATION X_HTTP_AUTHORIZATION X-HTTP_AUTHORIZATION REDIRECT_X_HTTP_AUTHORIZATION), - header: [/Bearer (.*)/i, /OAuth (.*)/i], - required: true - } - end - - def before - verify_token(token_parameter || token_header) - end - - def request - @request ||= Grape::Request.new(env) - end - - def params - @params ||= request.params - end - - def token_parameter - Array(options[:parameter]).each do |p| - return params[p] if params[p] - end - nil - end - - def token_header - return false unless authorization_header - Array(options[:header]).each do |regexp| - return $1 if authorization_header =~ regexp - end - nil - end - - def authorization_header - options[:accepted_headers].each do |head| - return env[head] if env[head] - end - nil - end - - def token_class - @klass ||= eval(options[:token_class]) # rubocop:disable Eval - end - - def verify_token(token) - token = token_class.verify(token) - if token - if token.respond_to?(:expired?) && token.expired? - error_out(401, 'invalid_grant') - else - if !token.respond_to?(:permission_for?) || token.permission_for?(env) - env['api.token'] = token - else - error_out(403, 'insufficient_scope') - end - end - elsif !!options[:required] - error_out(401, 'invalid_grant') - end - end - - def error_out(status, error) - throw :error, - message: error, - status: status, - headers: { - 'WWW-Authenticate' => "OAuth realm='#{options[:realm]}', error='#{error}'" - } - end - end - end - end -end diff --git a/lib/grape/middleware/auth/strategies.rb b/lib/grape/middleware/auth/strategies.rb new file mode 100644 index 0000000000..8ac3a4889c --- /dev/null +++ b/lib/grape/middleware/auth/strategies.rb @@ -0,0 +1,24 @@ +module Grape + module Middleware + module Auth + module Strategies + module_function + + def add(label, strategy, option_fetcher = ->(_) { [] }) + auth_strategies[label] = StrategyInfo.new(strategy, option_fetcher) + end + + def auth_strategies + @auth_strategies ||= { + http_basic: StrategyInfo.new(Rack::Auth::Basic, ->(settings) {[settings[:realm]] }), + http_digest: StrategyInfo.new(Rack::Auth::Digest::MD5, ->(settings) { [settings[:realm], settings[:opaque]] }) + } + end + + def [](label) + auth_strategies[label] + end + end + end + end +end diff --git a/lib/grape/middleware/auth/strategy_info.rb b/lib/grape/middleware/auth/strategy_info.rb new file mode 100644 index 0000000000..13ea751ded --- /dev/null +++ b/lib/grape/middleware/auth/strategy_info.rb @@ -0,0 +1,15 @@ +module Grape + module Middleware + module Auth + StrategyInfo = Struct.new(:auth_class, :settings_fetcher) do + + def create(app, options, &block) + strategy_args = settings_fetcher.call(options) + + auth_class.new(app, *strategy_args, &block) + end + + end + end + end +end diff --git a/spec/grape/middleware/auth/basic_spec.rb b/spec/grape/middleware/auth/basic_spec.rb deleted file mode 100644 index 0fd0d4cbfb..0000000000 --- a/spec/grape/middleware/auth/basic_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -require 'spec_helper' - -require 'base64' - -describe Grape::Middleware::Auth::Basic do - def app - Rack::Builder.new do |b| - b.use Grape::Middleware::Error - b.use(Grape::Middleware::Auth::Basic) do |u, p| - u && p && u == p - end - b.run lambda { |env| [200, {}, ["Hello there."]] } - end - end - - it 'throws a 401 if no auth is given' do - @proc = lambda { false } - get '/whatever' - expect(last_response.status).to eq(401) - end - - it 'authenticates if given valid creds' do - get '/whatever', {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('admin', 'admin') - expect(last_response.status).to eq(200) - end - - it 'throws a 401 is wrong auth is given' do - get '/whatever', {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('admin', 'wrong') - expect(last_response.status).to eq(401) - end -end diff --git a/spec/grape/middleware/auth/digest_spec.rb b/spec/grape/middleware/auth/digest_spec.rb deleted file mode 100644 index f61593b148..0000000000 --- a/spec/grape/middleware/auth/digest_spec.rb +++ /dev/null @@ -1,47 +0,0 @@ -require 'spec_helper' - -RSpec::Matchers.define :be_challenge do - match do |actual_response| - actual_response.status == 401 && - actual_response['WWW-Authenticate'] =~ /^Digest / && - actual_response.body.empty? - end -end - -class Test < Grape::API - http_digest(realm: 'Test Api', opaque: 'secret') do |username| - { 'foo' => 'bar' }[username] - end - - get '/test' do - [{ hey: 'you' }, { there: 'bar' }, { foo: 'baz' }] - end -end - -describe Grape::Middleware::Auth::Digest do - def app - Test - end - - it 'is a digest authentication challenge' do - get '/test' - expect(last_response).to be_challenge - end - - it 'throws a 401 if no auth is given' do - get '/test' - expect(last_response.status).to eq(401) - end - - it 'authenticates if given valid creds' do - digest_authorize "foo", "bar" - get '/test' - expect(last_response.status).to eq(200) - end - - it 'throws a 401 if given invalid creds' do - digest_authorize "bar", "foo" - get '/test' - expect(last_response.status).to eq(401) - end -end diff --git a/spec/grape/middleware/auth/oauth2_spec.rb b/spec/grape/middleware/auth/oauth2_spec.rb deleted file mode 100644 index 026cb11384..0000000000 --- a/spec/grape/middleware/auth/oauth2_spec.rb +++ /dev/null @@ -1,135 +0,0 @@ -require 'spec_helper' - -describe Grape::Middleware::Auth::OAuth2 do - class FakeToken - attr_accessor :token - - def self.verify(token) - FakeToken.new(token) if !!token && %w(g e).include?(token[0..0]) - end - - def initialize(token) - @token = token - end - - def expired? - @token[0..0] == 'e' - end - - def permission_for?(env) - env['PATH_INFO'] == '/forbidden' ? false : true - end - end - - def app - Rack::Builder.app do - use Grape::Middleware::Auth::OAuth2, token_class: 'FakeToken' - run lambda { |env| [200, {}, [(env['api.token'].token if env['api.token'])]] } - end - end - - context 'with the token in the query string' do - context 'and a valid token' do - before { get '/awesome?access_token=g123' } - - it 'sets env["api.token"]' do - expect(last_response.body).to eq('g123') - end - end - - context 'and an invalid token' do - before do - @err = catch :error do - get '/awesome?access_token=b123' - end - end - - it 'throws an error' do - expect(@err[:status]).to eq(401) - end - - it 'sets the WWW-Authenticate header in the response' do - expect(@err[:headers]['WWW-Authenticate']).to eq("OAuth realm='OAuth API', error='invalid_grant'") - end - end - end - - context 'with an expired token' do - before do - @err = catch :error do - get '/awesome?access_token=e123' - end - end - - it 'throws an error' do - expect(@err[:status]).to eq(401) - end - - it 'sets the WWW-Authenticate header in the response to error' do - expect(@err[:headers]['WWW-Authenticate']).to eq("OAuth realm='OAuth API', error='invalid_grant'") - end - end - - %w(HTTP_AUTHORIZATION X_HTTP_AUTHORIZATION X-HTTP_AUTHORIZATION REDIRECT_X_HTTP_AUTHORIZATION).each do |head| - context "with the token in the #{head} header" do - before do - get '/awesome', {}, head => 'OAuth g123' - end - - it 'sets env["api.token"]' do - expect(last_response.body).to eq('g123') - end - end - end - - context 'with the token in the POST body' do - before do - post '/awesome', 'access_token' => 'g123' - end - - it 'sets env["api.token"]' do - expect(last_response.body).to eq('g123') - end - end - - context 'when accessing something outside its scope' do - before do - @err = catch :error do - get '/forbidden?access_token=g123' - end - end - - it 'throws an error' do - expect(@err[:status]).to eq(403) - end - - it 'sets the WWW-Authenticate header in the response to error' do - expect(@err[:headers]['WWW-Authenticate']).to eq("OAuth realm='OAuth API', error='insufficient_scope'") - end - end - - context 'when authorization is not required' do - def app - Rack::Builder.app do - use Grape::Middleware::Auth::OAuth2, token_class: 'FakeToken', required: false - run lambda { |env| [200, {}, [(env['api.token'].token if env['api.token'])]] } - end - end - - context 'with no token' do - before { post '/awesome' } - - it 'succeeds anyway' do - expect(last_response.status).to eq(200) - end - end - - context 'with a valid token' do - before { get '/awesome?access_token=g123' } - - it 'sets env["api.token"]' do - expect(last_response.body).to eq('g123') - end - end - end -end diff --git a/spec/grape/middleware/auth/strategies_spec.rb b/spec/grape/middleware/auth/strategies_spec.rb new file mode 100644 index 0000000000..13003582ed --- /dev/null +++ b/spec/grape/middleware/auth/strategies_spec.rb @@ -0,0 +1,81 @@ +require 'spec_helper' + +require 'base64' + +describe Grape::Middleware::Auth::Strategies do + context 'Basic Auth' do + def app + proc = ->(u, p) { u && p && u == p } + Rack::Builder.new do |b| + b.use Grape::Middleware::Error + b.use(Grape::Middleware::Auth::Base, type: :http_basic, proc: proc) + b.run lambda { |env| [200, {}, ["Hello there."]] } + end + end + + it 'throws a 401 if no auth is given' do + @proc = lambda { false } + get '/whatever' + expect(last_response.status).to eq(401) + end + + it 'authenticates if given valid creds' do + get '/whatever', {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('admin', 'admin') + expect(last_response.status).to eq(200) + end + + it 'throws a 401 is wrong auth is given' do + get '/whatever', {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('admin', 'wrong') + expect(last_response.status).to eq(401) + end + end + + context 'Digest MD5 Auth' do + RSpec::Matchers.define :be_challenge do + match do |actual_response| + actual_response.status == 401 && + actual_response['WWW-Authenticate'] =~ /^Digest / && + actual_response.body.empty? + end + end + + module StrategiesSpec + class Test < Grape::API + http_digest(realm: 'Test Api', opaque: 'secret') do |username| + { 'foo' => 'bar' }[username] + end + + get '/test' do + [{ hey: 'you' }, { there: 'bar' }, { foo: 'baz' }] + end + end + end + + def app + StrategiesSpec::Test + end + + it 'is a digest authentication challenge' do + get '/test' + expect(last_response).to be_challenge + end + + it 'throws a 401 if no auth is given' do + get '/test' + expect(last_response.status).to eq(401) + end + + it 'authenticates if given valid creds' do + digest_authorize "foo", "bar" + get '/test' + expect(last_response.status).to eq(200) + end + + it 'throws a 401 if given invalid creds' do + digest_authorize "bar", "foo" + get '/test' + expect(last_response.status).to eq(401) + end + end + +end