Merge pull request #63 from travis-ci/ps-expiring-tokens

Tokens improvements
This commit is contained in:
Piotr Sarnacki 2013-05-08 05:18:32 -07:00
commit fc98bf16e5
11 changed files with 208 additions and 29 deletions

View File

@ -4,7 +4,7 @@ require 'securerandom'
class Travis::Api::App class Travis::Api::App
class AccessToken class AccessToken
DEFAULT_SCOPES = [:public, :private] 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 = {}) def self.create(options = {})
new(options).tap(&:save) new(options).tap(&:save)
@ -18,25 +18,40 @@ class Travis::Api::App
def self.find_by_token(token) def self.find_by_token(token)
return token if token.is_a? self return token if token.is_a? self
user_id, app_id, *scopes = redis.lrange(key(token), 0, -1) 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 end
def initialize(options = {}) def initialize(options = {})
raise ArgumentError, 'must supply either user_id or user' unless options.key?(:user) ^ options.key?(:user_id) 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) 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]) @app_id = Integer(options[:app_id])
@scopes = Array(options[:scopes] || options[:scope] || DEFAULT_SCOPES).map(&:to_sym) @scopes = Array(options[:scopes] || options[:scope] || DEFAULT_SCOPES).map(&:to_sym)
@user = options[:user] @user = options[:user]
@user_id = Integer(options[:user_id] || @user.id) @user_id = Integer(options[:user_id] || @user.id)
@token = options[:token] || reuse_token || SecureRandom.urlsafe_base64(16) @token = options[:token] || reuse_token || SecureRandom.urlsafe_base64(16)
@extra = options[:extra]
end end
def save def save
key = key(token) key = key(token)
redis.del(key) 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) redis.set(reuse_key, token)
if expires_in
redis.expire(reuse_key, expires_in)
redis.expire(key, expires_in)
end
end end
def user def user
@ -60,6 +75,14 @@ class Travis::Api::App
def key(token) def key(token)
"t:#{token}" "t:#{token}"
end 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 end
include Helpers include Helpers
@ -68,7 +91,7 @@ class Travis::Api::App
private private
def reuse_token def reuse_token
redis.get(reuse_key) redis.get(reuse_key) unless expires_in
end end
def reuse_key def reuse_key

View File

@ -11,6 +11,16 @@ class Travis::Api::App
def public? def public?
scope == :public scope == :public
end 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 end
def self.registered(app) def self.registered(app)
@ -18,24 +28,36 @@ class Travis::Api::App
app.helpers(Helpers) app.helpers(Helpers)
end end
def scope(name) def scope(*names)
condition do 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 scopes = env['travis.access_token'].try(:scopes) || settings.anonymous_scopes
result = names.any? do |name|
if scopes.include?(name) && required_params_match?
headers['X-OAuth-Scopes'] = scopes.map(&:to_s).join(',') headers['X-OAuth-Scopes'] = scopes.map(&:to_s).join(',')
headers['X-Accepted-OAuth-Scopes'] = name.to_s headers['X-Accepted-OAuth-Scopes'] = name.to_s
if scopes.include? name
env['travis.scope'] = name env['travis.scope'] = name
headers['Vary'] = 'Accept' headers['Vary'] = 'Accept'
headers['Vary'] << ', Authorization' unless public? headers['Vary'] << ', Authorization' unless public?
true true
elsif env['travis.access_token'] 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" } pass { halt 403, "insufficient access" }
else else
pass { halt 401, "no access token supplied" } pass { halt 401, "no access token supplied" }
end end
end end
result
end
end end
def route(verb, path, options = {}, &block) def route(verb, path, options = {}, &block)

View File

@ -10,8 +10,8 @@ class Travis::Api::App
def respond_with(resource, options = {}) def respond_with(resource, options = {})
result = respond(resource, options) result = respond(resource, options)
result = result ? result.to_json : 404 result = result.to_json if result && response.content_type =~ /application\/json/
halt result halt result || 404
end end
def body(value = nil, options = {}, &block) def body(value = nil, options = {}, &block)
@ -24,10 +24,18 @@ class Travis::Api::App
def respond(resource, options) def respond(resource, options)
resource = apply_service_responder(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| responders(resource, options).find do |const|
responder = const.new(self, resource, options.dup.merge(accept: accept)) 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
end end

View File

@ -8,7 +8,7 @@ module Travis::Api::App::Responders
headers['Pragma'] = "no-cache" headers['Pragma'] = "no-cache"
headers['Expires'] = Time.now.utc.httpdate headers['Expires'] = Time.now.utc.httpdate
headers['Content-Disposition'] = %(inline; filename="#{File.basename(filename)}") 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 end
private private

View File

@ -10,7 +10,7 @@ class Travis::Api::App
def apply def apply
super super
halt result.to_json if result result
end end
private private

View File

@ -21,7 +21,7 @@ module Travis::Api::App::Responders
headers['Content-Disposition'] = %(#{disposition}; filename="#{filename}") headers['Content-Disposition'] = %(#{disposition}; filename="#{filename}")
halt(params[:deansi] ? clear_ansi(resource.content) : resource.content) params[:deansi] ? clear_ansi(resource.content) : resource.content
end end
private private

View File

@ -22,7 +22,7 @@ module Travis::Api::App::Responders
def apply def apply
super super
halt TEMPLATE % data TEMPLATE % data
end end
private private

View File

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

View File

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

View File

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

View File

@ -23,8 +23,7 @@ module Travis::Api::App::Responders
let(:resource) { { foo: 'bar' } } let(:resource) { { foo: 'bar' } }
it 'returns resource converted to_json' do it 'returns resource converted to_json' do
json.expects(:halt).with({ foo: 'bar' }.to_json) json.apply.should == { foo: 'bar' }
json.apply
end end
end end
@ -46,8 +45,7 @@ module Travis::Api::App::Responders
end end
it 'returns proper data converted to json' do it 'returns proper data converted to json' do
json.expects(:halt).with({ foo: 'bar' }.to_json) json.apply.should == { foo: 'bar' }
json.apply
end end
end end
end end