Add support for direct granting of OAuth tokens and add tests

This allows a client (when authorized in a whitelist) to send direct credentials via a Basic auth header and therefore bypass the OAuth approval UI for that user.
This commit is contained in:
Joseph Schorr 2016-05-13 14:52:22 -04:00
parent f957fbe96d
commit 7933aecf25
5 changed files with 216 additions and 18 deletions

View file

@ -72,24 +72,37 @@ def _validate_and_apply_oauth_token(token):
identity_changed.send(app, identity=new_identity)
def _process_basic_auth(auth):
def _parse_basic_auth_header(auth):
normalized = [part.strip() for part in auth.split(' ') if part]
if normalized[0].lower() != 'basic' or len(normalized) != 2:
logger.debug('Invalid basic auth format.')
return
return None
credentials = [part.decode('utf-8') for part in b64decode(normalized[1]).split(':', 1)]
logger.debug('Found basic auth header: %s', auth)
try:
credentials = [part.decode('utf-8') for part in b64decode(normalized[1]).split(':', 1)]
except TypeError:
logger.exception('Exception when parsing basic auth header')
return None
if len(credentials) != 2:
logger.debug('Invalid basic auth credential format.')
return None
elif credentials[0] == '$token':
return credentials
def _process_basic_auth(auth):
credentials = _parse_basic_auth_header(auth)
if credentials is None:
return
if credentials[0] == '$token':
# Use as token auth
try:
token = model.token.load_token_data(credentials[1])
logger.debug('Successfully validated token: %s', credentials[1])
set_validated_token(token)
identity_changed.send(app, identity=Identity(token.code, 'token'))
return
@ -117,7 +130,6 @@ def _process_basic_auth(auth):
else:
(authenticated, _) = authentication.verify_and_link_user(credentials[0], credentials[1],
basic_auth=True)
if authenticated:
logger.debug('Successfully validated user: %s', authenticated.username)
set_authenticated_user(authenticated)
@ -130,6 +142,23 @@ def _process_basic_auth(auth):
logger.debug('Basic auth present but could not be validated.')
def has_basic_auth(username):
auth = request.headers.get('authorization', '')
if not auth:
return False
credentials = _parse_basic_auth_header(auth)
if not credentials:
return False
(authenticated, _) = authentication.verify_and_link_user(credentials[0], credentials[1],
basic_auth=True)
if not authenticated:
return False
return authenticated.username == username
def generate_signed_token(grants, user_context):
ser = SecureCookieSessionInterface().get_signing_serializer(app)
data_to_sign = {
@ -209,6 +238,22 @@ def process_auth(func):
return wrapper
def process_auth_or_cookie(func):
@wraps(func)
def wrapper(*args, **kwargs):
auth = request.headers.get('authorization', '')
if auth:
logger.debug('Validating auth header: %s', auth)
_process_basic_auth(auth)
else:
logger.debug('No auth header.')
_load_user_from_cookie()
return func(*args, **kwargs)
return wrapper
def require_session_login(func):
@wraps(func)
def wrapper(*args, **kwargs):

View file

@ -348,3 +348,7 @@ class DefaultConfig(object):
# Number of minutes between expiration refresh in minutes
INSTANCE_SERVICE_KEY_REFRESH = 60
# The whitelist of client IDs for OAuth applications that allow for direct login.
DIRECT_OAUTH_CLIENTID_WHITELIST = []

View file

@ -168,22 +168,23 @@ class DatabaseAuthorizationProvider(AuthorizationProvider):
err = 'unsupported_response_type'
return self._make_redirect_error_response(redirect_uri, err)
# Check redirect URI
# Check for a valid client ID.
is_valid_client_id = self.validate_client_id(client_id)
if not is_valid_client_id:
err = 'unauthorized_client'
return self._make_redirect_error_response(redirect_uri, err)
# Check for a valid redirect URI.
is_valid_redirect_uri = self.validate_redirect_uri(client_id, redirect_uri)
if not is_valid_redirect_uri:
return self._invalid_redirect_uri_response()
# Check conditions
is_valid_client_id = self.validate_client_id(client_id)
is_valid_access = self.validate_access()
scope = params.get('scope', '')
are_valid_scopes = self.validate_scope(client_id, scope)
# Return proper error responses on invalid conditions
if not is_valid_client_id:
err = 'unauthorized_client'
return self._make_redirect_error_response(redirect_uri, err)
if not is_valid_access:
err = 'access_denied'
return self._make_redirect_error_response(redirect_uri, err)

View file

@ -12,10 +12,11 @@ import features
from app import app, billing as stripe, build_logs, avatar, signer, log_archive, config_provider
from auth import scopes
from auth.auth import require_session_login, process_oauth
from auth.auth import require_session_login, process_oauth, has_basic_auth, process_auth_or_cookie
from auth.permissions import (AdministerOrganizationPermission, ReadRepositoryPermission,
SuperUserPermission, AdministerRepositoryPermission,
ModifyRepositoryPermission)
from auth.auth_context import get_authenticated_user
from buildtrigger.basehandler import BuildTriggerHandler
from buildtrigger.bitbuckethandler import BitbucketBuildTrigger
from buildtrigger.customhandler import CustomBuildTrigger
@ -452,21 +453,27 @@ def build_status_badge(namespace_name, repo_name):
class FlaskAuthorizationProvider(model.oauth.DatabaseAuthorizationProvider):
def get_authorized_user(self):
return current_user.db_user()
return get_authenticated_user()
def _make_response(self, body='', headers=None, status_code=200):
return make_response(body, status_code, headers)
@web.route('/oauth/authorizeapp', methods=['POST'])
@csrf_protect
@process_auth_or_cookie
def authorize_application():
if not current_user.is_authenticated:
# Check for an authenticated user.
if not get_authenticated_user():
abort(401)
return
provider = FlaskAuthorizationProvider()
# If direct OAuth is not enabled or the user is not directly authed, verify CSRF.
client_id = request.form.get('client_id', None)
whitelist = app.config.get('DIRECT_OAUTH_CLIENTID_WHITELIST', [])
if client_id not in whitelist or not has_basic_auth(get_authenticated_user().username):
verify_csrf()
provider = FlaskAuthorizationProvider()
redirect_uri = request.form.get('redirect_uri', None)
scope = request.form.get('scope', None)
@ -474,7 +481,6 @@ def authorize_application():
return provider.get_token_response('token', client_id, redirect_uri, scope=scope)
@web.route(app.config['LOCAL_OAUTH_HANDLER'], methods=['GET'])
def oauth_local_handler():
if not current_user.is_authenticated:

View file

@ -3,6 +3,7 @@
import json as py_json
import time
import unittest
import base64
from urllib import urlencode
from urlparse import urlparse, urlunparse, parse_qs
@ -86,6 +87,17 @@ class EndpointTestCase(unittest.TestCase):
self.assertEquals(rv.status_code, expected_code)
return rv.data
def postResponse(self, resource_name, headers=None, form=None, with_csrf=True, expected_code=200, **kwargs):
headers = headers or {}
form = form or {}
url = url_for(resource_name, **kwargs)
if with_csrf:
url = EndpointTestCase._add_csrf(url)
rv = self.app.post(url, headers=headers, data=form)
self.assertEquals(rv.status_code, expected_code)
return rv
def login(self, username, password):
rv = self.app.post(EndpointTestCase._add_csrf(api.url_for(Signin)),
data=py_json.dumps(dict(username=username, password=password)),
@ -191,6 +203,136 @@ class WebEndpointTestCase(EndpointTestCase):
self.getResponse('web.redirect_to_namespace', namespace='buynlarge', expected_code=302)
class OAuthTestCase(EndpointTestCase):
def test_authorize_nologin(self):
form = {
'client_id': 'someclient',
'redirect_uri': 'http://localhost:5000/foobar',
'scope': 'user:admin',
}
self.postResponse('web.authorize_application', form=form, with_csrf=True, expected_code=401)
def test_authorize_invalidclient(self):
self.login('devtable', 'password')
form = {
'client_id': 'someclient',
'redirect_uri': 'http://localhost:5000/foobar',
'scope': 'user:admin',
}
resp = self.postResponse('web.authorize_application', form=form, with_csrf=True, expected_code=302)
self.assertEquals('http://localhost:5000/foobar?error=unauthorized_client', resp.headers['Location'])
def test_authorize_invalidscope(self):
self.login('devtable', 'password')
form = {
'client_id': 'deadbeef',
'redirect_uri': 'http://localhost:8000/o2c.html',
'scope': 'invalid:scope',
}
resp = self.postResponse('web.authorize_application', form=form, with_csrf=True, expected_code=302)
self.assertEquals('http://localhost:8000/o2c.html?error=invalid_scope', resp.headers['Location'])
def test_authorize_invalidredirecturi(self):
self.login('devtable', 'password')
# Note: Defined in initdb.py
form = {
'client_id': 'deadbeef',
'redirect_uri': 'http://some/invalid/uri',
'scope': 'user:admin',
}
self.postResponse('web.authorize_application', form=form, with_csrf=True, expected_code=400)
def test_authorize_success(self):
self.login('devtable', 'password')
# Note: Defined in initdb.py
form = {
'client_id': 'deadbeef',
'redirect_uri': 'http://localhost:8000/o2c.html',
'scope': 'user:admin',
}
resp = self.postResponse('web.authorize_application', form=form, with_csrf=True, expected_code=302)
self.assertTrue('access_token=' in resp.headers['Location'])
def test_authorize_nocsrf(self):
self.login('devtable', 'password')
# Note: Defined in initdb.py
form = {
'client_id': 'deadbeef',
'redirect_uri': 'http://localhost:8000/o2c.html',
'scope': 'user:admin',
}
self.postResponse('web.authorize_application', form=form, with_csrf=False, expected_code=403)
def test_authorize_nocsrf_withinvalidheader(self):
self.login('devtable', 'password')
# Note: Defined in initdb.py
form = {
'client_id': 'deadbeef',
'redirect_uri': 'http://localhost:8000/o2c.html',
'scope': 'user:admin',
}
headers = dict(authorization='Some random header')
self.postResponse('web.authorize_application', headers=headers, form=form, with_csrf=False, expected_code=401)
def test_authorize_nocsrf_withbadheader(self):
self.login('devtable', 'password')
# Note: Defined in initdb.py
form = {
'client_id': 'deadbeef',
'redirect_uri': 'http://localhost:8000/o2c.html',
'scope': 'user:admin',
}
headers = dict(authorization='Basic ' + base64.b64encode('devtable:invalidpassword'))
self.postResponse('web.authorize_application', headers=headers, form=form, with_csrf=False, expected_code=401)
def test_authorize_nocsrf_correctheader(self):
# Note: Defined in initdb.py
form = {
'client_id': 'deadbeef',
'redirect_uri': 'http://localhost:8000/o2c.html',
'scope': 'user:admin',
}
# Try without the client id being in the whitelist.
headers = dict(authorization='Basic ' + base64.b64encode('devtable:password'))
self.postResponse('web.authorize_application', headers=headers, form=form, with_csrf=False, expected_code=403)
# Add the client ID to the whitelist and try again.
app.config['DIRECT_OAUTH_CLIENTID_WHITELIST'] = ['deadbeef']
headers = dict(authorization='Basic ' + base64.b64encode('devtable:password'))
resp = self.postResponse('web.authorize_application', headers=headers, form=form, with_csrf=True, expected_code=302)
self.assertTrue('access_token=' in resp.headers['Location'])
def test_authorize_nocsrf_ratelimiting(self):
# Note: Defined in initdb.py
form = {
'client_id': 'deadbeef',
'redirect_uri': 'http://localhost:8000/o2c.html',
'scope': 'user:admin',
}
# Try without the client id being in the whitelist a few times, making sure we eventually get rate limited.
headers = dict(authorization='Basic ' + base64.b64encode('devtable:invalidpassword'))
self.postResponse('web.authorize_application', headers=headers, form=form, with_csrf=False, expected_code=401)
self.postResponse('web.authorize_application', headers=headers, form=form, with_csrf=False, expected_code=429)
class KeyServerTestCase(EndpointTestCase):
def _get_test_jwt_payload(self):
return {