Add some sort of oauth.

This commit is contained in:
jakedt 2014-03-12 12:37:06 -04:00
parent 220649e579
commit 25ceb90fc6
13 changed files with 290 additions and 46 deletions

View file

@ -9,12 +9,12 @@ from data import model
from app import app from app import app
from permissions import QuayDeferredPermissionUser from permissions import QuayDeferredPermissionUser
from util.names import parse_namespace_repository
from util.http import abort from util.http import abort
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def process_basic_auth(auth): def process_basic_auth(auth):
normalized = [part.strip() for part in auth.split(' ') if part] normalized = [part.strip() for part in auth.split(' ') if part]
if normalized[0].lower() != 'basic' or len(normalized) != 2: if normalized[0].lower() != 'basic' or len(normalized) != 2:
@ -87,7 +87,8 @@ def process_token(auth):
(detail.split('=') for detail in token_details)} (detail.split('=') for detail in token_details)}
if 'signature' not in token_vals: if 'signature' not in token_vals:
logger.warning('Token does not contain signature: %s' % auth) logger.warning('Token does not contain signature: %s' % auth)
abort(401, message="Token does not contain a valid signature: %(auth)", issue='invalid-auth-token', auth=auth) abort(401, message="Token does not contain a valid signature: %(auth)",
issue='invalid-auth-token', auth=auth)
try: try:
token_data = model.load_token_data(token_vals['signature']) token_data = model.load_token_data(token_vals['signature'])
@ -95,7 +96,8 @@ def process_token(auth):
except model.InvalidTokenException: except model.InvalidTokenException:
logger.warning('Token could not be validated: %s' % logger.warning('Token could not be validated: %s' %
token_vals['signature']) token_vals['signature'])
abort(401, message="Token could not be validated: %(auth)", issue='invalid-auth-token', auth=auth) abort(401, message="Token could not be validated: %(auth)", issue='invalid-auth-token',
auth=auth)
logger.debug('Successfully validated token: %s' % token_data.code) logger.debug('Successfully validated token: %s' % token_data.code)
ctx = _request_ctx_stack.top ctx = _request_ctx_stack.top

25
auth/scopes.py Normal file
View file

@ -0,0 +1,25 @@
READ_REPO = {
'scope': 'repo:read',
'description': ('Grants read-only access to all repositories for which the granting user or '
' robot has access.')
}
WRITE_REPO = {
'scope': 'repo:write',
'description': ('Grants read-write access to all repositories for which the granting user or '
'robot has access, and is a superset of repo:read.')
}
ADMIN_REPO = {
'scope': 'repo:admin',
'description': ('Grants administrator access to all repositories for which the granting user or '
'robot has access, and is a superset of repo:read and repo:write.')
}
CREATE_REPO = {
'scope': 'repo:create',
'description': ('Grants create repository access to all namespaces for which the granting user '
'or robot is allowed to create repositories.')
}
ALL_SCOPES = {scope['scope']:scope for scope in (READ_REPO, WRITE_REPO, ADMIN_REPO, CREATE_REPO)}

View file

@ -271,9 +271,33 @@ class LogEntry(BaseModel):
metadata_json = TextField(default='{}') metadata_json = TextField(default='{}')
all_models = [User, Repository, Image, AccessToken, Role, class OAuthApplication(BaseModel):
RepositoryPermission, Visibility, RepositoryTag, client_id = CharField(index=True, default=random_string_generator(length=20))
EmailConfirmation, FederatedLogin, LoginService, QueueItem, client_secret = CharField(default=random_string_generator(length=40))
RepositoryBuild, Team, TeamMember, TeamRole, Webhook, redirect_uri = CharField()
LogEntryKind, LogEntry, PermissionPrototype, ImageStorage, organization = ForeignKeyField(User)
BuildTriggerService, RepositoryBuildTrigger]
class OAuthAuthorizationCode(BaseModel):
application = ForeignKeyField(OAuthApplication)
code = CharField(index=True)
scope = CharField()
data = CharField(default=random_string_generator())
class OAuthAccessToken(BaseModel):
application = ForeignKeyField(OAuthApplication)
authorized_user = ForeignKeyField(User)
scope = CharField()
access_token = CharField(index=True)
token_type = CharField(default='Bearer')
expires_at = DateTimeField()
refresh_token = CharField(index=True, null=True)
data = CharField() # What the hell is this field for?
all_models = [User, Repository, Image, AccessToken, Role, RepositoryPermission, Visibility,
RepositoryTag, EmailConfirmation, FederatedLogin, LoginService, QueueItem,
RepositoryBuild, Team, TeamMember, TeamRole, Webhook, LogEntryKind, LogEntry,
PermissionPrototype, ImageStorage, BuildTriggerService, RepositoryBuildTrigger,
OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken]

