fully implement access tokens

This commit is contained in:
Konstantin Haase 2012-08-15 00:52:22 +02:00
parent 9317d67693
commit f05ea7198b
16 changed files with 371 additions and 45 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View 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

View 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

View 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

View File

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

View 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

View File

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

View File

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