diff --git a/lib/travis/api/app/access_token.rb b/lib/travis/api/app/access_token.rb index a8e324b9..99a70ad5 100644 --- a/lib/travis/api/app/access_token.rb +++ b/lib/travis/api/app/access_token.rb @@ -4,7 +4,7 @@ require 'securerandom' class Travis::Api::App class AccessToken DEFAULT_SCOPES = [:public, :private] - attr_reader :token, :scopes, :user_id, :app_id + attr_reader :token, :scopes, :user_id, :app_id, :expires_in, :extra def self.create(options = {}) new(options).tap(&:save) @@ -18,25 +18,40 @@ class Travis::Api::App def self.find_by_token(token) return token if token.is_a? self user_id, app_id, *scopes = redis.lrange(key(token), 0, -1) - new(token: token, scopes: scopes, user_id: user_id, app_id: app_id) if user_id + extra = decode_json(scopes.pop) if scopes.last && scopes.last =~ /^json:/ + new(token: token, scopes: scopes, user_id: user_id, app_id: app_id, extra: extra) if user_id end def initialize(options = {}) raise ArgumentError, 'must supply either user_id or user' unless options.key?(:user) ^ options.key?(:user_id) raise ArgumentError, 'must supply app_id' unless options.key?(:app_id) + begin + @expires_in = Integer(options[:expires_in]) if options[:expires_in] + rescue ArgumentError + raise ArgumentError, 'expires_in must be of integer type' + end + @app_id = Integer(options[:app_id]) @scopes = Array(options[:scopes] || options[:scope] || DEFAULT_SCOPES).map(&:to_sym) @user = options[:user] @user_id = Integer(options[:user_id] || @user.id) @token = options[:token] || reuse_token || SecureRandom.urlsafe_base64(16) + @extra = options[:extra] end def save key = key(token) redis.del(key) - redis.rpush(key, [user_id, app_id, *scopes].map(&:to_s)) + data = [user_id, app_id, *scopes] + data << encode_json(extra) if extra + redis.rpush(key, data.map(&:to_s)) redis.set(reuse_key, token) + + if expires_in + redis.expire(reuse_key, expires_in) + redis.expire(key, expires_in) + end end def user @@ -60,6 +75,14 @@ class Travis::Api::App def key(token) "t:#{token}" end + + def encode_json(hash) + 'json:' + Base64.encode64(hash.to_json) + end + + def decode_json(json) + JSON.parse(Base64.decode64(json.gsub(/^json:/, ''))) + end end include Helpers @@ -68,7 +91,7 @@ class Travis::Api::App private def reuse_token - redis.get(reuse_key) + redis.get(reuse_key) unless expires_in end def reuse_key diff --git a/lib/travis/api/app/extensions/scoping.rb b/lib/travis/api/app/extensions/scoping.rb index 626bdbb2..5945804e 100644 --- a/lib/travis/api/app/extensions/scoping.rb +++ b/lib/travis/api/app/extensions/scoping.rb @@ -11,6 +11,16 @@ class Travis::Api::App def public? scope == :public end + + def required_params_match? + return true unless token = env['travis.access_token'] + + if token.extra && (required_params = token.extra['required_params']) + required_params.all? { |name, value| params[name] == value } + else + true + end + end end def self.registered(app) @@ -18,23 +28,35 @@ class Travis::Api::App app.helpers(Helpers) end - def scope(name) + def scope(*names) condition do - name = settings.default_scope if name == :default + names = [settings.default_scope] if names == [:default] scopes = env['travis.access_token'].try(:scopes) || settings.anonymous_scopes - headers['X-OAuth-Scopes'] = scopes.map(&:to_s).join(',') - headers['X-Accepted-OAuth-Scopes'] = name.to_s - if scopes.include? name - env['travis.scope'] = name - headers['Vary'] = 'Accept' - headers['Vary'] << ', Authorization' unless public? - true - elsif env['travis.access_token'] - pass { halt 403, "insufficient access" } - else - pass { halt 401, "no access token supplied" } + result = names.any? do |name| + if scopes.include?(name) && required_params_match? + headers['X-OAuth-Scopes'] = scopes.map(&:to_s).join(',') + headers['X-Accepted-OAuth-Scopes'] = name.to_s + + env['travis.scope'] = name + headers['Vary'] = 'Accept' + headers['Vary'] << ', Authorization' unless public? + true + end end + + if !result + headers['X-OAuth-Scopes'] = scopes.map(&:to_s).join(',') + headers['X-Accepted-OAuth-Scopes'] = names.first.to_s + + if env['travis.access_token'] + pass { halt 403, "insufficient access" } + else + pass { halt 401, "no access token supplied" } + end + end + + result end end diff --git a/lib/travis/api/app/helpers/respond_with.rb b/lib/travis/api/app/helpers/respond_with.rb index a5b19204..19f4c4fd 100644 --- a/lib/travis/api/app/helpers/respond_with.rb +++ b/lib/travis/api/app/helpers/respond_with.rb @@ -10,8 +10,8 @@ class Travis::Api::App def respond_with(resource, options = {}) result = respond(resource, options) - result = result ? result.to_json : 404 - halt result + result = result.to_json if result && response.content_type =~ /application\/json/ + halt result || 404 end def body(value = nil, options = {}, &block) @@ -24,10 +24,18 @@ class Travis::Api::App def respond(resource, options) resource = apply_service_responder(resource, options) - response = acceptable_formats.find do |accept| + response = nil + acceptable_formats.find do |accept| responders(resource, options).find do |const| responder = const.new(self, resource, options.dup.merge(accept: accept)) - responder.apply if responder.apply? + response = responder.apply if responder.apply? + end + end + + if responders = options[:responders] + responders.each do |klass| + responder = klass.new(self, response, options) + response = responder.apply if responder.apply? end end diff --git a/lib/travis/api/app/responders/image.rb b/lib/travis/api/app/responders/image.rb index d80d28e4..c6c6a17d 100644 --- a/lib/travis/api/app/responders/image.rb +++ b/lib/travis/api/app/responders/image.rb @@ -8,7 +8,7 @@ module Travis::Api::App::Responders headers['Pragma'] = "no-cache" headers['Expires'] = Time.now.utc.httpdate headers['Content-Disposition'] = %(inline; filename="#{File.basename(filename)}") - halt send_file(filename, type: :png, last_modified: last_modified) + send_file(filename, type: :png, last_modified: last_modified) end private diff --git a/lib/travis/api/app/responders/json.rb b/lib/travis/api/app/responders/json.rb index 15388f6b..012b2be8 100644 --- a/lib/travis/api/app/responders/json.rb +++ b/lib/travis/api/app/responders/json.rb @@ -10,7 +10,7 @@ class Travis::Api::App def apply super - halt result.to_json if result + result end private diff --git a/lib/travis/api/app/responders/plain.rb b/lib/travis/api/app/responders/plain.rb index 9d045478..63d3d223 100644 --- a/lib/travis/api/app/responders/plain.rb +++ b/lib/travis/api/app/responders/plain.rb @@ -21,7 +21,7 @@ module Travis::Api::App::Responders headers['Content-Disposition'] = %(#{disposition}; filename="#{filename}") - halt(params[:deansi] ? clear_ansi(resource.content) : resource.content) + params[:deansi] ? clear_ansi(resource.content) : resource.content end private diff --git a/lib/travis/api/app/responders/xml.rb b/lib/travis/api/app/responders/xml.rb index b69911b1..836d5f20 100644 --- a/lib/travis/api/app/responders/xml.rb +++ b/lib/travis/api/app/responders/xml.rb @@ -22,7 +22,7 @@ module Travis::Api::App::Responders def apply super - halt TEMPLATE % data + TEMPLATE % data end private diff --git a/spec/integration/responders_spec.rb b/spec/integration/responders_spec.rb new file mode 100644 index 00000000..2dc2cb78 --- /dev/null +++ b/spec/integration/responders_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe 'App' do + before do + FactoryGirl.create(:test, :number => '3.1', :queue => 'builds.common') + + responder = Class.new(Travis::Api::App::Responders::Base) do + def apply? + true + end + + def apply + resource[:extra] = 'moar!' + + resource + end + end + + add_endpoint '/foo' do + get '/hash' do + respond_with({ foo: 'bar' }, responders: [responder]) + end + end + end + + it 'runs responder when rendering the response with respond_with' do + response = get '/foo/hash', {}, 'HTTP_ACCEPT' => 'application/json' + JSON.parse(response.body).should == { 'foo' => 'bar', 'extra' => 'moar!' } + end +end diff --git a/spec/integration/scopes_spec.rb b/spec/integration/scopes_spec.rb new file mode 100644 index 00000000..f06fd4db --- /dev/null +++ b/spec/integration/scopes_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe 'App' do + before do + FactoryGirl.create(:test, :number => '3.1', :queue => 'builds.common') + + add_endpoint '/foo' do + get '/:id/bar', scope: [:foo, :bar] do + respond_with foo: 'bar' + end + + get '/:job_id/log' do + respond_with job_id: params[:job_id] + end + end + end + + it 'checks if token has one of the required scopes' do + token = Travis::Api::App::AccessToken.new(app_id: 1, user_id: 2, scopes: [:foo]).tap(&:save) + + response = get '/foo/1/bar', {}, 'HTTP_ACCEPT' => 'application/json; version=2', 'HTTP_AUTHORIZATION' => "token #{token.token}" + response.should be_successful + response.headers['X-Accepted-OAuth-Scopes'].should == 'foo' + + token = Travis::Api::App::AccessToken.new(app_id: 1, user_id: 2, scopes: [:bar]).tap(&:save) + + response = get '/foo/1/bar', {}, 'HTTP_ACCEPT' => 'application/json; version=2', 'HTTP_AUTHORIZATION' => "token #{token.token}" + response.should be_successful + response.headers['X-Accepted-OAuth-Scopes'].should == 'bar' + + token = Travis::Api::App::AccessToken.new(app_id: 1, user_id: 2, scopes: [:baz]).tap(&:save) + + response = get '/foo/1/bar', {}, 'HTTP_ACCEPT' => 'application/json; version=2', 'HTTP_AUTHORIZATION' => "token #{token.token}" + response.status.should == 404 + end + + it 'checks if required_params match the from the request' do + extra = { + required_params: { job_id: '10' } + } + token = Travis::Api::App::AccessToken.new(app_id: 1, user_id: 2, extra: extra).tap(&:save) + + response = get '/foo/10/log', {}, 'HTTP_ACCEPT' => 'application/json', 'HTTP_AUTHORIZATION' => "token #{token.token}" + response.should be_successful + + response = get '/foo/11/log', {}, 'HTTP_ACCEPT' => 'application/json', 'HTTP_AUTHORIZATION' => "token #{token.token}" + response.status.should == 403 + end +end diff --git a/spec/unit/access_token_spec.rb b/spec/unit/access_token_spec.rb new file mode 100644 index 00000000..3cb8794b --- /dev/null +++ b/spec/unit/access_token_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe Travis::Api::App::AccessToken do + it 'errors out on wrong type of :expires_in argument' do + expect { + described_class.new(app_id: 1, user_id: 2, expires_in: 'foo') + }.to raise_error(ArgumentError, 'expires_in must be of integer type') + end + + it 'allows to skip expires_in' do + expect { + described_class.new(app_id: 1, user_id: 2, expires_in: nil) + }.to_not raise_error(ArgumentError) + end + + it 'does not reuse token if expires_in is set' do + token = described_class.new(app_id: 1, user_id: 2).tap(&:save) + new_token = described_class.new(app_id: 1, user_id: 2, expires_in: 10) + + token.token.should_not == new_token.token + end + + it 'expires the token after given period of time' do + token = described_class.new(app_id: 1, user_id: 2, expires_in: 1).tap(&:save) + + described_class.find_by_token(token.token).should_not be_nil + + sleep 2 + + described_class.find_by_token(token.token).should be_nil + end + + it 'allows to save extra information' do + attrs = { + app_id: 1, + user_id: 3, + expires_in: 1, + extra: { + required_params: { job_id: '1' } + } + } + + token = described_class.new(attrs).tap(&:save) + token.extra.should == attrs[:extra] + + token = described_class.find_by_token(token.token) + token.extra.should == { 'required_params' => { 'job_id' => '1' } } + end +end diff --git a/spec/unit/responders/json_spec.rb b/spec/unit/responders/json_spec.rb index 37146d58..5b43bbc1 100644 --- a/spec/unit/responders/json_spec.rb +++ b/spec/unit/responders/json_spec.rb @@ -23,8 +23,7 @@ module Travis::Api::App::Responders let(:resource) { { foo: 'bar' } } it 'returns resource converted to_json' do - json.expects(:halt).with({ foo: 'bar' }.to_json) - json.apply + json.apply.should == { foo: 'bar' } end end @@ -46,8 +45,7 @@ module Travis::Api::App::Responders end it 'returns proper data converted to json' do - json.expects(:halt).with({ foo: 'bar' }.to_json) - json.apply + json.apply.should == { foo: 'bar' } end end end