1
data/model/__init__.py Normal file
View file

@ -0,0 +1 @@
from data.model.legacy import *

View file

@ -2,11 +2,10 @@ import bcrypt
import logging import logging
import datetime import datetime
import dateutil.parser import dateutil.parser
import operator
import json import json
from database import * from data.database import *
from util.validation import * from util.validation import *
from util.names import format_robot_username from util.names import format_robot_username

142
data/model/oauth.py Normal file
View file

@ -0,0 +1,142 @@
from datetime import datetime, timedelta
from oauth2lib.provider import AuthorizationProvider
from oauth2lib import utils
from data.database import OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken
from auth import scopes
class DatabaseAuthorizationProvider(AuthorizationProvider):
def get_authorized_user(self):
raise NotImplementedError('Subclasses must fill in the ability to get the authorized_user.')
def validate_client_id(self, client_id):
try:
OAuthApplication.get(client_id=client_id)
return True
except OAuthApplication.DoesNotExist:
return False
def validate_client_secret(self, client_id, client_secret):
try:
OAuthApplication.get(client_id=client_id, client_secret=client_secret)
return True
except OAuthApplication.DoesNotExist:
return False
def validate_redirect_uri(self, client_id, redirect_uri):
try:
app = OAuthApplication.get(client_id=client_id)
if app.redirect_uri and redirect_uri.startswith(app.redirect_uri):
return True
return False
except OAuthApplication.DoesNotExist:
return False
def validate_scope(self, client_id, scope):
return scope in scopes.ALL_SCOPES.keys()
def validate_access(self):
return self.get_authorized_user() is not None
def from_authorization_code(self, client_id, code, scope):
try:
found = (OAuthAuthorizationCode
.select()
.join(OAuthApplication)
.where(OAuthApplication.client_id == client_id, OAuthAuthorizationCode.code == code,
OAuthAuthorizationCode.scope == scope)
.get())
return found.data
except OAuthAuthorizationCode.DoesNotExist:
return None
def from_refresh_token(self, client_id, refresh_token, scope):
try:
found = (OAuthAccessToken
.select()
.join(OAuthApplication)
.where(OAuthApplication.client_id == client_id,
OAuthAccessToken.refresh_token == refresh_token,
OAuthAccessToken.scope == scope)
.get())
return found.data
except OAuthAccessToken.DoesNotExist:
return None
def persist_authorization_code(self, client_id, code, scope):
app = OAuthApplication.get(client_id=client_id)
OAuthAuthorizationCode.create(application=app, code=code, scope=scope)
def persist_token_information(self, client_id, scope, access_token, token_type, expires_in,
refresh_token, data):
app = OAuthApplication.get(client_id=client_id)
user = self.get_authorized_user()
expires_at = datetime.now() + timedelta(seconds=expires_in)
OAuthAccessToken.create(application=app, authorized_user=user, scope=scope,
access_token=access_token, token_type=token_type,
expires_at=expires_at, refresh_token=refresh_token, data=data)
def discard_authorization_code(self, client_id, code):
found = (AuthorizationCode
.select()
.join(OAuthApplication)
.where(OAuthApplication.client_id == client_id, OAuthAuthorizationCode.code == code)
.get())
found.delete_instance()
def discard_refresh_token(self, client_id, refresh_token):
found = (AccessToken
.select()
.join(OAuthApplication)
.where(OAuthApplication.client_id == client_id,
OAuthAccessToken.refresh_token == refresh_token)
.get())
found.delete_instance()
def get_token_response(self, response_type, client_id, redirect_uri, **params):
# Ensure proper response_type
if response_type != 'token':
err = 'unsupported_response_type'
return self._make_redirect_error_response(redirect_uri, err)
# Check 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', '')
is_valid_scope = 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)
if not is_valid_scope:
err = 'invalid_scope'
return self._make_redirect_error_response(redirect_uri, err)
access_token = self.generate_access_token()
token_type = self.token_type
expires_in = self.token_expires_in
refresh_token = None # No refresh token for this kind of flow
self.persist_token_information(client_id=client_id, scope=scope, access_token=access_token,
token_type=token_type, expires_in=expires_in,
refresh_token=refresh_token, data='')
url = utils.build_url(redirect_uri, params)
url += '#access_token=%s&token_type=%s&expires_in=%s' % (access_token, token_type, expires_in)
return self._make_response(headers={'Location': url}, status_code=302)
def create_application(org, redirect_uri, **kwargs):
return OAuthApplication.create(organization=org, redirect_uri=redirect_uri, **kwargs)

