fully implement access tokens
This commit is contained in:
parent
9317d67693
commit
f05ea7198b
1
Gemfile
1
Gemfile
|
@ -11,6 +11,7 @@ gem 'yard-sinatra', github: 'rkh/yard-sinatra'
|
|||
group :test do
|
||||
gem 'rspec', '~> 2.11'
|
||||
gem 'factory_girl', '~> 2.4.0'
|
||||
gem 'mocha', '~> 0.12'
|
||||
end
|
||||
|
||||
group :development do
|
||||
|
|
|
@ -20,7 +20,7 @@ GIT
|
|||
|
||||
GIT
|
||||
remote: git://github.com/travis-ci/travis-core.git
|
||||
revision: 0382e80787319cc496400a2e7cb85feb63e278ec
|
||||
revision: b2f6905573a3b947bc6d3df6802bb03f5dc13bae
|
||||
specs:
|
||||
travis-core (0.0.1)
|
||||
actionmailer (~> 3.2.3)
|
||||
|
@ -129,11 +129,14 @@ GEM
|
|||
i18n (>= 0.4.0)
|
||||
mime-types (~> 1.16)
|
||||
treetop (~> 1.4.8)
|
||||
metaclass (0.0.1)
|
||||
metriks (0.9.9.1)
|
||||
atomic (~> 1.0)
|
||||
avl_tree (~> 1.1.2)
|
||||
hitimes (~> 1.1)
|
||||
mime-types (1.19)
|
||||
mocha (0.12.3)
|
||||
metaclass (~> 0.0.1)
|
||||
multi_json (1.3.6)
|
||||
multipart-post (1.1.5)
|
||||
net-http-persistent (2.7)
|
||||
|
@ -227,6 +230,7 @@ DEPENDENCIES
|
|||
foreman
|
||||
hubble!
|
||||
micro_migrations!
|
||||
mocha (~> 0.12)
|
||||
rake (~> 0.9.2)
|
||||
rerun
|
||||
rspec (~> 2.11)
|
||||
|
|
|
@ -7,6 +7,7 @@ require 'backports'
|
|||
require 'rack'
|
||||
require 'rack/protection'
|
||||
require 'active_record'
|
||||
require 'redis'
|
||||
|
||||
# Rack class implementing the HTTP API.
|
||||
# Instances respond to #call.
|
||||
|
@ -15,7 +16,7 @@ require 'active_record'
|
|||
#
|
||||
# Requires TLS in production.
|
||||
class Travis::Api::App
|
||||
autoload :AccessToken, 'travis/api/api/access_token'
|
||||
autoload :AccessToken, 'travis/api/app/access_token'
|
||||
autoload :Responder, 'travis/api/app/responder'
|
||||
autoload :Endpoint, 'travis/api/app/endpoint'
|
||||
autoload :Extensions, 'travis/api/app/extensions'
|
||||
|
@ -57,7 +58,7 @@ class Travis::Api::App
|
|||
Middleware.subclasses.each { |m| use(m) }
|
||||
endpoints = Endpoint.subclasses
|
||||
endpoints -= [Endpoint::Home] if options[:disable_root_endpoint]
|
||||
endpoints.each { |e| map(e.prefix) { run(e) } }
|
||||
endpoints.each { |e| map(e.prefix) { run(e.new) } }
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
require 'travis/api/app'
|
||||
require 'securerandom'
|
||||
require 'redis'
|
||||
|
||||
class Travis::Api::App
|
||||
class AccessToken
|
||||
DEFAULT_SCOPES = [:public, :private]
|
||||
attr_reader :token, :scopes, :user_id
|
||||
|
||||
def self.create(options = {})
|
||||
|
@ -16,21 +16,30 @@ class Travis::Api::App
|
|||
end
|
||||
|
||||
def initialize(options = {})
|
||||
raise ArgumentError, 'must supply either user_id or user' unless options[:user] ^ options[:user_id]
|
||||
raise ArgumentError, 'must supply either user_id or user' unless options.key?(:user) ^ options.key?(:user_id)
|
||||
|
||||
@token = options[:token] || SecureRandom.urlsafe_base64(64)
|
||||
@scopes = Array(options[:scopes] || options[:scope])
|
||||
@scopes = Array(options[:scopes] || options[:scope] || DEFAULT_SCOPES).map(&:to_sym)
|
||||
@user = options[:user]
|
||||
@user_id = options[:user_id] || @user.id
|
||||
@user_id = Integer(options[:user_id] || @user.id)
|
||||
end
|
||||
|
||||
def save
|
||||
key = key(token)
|
||||
redis.del(key)
|
||||
redis.rpush(key, [user_id, nil, *scopes].map(&))
|
||||
redis.rpush(key, [user_id, '', *scopes].map(&:to_s))
|
||||
end
|
||||
|
||||
def user
|
||||
@user ||= User.find(user_id)
|
||||
@user ||= User.find(user_id) if user_id
|
||||
end
|
||||
|
||||
def user?
|
||||
!!user
|
||||
end
|
||||
|
||||
def to_s
|
||||
token
|
||||
end
|
||||
|
||||
module Helpers
|
||||
|
|
|
@ -4,13 +4,10 @@ class Travis::Api::App
|
|||
# Superclass for HTTP endpoints. Takes care of prefixing.
|
||||
class Endpoint < Responder
|
||||
set(:prefix) { "/" << name[/[^:]+$/].underscore }
|
||||
before { content_type :json }
|
||||
register :scoping
|
||||
|
||||
before { content_type :json }
|
||||
error(ActiveRecord::RecordNotFound, Sinatra::NotFound) { not_found }
|
||||
not_found { content_type =~ /json/ ? { 'file' => 'not found' } : 'file not found' }
|
||||
|
||||
# TODO: Dummy method.
|
||||
def self.scope(name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -31,7 +31,7 @@ class Travis::Api::App
|
|||
# This is the recommended way for the official client. We might improve the
|
||||
# authorization flow to support third-party clients in the future, too.
|
||||
class Authorization < Endpoint
|
||||
set prefix: '/auth', default_scope: :private
|
||||
set prefix: '/auth'
|
||||
|
||||
# Parameters:
|
||||
#
|
||||
|
|
|
@ -19,7 +19,7 @@ class Travis::Api::App
|
|||
'uri' => (controller.prefix + route.http_path[1..-2]).gsub('//', '/'),
|
||||
'verb' => route.http_verb,
|
||||
'doc' => route.docstring,
|
||||
'scope' => /scope\W+(\w+)/.match(route.source).try(:[], 1)
|
||||
'scope' => /scope\W+(\w+)/.match(route.source).try(:[], 1) || controller.default_scope.to_s
|
||||
}
|
||||
endpoint = endpoints[controller.prefix] ||= {
|
||||
'name' => namespace.name,
|
||||
|
|
44
lib/travis/api/app/extensions/scoping.rb
Normal file
44
lib/travis/api/app/extensions/scoping.rb
Normal file
|
@ -0,0 +1,44 @@
|
|||
require 'travis/api/app'
|
||||
|
||||
class Travis::Api::App
|
||||
module Extensions
|
||||
module Scoping
|
||||
module Helpers
|
||||
def access_token
|
||||
env['travis.access_token']
|
||||
end
|
||||
|
||||
def user
|
||||
access_token.user if logged_in?
|
||||
end
|
||||
|
||||
def logged_in?
|
||||
!!access_token
|
||||
end
|
||||
|
||||
def scopes
|
||||
logged_in? ? access_token.scopes : settings.anonymous_scopes
|
||||
end
|
||||
end
|
||||
|
||||
def self.registered(app)
|
||||
app.set default_scope: :public, anonymous_scopes: [:public]
|
||||
app.helpers(Helpers)
|
||||
end
|
||||
|
||||
def scope(name)
|
||||
condition do
|
||||
name = settings.default_scope if name == :default
|
||||
headers['X-OAuth-Scopes'] = scopes.map(&:to_s).join(',')
|
||||
headers['X-Accepted-OAuth-Scopes'] = name.to_s
|
||||
scopes.include? name
|
||||
end
|
||||
end
|
||||
|
||||
def route(verb, path, options = {}, &block)
|
||||
options[:scope] ||= :default
|
||||
super(verb, path, options, &block)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,9 +0,0 @@
|
|||
require 'travis/api/app'
|
||||
|
||||
class Travis::Api::App
|
||||
class Middleware
|
||||
# Checks access tokens and sets appropriate scopes.
|
||||
class AccessToken < Middleware
|
||||
end
|
||||
end
|
||||
end
|
42
lib/travis/api/app/middleware/scope_check.rb
Normal file
42
lib/travis/api/app/middleware/scope_check.rb
Normal file
|
@ -0,0 +1,42 @@
|
|||
require 'travis/api/app'
|
||||
|
||||
class Travis::Api::App
|
||||
class Middleware
|
||||
# Checks access tokens and sets appropriate scopes.
|
||||
class ScopeCheck < Middleware
|
||||
before do
|
||||
next unless token
|
||||
access_token = AccessToken.find_by_token(token)
|
||||
halt 403, 'access denied' unless access_token
|
||||
env['travis.access_token'] = access_token
|
||||
end
|
||||
|
||||
after do
|
||||
headers['X-OAuth-Scopes'] ||= begin
|
||||
scopes = Array(env['travis.access_token'].try(:scopes))
|
||||
scopes.map(&:to_s).join(',')
|
||||
end
|
||||
end
|
||||
|
||||
def token
|
||||
@token ||= header_token || query_token
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def query_token
|
||||
params[:access_token] if params[:access_token] and not params[:access_token].empty?
|
||||
end
|
||||
|
||||
def header_token
|
||||
type, payload = env['HTTP_AUTHORIZATION'].to_s.split(" ", 2)
|
||||
return if payload.nil? or payload.empty?
|
||||
|
||||
case type.downcase
|
||||
when 'basic' then payload.unpack("m").first.split(':', 2).first
|
||||
when 'token' then payload.gsub(/^"(.+)"$/, '\1')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
62
spec/endpoint/authorization_spec.rb
Normal file
62
spec/endpoint/authorization_spec.rb
Normal file
|
@ -0,0 +1,62 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Travis::Api::App::Endpoint::Authorization do
|
||||
include Travis::Testing::Stubs
|
||||
|
||||
before do
|
||||
add_endpoint '/info' do
|
||||
get '/login', scope: :private do
|
||||
env['travis.access_token'].user.login
|
||||
end
|
||||
end
|
||||
|
||||
User.stubs(:find_by_login).with(user.login).returns(user)
|
||||
User.stubs(:find).with(user.id).returns(user)
|
||||
end
|
||||
|
||||
describe 'GET /auth/authorize' do
|
||||
pending "not yet implemented"
|
||||
end
|
||||
|
||||
describe 'POST /auth/access_token' do
|
||||
pending "not yet implemented"
|
||||
end
|
||||
|
||||
describe 'POST /auth/github' do
|
||||
before do
|
||||
GH.stubs(:with).with(token: 'private repos').returns stub(:[] => user.login, :headers => {'x-oauth-scopes' => 'repo'})
|
||||
GH.stubs(:with).with(token: 'public repos').returns stub(:[] => user.login, :headers => {'x-oauth-scopes' => 'public_repo'})
|
||||
GH.stubs(:with).with(token: 'no repos').returns stub(:[] => user.login, :headers => {'x-oauth-scopes' => 'user'})
|
||||
GH.stubs(:with).with(token: 'invalid token').raises(Faraday::Error::ClientError, 'CLIENT ERROR!')
|
||||
end
|
||||
|
||||
def get_token(github_token)
|
||||
post('/auth/github', token: github_token).should be_ok
|
||||
parsed_body['access_token']
|
||||
end
|
||||
|
||||
def user_for(github_token)
|
||||
get '/info/login', access_token: get_token(github_token)
|
||||
last_response.status.should == 200
|
||||
User.find_by_login(body)
|
||||
end
|
||||
|
||||
it 'accepts tokens with repo scope' do
|
||||
user_for('private repos').should == user
|
||||
end
|
||||
|
||||
it 'accepts tokens with public_repo scope' do
|
||||
user_for('public repos').should == user
|
||||
end
|
||||
|
||||
it 'rejects tokens with user scope' do
|
||||
post('/auth/github', token: 'no repos').should_not be_ok
|
||||
body.should_not include('access_token')
|
||||
end
|
||||
|
||||
it 'rejects tokens with user scope' do
|
||||
post('/auth/github', token: 'invalid token').should_not be_ok
|
||||
body.should_not include('access_token')
|
||||
end
|
||||
end
|
||||
end
|
65
spec/extensions/scoping_spec.rb
Normal file
65
spec/extensions/scoping_spec.rb
Normal file
|
@ -0,0 +1,65 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Travis::Api::App::Extensions::Scoping do
|
||||
include Travis::Testing::Stubs
|
||||
|
||||
before do
|
||||
mock_app do
|
||||
register Travis::Api::App::Extensions::Scoping
|
||||
get('/') { 'ok' }
|
||||
get('/private', scope: :private) { 'ok' }
|
||||
end
|
||||
|
||||
User.stubs(:find).with(user.id).returns(user)
|
||||
end
|
||||
|
||||
def with_scopes(url, *scopes)
|
||||
token = Travis::Api::App::AccessToken.create(user: user, scopes: scopes)
|
||||
get(url, {}, 'travis.access_token' => token)
|
||||
end
|
||||
|
||||
it 'uses the default scope if no token is given' do
|
||||
get('/').should be_ok
|
||||
headers['X-Accepted-OAuth-Scopes'].should == 'public'
|
||||
headers['X-OAuth-Scopes'].should == 'public'
|
||||
end
|
||||
|
||||
it 'allows overriding scopes for anonymous users' do
|
||||
settings.set anonymous_scopes: [:foo]
|
||||
get('/').should_not be_ok
|
||||
headers['X-Accepted-OAuth-Scopes'].should == 'public'
|
||||
headers['X-OAuth-Scopes'].should == 'foo'
|
||||
end
|
||||
|
||||
it 'allows overriding default scope' do
|
||||
settings.set default_scope: :foo
|
||||
get('/').should_not be_ok
|
||||
headers['X-Accepted-OAuth-Scopes'].should == 'foo'
|
||||
headers['X-OAuth-Scopes'].should == 'public'
|
||||
end
|
||||
|
||||
it 'allows overriding default scope and anonymous scope' do
|
||||
settings.set default_scope: :foo, anonymous_scopes: [:foo, :bar]
|
||||
get('/').should be_ok
|
||||
headers['X-Accepted-OAuth-Scopes'].should == 'foo'
|
||||
headers['X-OAuth-Scopes'].should == 'foo,bar'
|
||||
end
|
||||
|
||||
it 'takes the scope from the access token' do
|
||||
with_scopes('/', :foo).should_not be_ok
|
||||
headers['X-Accepted-OAuth-Scopes'].should == 'public'
|
||||
headers['X-OAuth-Scopes'].should == 'foo'
|
||||
end
|
||||
|
||||
it 'accepts the scope from the condition' do
|
||||
with_scopes('/private', :foo, :bar, :private).should be_ok
|
||||
headers['X-Accepted-OAuth-Scopes'].should == 'private'
|
||||
headers['X-OAuth-Scopes'].should == 'foo,bar,private'
|
||||
end
|
||||
|
||||
it 'rejects if scope from condition is missing' do
|
||||
with_scopes('/private', :foo, :bar).should_not be_ok
|
||||
headers['X-Accepted-OAuth-Scopes'].should == 'private'
|
||||
headers['X-OAuth-Scopes'].should == 'foo,bar'
|
||||
end
|
||||
end
|
|
@ -1,16 +0,0 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Travis::Api::App::Middleware::AccessToken do
|
||||
before do
|
||||
mock_app do
|
||||
use Travis::Api::App::Middleware::AccessToken
|
||||
get('/check_cors') { 'ok' }
|
||||
end
|
||||
end
|
||||
|
||||
it 'sets associated scope properly'
|
||||
it 'lets through requests without a token'
|
||||
it 'reject requests with an invalide token'
|
||||
it 'rejects expired tokens'
|
||||
it 'checks that the token corresponds to Origin'
|
||||
end
|
63
spec/middleware/scope_check_spec.rb
Normal file
63
spec/middleware/scope_check_spec.rb
Normal file
|
@ -0,0 +1,63 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Travis::Api::App::Middleware::ScopeCheck do
|
||||
include Travis::Testing::Stubs
|
||||
|
||||
let :access_token do
|
||||
Travis::Api::App::AccessToken.create(user: user, scope: :foo)
|
||||
end
|
||||
|
||||
before do
|
||||
mock_app do
|
||||
use Travis::Api::App::Middleware::ScopeCheck
|
||||
get('/') { 'ok' }
|
||||
get('/token') { env['travis.access_token'].to_s }
|
||||
end
|
||||
|
||||
User.stubs(:find).with(user.id).returns(user)
|
||||
end
|
||||
|
||||
it 'lets through requests without a token' do
|
||||
get('/').should be_ok
|
||||
body.should == 'ok'
|
||||
headers['X-OAuth-Scopes'].should_not == 'foo'
|
||||
end
|
||||
|
||||
describe 'sets associated scope properly' do
|
||||
it 'accepts Authorization token header' do
|
||||
get('/', {}, 'HTTP_AUTHORIZATION' => "token #{access_token}").should be_ok
|
||||
headers['X-OAuth-Scopes'].should == 'foo'
|
||||
end
|
||||
|
||||
it 'accepts basic auth' do
|
||||
authorize access_token.to_s, 'x'
|
||||
get('/').should be_ok
|
||||
headers['X-OAuth-Scopes'].should == 'foo'
|
||||
end
|
||||
|
||||
it 'accepts query parameters' do
|
||||
get('/', access_token: access_token.to_s).should be_ok
|
||||
headers['X-OAuth-Scopes'].should == 'foo'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'reject requests with an invalide token' do
|
||||
it 'rejects Authorization token header' do
|
||||
get('/', {}, 'HTTP_AUTHORIZATION' => "token foo").should_not be_ok
|
||||
end
|
||||
|
||||
it 'rejects basic auth' do
|
||||
authorize 'foo', 'x'
|
||||
get('/').should_not be_ok
|
||||
end
|
||||
|
||||
it 'rejects query parameters' do
|
||||
get('/', access_token: 'foo').should_not be_ok
|
||||
end
|
||||
end
|
||||
|
||||
it 'sets env["travis.access_token"]' do
|
||||
authorize access_token.to_s, 'x'
|
||||
get('/token').body.should == access_token.to_s
|
||||
end
|
||||
end
|
|
@ -4,14 +4,46 @@ require 'rspec'
|
|||
require 'travis/api/app'
|
||||
require 'sinatra/test_helpers'
|
||||
require 'logger'
|
||||
require 'gh'
|
||||
require 'multi_json'
|
||||
|
||||
Travis.logger = Logger.new(StringIO.new)
|
||||
Travis::Api::App.setup
|
||||
|
||||
Backports.require_relative_dir 'support'
|
||||
|
||||
RSpec.configure do |config|
|
||||
config.expect_with :rspec, :stdlib
|
||||
config.include Sinatra::TestHelpers
|
||||
config.before(:each) { set_app Travis::Api::App.new }
|
||||
module TestHelpers
|
||||
include Sinatra::TestHelpers
|
||||
|
||||
def custom_endpoints
|
||||
@custom_endpoints ||= []
|
||||
end
|
||||
|
||||
def add_endpoint(prefix, &block)
|
||||
endpoint = Sinatra.new(Travis::Api::App::Endpoint, &block)
|
||||
endpoint.set(prefix: prefix)
|
||||
set_app Travis::Api::App.new
|
||||
custom_endpoints << endpoint
|
||||
end
|
||||
|
||||
def parsed_body
|
||||
MultiJson.decode(body)
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.configure do |config|
|
||||
config.mock_framework = :mocha
|
||||
config.expect_with :rspec, :stdlib
|
||||
config.include TestHelpers
|
||||
|
||||
config.before :each do
|
||||
::Redis.connect(url: Travis.config.redis.url).flushdb
|
||||
set_app Travis::Api::App.new
|
||||
end
|
||||
|
||||
config.after :each do
|
||||
custom_endpoints.each do |endpoint|
|
||||
endpoint.superclass.direct_subclasses.delete(endpoint)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -25,6 +25,7 @@ Gem::Specification.new do |s|
|
|||
"README.md",
|
||||
"Rakefile",
|
||||
"config.ru",
|
||||
"config/database.yml",
|
||||
"lib/travis/api/app.rb",
|
||||
"lib/travis/api/app/access_token.rb",
|
||||
"lib/travis/api/app/endpoint.rb",
|
||||
|
@ -33,6 +34,36 @@ Gem::Specification.new do |s|
|
|||
"lib/travis/api/app/endpoint/branches.rb",
|
||||
"lib/travis/api/app/endpoint/builds.rb",
|
||||
"lib/travis/api/app/endpoint/documentation.rb",
|
||||
"lib/travis/api/app/endpoint/documentation/css/bootstrap-responsive.css",
|
||||
"lib/travis/api/app/endpoint/documentation/css/bootstrap-responsive.min.css",
|
||||
"lib/travis/api/app/endpoint/documentation/css/bootstrap.css",
|
||||
"lib/travis/api/app/endpoint/documentation/css/bootstrap.min.css",
|
||||
"lib/travis/api/app/endpoint/documentation/css/prettify.css",
|
||||
"lib/travis/api/app/endpoint/documentation/img/glyphicons-halflings-white.png",
|
||||
"lib/travis/api/app/endpoint/documentation/img/glyphicons-halflings.png",
|
||||
"lib/travis/api/app/endpoint/documentation/img/grid-18px-masked.png",
|
||||
"lib/travis/api/app/endpoint/documentation/js/bootstrap.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/bootstrap.min.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/jquery.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-apollo.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-clj.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-css.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-go.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-hs.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-lisp.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-lua.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-ml.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-n.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-proto.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-scala.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-sql.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-tex.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-vb.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-vhdl.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-wiki.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-xq.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/lang-yaml.js",
|
||||
"lib/travis/api/app/endpoint/documentation/js/prettify.js",
|
||||
"lib/travis/api/app/endpoint/endpoints.rb",
|
||||
"lib/travis/api/app/endpoint/home.rb",
|
||||
"lib/travis/api/app/endpoint/hooks.rb",
|
||||
|
|
Loading…
Reference in New Issue
Block a user