Merge pull request #63 from travis-ci/ps-expiring-tokens
Tokens improvements
This commit is contained in:
commit
fc98bf16e5
|
@ -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
|
||||||
|
|
|
@ -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,23 +28,35 @@ 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
|
||||||
headers['X-OAuth-Scopes'] = scopes.map(&:to_s).join(',')
|
|
||||||
headers['X-Accepted-OAuth-Scopes'] = name.to_s
|
|
||||||
|
|
||||||
if scopes.include? name
|
result = names.any? do |name|
|
||||||
env['travis.scope'] = name
|
if scopes.include?(name) && required_params_match?
|
||||||
headers['Vary'] = 'Accept'
|
headers['X-OAuth-Scopes'] = scopes.map(&:to_s).join(',')
|
||||||
headers['Vary'] << ', Authorization' unless public?
|
headers['X-Accepted-OAuth-Scopes'] = name.to_s
|
||||||
true
|
|
||||||
elsif env['travis.access_token']
|
env['travis.scope'] = name
|
||||||
pass { halt 403, "insufficient access" }
|
headers['Vary'] = 'Accept'
|
||||||
else
|
headers['Vary'] << ', Authorization' unless public?
|
||||||
pass { halt 401, "no access token supplied" }
|
true
|
||||||
|
end
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -22,7 +22,7 @@ module Travis::Api::App::Responders
|
||||||
def apply
|
def apply
|
||||||
super
|
super
|
||||||
|
|
||||||
halt TEMPLATE % data
|
TEMPLATE % data
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
30
spec/integration/responders_spec.rb
Normal file
30
spec/integration/responders_spec.rb
Normal 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
|
49
spec/integration/scopes_spec.rb
Normal file
49
spec/integration/scopes_spec.rb
Normal 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
|
49
spec/unit/access_token_spec.rb
Normal file
49
spec/unit/access_token_spec.rb
Normal 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
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue
Block a user