View file

@ -14,6 +14,7 @@ from util.names import parse_namespace_repository
from auth.permissions import (ReadRepositoryPermission, from auth.permissions import (ReadRepositoryPermission,
ModifyRepositoryPermission, ModifyRepositoryPermission,
AdministerRepositoryPermission) AdministerRepositoryPermission)
from auth import scopes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -100,8 +101,9 @@ class RepositoryParamResource(Resource):
method_decorators = [parse_repository_name] method_decorators = [parse_repository_name]
def require_repo_permission(permission_class, allow_public=False): def require_repo_permission(permission_class, scope, allow_public=False):
def wrapper(func): def wrapper(func):
@add_method_metadata('oauth2_scope', scope)
@wraps(func) @wraps(func)
def wrapped(self, namespace, repository, *args, **kwargs): def wrapped(self, namespace, repository, *args, **kwargs):
permission = permission_class(namespace, repository) permission = permission_class(namespace, repository)
@ -114,9 +116,9 @@ def require_repo_permission(permission_class, allow_public=False):
return wrapper return wrapper
require_repo_read = require_repo_permission(ReadRepositoryPermission, True) require_repo_read = require_repo_permission(ReadRepositoryPermission, scopes.READ_REPO, True)
require_repo_write = require_repo_permission(ModifyRepositoryPermission) require_repo_write = require_repo_permission(ModifyRepositoryPermission, scopes.WRITE_REPO)
require_repo_admin = require_repo_permission(AdministerRepositoryPermission) require_repo_admin = require_repo_permission(AdministerRepositoryPermission, scopes.ADMIN_REPO)
def validate_json_request(schema_name): def validate_json_request(schema_name):

View file

@ -5,6 +5,7 @@ from flask.ext.restful import Resource, reqparse
from endpoints.api import resource, method_metadata, nickname, truthy_bool from endpoints.api import resource, method_metadata, nickname, truthy_bool
from app import app from app import app
from auth import scopes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -46,6 +47,7 @@ def swagger_route_data():
'required': True, 'required': True,
}) })
if method is not None:
req_schema_name = method_metadata(method, 'request_schema') req_schema_name = method_metadata(method, 'request_schema')
if req_schema_name: if req_schema_name:
parameters.append({ parameters.append({
@ -74,14 +76,21 @@ def swagger_route_data():
parameters.append(new_param) parameters.append(new_param)
if method is not None: new_operation = {
operations.append({
'method': method_name, 'method': method_name,
'nickname': method_metadata(method, 'nickname'), 'nickname': method_metadata(method, 'nickname'),
'type': 'void', 'type': 'void',
'summary': method.__doc__ if method.__doc__ else '', 'summary': method.__doc__ if method.__doc__ else '',
'parameters': parameters, 'parameters': parameters,
}) }
scope = method_metadata(method, 'oauth2_scope')
if scope:
new_operation['authorizations'] = {
'oauth2': [scope]
}
operations.append(new_operation)
swagger_path = PARAM_REGEX.sub(r'{\2}', rule.rule) swagger_path = PARAM_REGEX.sub(r'{\2}', rule.rule)
apis.append({ apis.append({
@ -103,6 +112,19 @@ def swagger_route_data():
'termsOfServiceUrl': 'https://quay.io/tos', 'termsOfServiceUrl': 'https://quay.io/tos',
'contact': 'support@quay.io', 'contact': 'support@quay.io',
}, },
'authorizations': {
'oauth2': {
'scopes': list(scopes.ALL_SCOPES.values()),
'grantTypes': {
"implicit": {
"tokenName": "access_token",
"loginEndpoint": {
"url": "http://ci.devtable.com:5000/oauth/authorize",
},
},
},
},
},
'apis': apis, 'apis': apis,
'models': models, 'models': models,
} }

View file

@ -19,7 +19,6 @@ client = app.config['HTTPCLIENT']
callback = Blueprint('callback', __name__) callback = Blueprint('callback', __name__)
def exchange_github_code_for_token(code): def exchange_github_code_for_token(code):
code = request.args.get('code') code = request.args.get('code')
payload = { payload = {

View file

@ -8,6 +8,7 @@ from flask.ext.login import current_user
from urlparse import urlparse from urlparse import urlparse
from data import model from data import model
from data.model.oauth import DatabaseAuthorizationProvider
from app import app from app import app
from auth.permissions import AdministerOrganizationPermission from auth.permissions import AdministerOrganizationPermission
from util.invoice import renderInvoiceToPdf from util.invoice import renderInvoiceToPdf
@ -228,3 +229,26 @@ def build_status_badge(namespace, repository):
response = make_response(STATUS_TAGS[status_name]) response = make_response(STATUS_TAGS[status_name])
response.content_type = 'image/svg+xml' response.content_type = 'image/svg+xml'
return response return response
class FlaskAuthorizationProvider(DatabaseAuthorizationProvider):
def get_authorized_user(self):
return current_user.db_user()
def _make_response(self, body='', headers=None, status_code=200):
return make_response(body, status_code, headers)
@web.route('/oauth/authorize', methods=['GET'])
@no_cache
def request_authorization_code():
provider = FlaskAuthorizationProvider()
response_type = request.args.get('response_type', 'code')
client_id = request.args.get('client_id', None)
redirect_uri = request.args.get('redirect_uri', None)
scope = request.args.get('scope', None)
if response_type == 'token':
return provider.get_token_response(response_type, client_id, redirect_uri, scope=scope)
else:
return provider.get_authorization_code(response_type, client_id, redirect_uri, scope=scope)

View file

@ -9,6 +9,7 @@ from peewee import (SqliteDatabase, create_model_tables, drop_model_tables,
from data.database import * from data.database import *
from data import model from data import model
from data.model import oauth
from app import app from app import app
@ -338,6 +339,8 @@ def populate_database():
org.stripe_id = TEST_STRIPE_ID org.stripe_id = TEST_STRIPE_ID
org.save() org.save()
oauth.create_application(org, 'http://localhost:8000/o2c.html', client_id='deadbeef')
model.create_robot('neworgrobot', org) model.create_robot('neworgrobot', org)
owners = model.get_organization_team('buynlarge', 'owners') owners = model.get_organization_team('buynlarge', 'owners')

View file

@ -26,3 +26,4 @@ loremipsum
pygithub pygithub
flask-restful flask-restful
jsonschema jsonschema
git+https://github.com/NateFerrero/oauth2lib.git

Binary file not shown.