From 25ceb90fc60bb9a6e05dfb0484602b063e7766a2 Mon Sep 17 00:00:00 2001 From: jakedt Date: Wed, 12 Mar 2014 12:37:06 -0400 Subject: [PATCH] Add some sort of oauth. --- auth/auth.py | 8 +- auth/scopes.py | 25 +++++ data/database.py | 36 ++++++-- data/model/__init__.py | 1 + data/{model.py => model/legacy.py} | 3 +- data/model/oauth.py | 142 +++++++++++++++++++++++++++++ endpoints/api/__init__.py | 10 +- endpoints/api/discovery.py | 82 +++++++++++------ endpoints/callbacks.py | 1 - endpoints/web.py | 24 +++++ initdb.py | 3 + requirements-nover.txt | 1 + test/data/test.db | Bin 171008 -> 185344 bytes 13 files changed, 290 insertions(+), 46 deletions(-) create mode 100644 auth/scopes.py create mode 100644 data/model/__init__.py rename data/{model.py => model/legacy.py} (99%) create mode 100644 data/model/oauth.py diff --git a/auth/auth.py b/auth/auth.py index fb08b9184..c5ee8c5e9 100644 --- a/auth/auth.py +++ b/auth/auth.py @@ -9,12 +9,12 @@ from data import model from app import app from permissions import QuayDeferredPermissionUser -from util.names import parse_namespace_repository from util.http import abort logger = logging.getLogger(__name__) + def process_basic_auth(auth): normalized = [part.strip() for part in auth.split(' ') if part] if normalized[0].lower() != 'basic' or len(normalized) != 2: @@ -87,7 +87,8 @@ def process_token(auth): (detail.split('=') for detail in token_details)} if 'signature' not in token_vals: 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: token_data = model.load_token_data(token_vals['signature']) @@ -95,7 +96,8 @@ def process_token(auth): except model.InvalidTokenException: logger.warning('Token could not be validated: %s' % 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) ctx = _request_ctx_stack.top diff --git a/auth/scopes.py b/auth/scopes.py new file mode 100644 index 000000000..3a50515a5 --- /dev/null +++ b/auth/scopes.py @@ -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)} diff --git a/data/database.py b/data/database.py index 413d2261c..840265fae 100644 --- a/data/database.py +++ b/data/database.py @@ -271,9 +271,33 @@ class LogEntry(BaseModel): metadata_json = TextField(default='{}') -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] +class OAuthApplication(BaseModel): + client_id = CharField(index=True, default=random_string_generator(length=20)) + client_secret = CharField(default=random_string_generator(length=40)) + redirect_uri = CharField() + organization = ForeignKeyField(User) + + +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] diff --git a/data/model/__init__.py b/data/model/__init__.py new file mode 100644 index 000000000..8258c9c94 --- /dev/null +++ b/data/model/__init__.py @@ -0,0 +1 @@ +from data.model.legacy import * \ No newline at end of file diff --git a/data/model.py b/data/model/legacy.py similarity index 99% rename from data/model.py rename to data/model/legacy.py index fd3b51855..cd21cef57 100644 --- a/data/model.py +++ b/data/model/legacy.py @@ -2,11 +2,10 @@ import bcrypt import logging import datetime import dateutil.parser -import operator import json -from database import * +from data.database import * from util.validation import * from util.names import format_robot_username diff --git a/data/model/oauth.py b/data/model/oauth.py new file mode 100644 index 000000000..af1cb1f44 --- /dev/null +++ b/data/model/oauth.py @@ -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) diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 5bbceb959..7748ab8c3 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -14,6 +14,7 @@ from util.names import parse_namespace_repository from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission, AdministerRepositoryPermission) +from auth import scopes logger = logging.getLogger(__name__) @@ -100,8 +101,9 @@ class RepositoryParamResource(Resource): 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): + @add_method_metadata('oauth2_scope', scope) @wraps(func) def wrapped(self, namespace, repository, *args, **kwargs): permission = permission_class(namespace, repository) @@ -114,9 +116,9 @@ def require_repo_permission(permission_class, allow_public=False): return wrapper -require_repo_read = require_repo_permission(ReadRepositoryPermission, True) -require_repo_write = require_repo_permission(ModifyRepositoryPermission) -require_repo_admin = require_repo_permission(AdministerRepositoryPermission) +require_repo_read = require_repo_permission(ReadRepositoryPermission, scopes.READ_REPO, True) +require_repo_write = require_repo_permission(ModifyRepositoryPermission, scopes.WRITE_REPO) +require_repo_admin = require_repo_permission(AdministerRepositoryPermission, scopes.ADMIN_REPO) def validate_json_request(schema_name): diff --git a/endpoints/api/discovery.py b/endpoints/api/discovery.py index 7ec4c1ae6..8ff3fa6d8 100644 --- a/endpoints/api/discovery.py +++ b/endpoints/api/discovery.py @@ -5,6 +5,7 @@ from flask.ext.restful import Resource, reqparse from endpoints.api import resource, method_metadata, nickname, truthy_bool from app import app +from auth import scopes logger = logging.getLogger(__name__) @@ -46,42 +47,50 @@ def swagger_route_data(): 'required': True, }) - req_schema_name = method_metadata(method, 'request_schema') - if req_schema_name: - parameters.append({ - 'paramType': 'body', - 'name': 'body', - 'description': 'Request body contents.', - 'dataType': req_schema_name, - 'required': True, - }) - - schema = view_class.schemas[req_schema_name] - models[req_schema_name] = schema - - if '__api_query_params' in dir(method): - for param_spec in method.__api_query_params: - new_param = { - 'paramType': 'query', - 'name': param_spec['name'], - 'description': param_spec['help'], - 'dataType': TYPE_CONVERTER[param_spec['type']], - 'required': param_spec['required'], - } - - if len(param_spec['choices']) > 0: - new_param['enum'] = list(param_spec['choices']) - - parameters.append(new_param) - if method is not None: - operations.append({ + req_schema_name = method_metadata(method, 'request_schema') + if req_schema_name: + parameters.append({ + 'paramType': 'body', + 'name': 'body', + 'description': 'Request body contents.', + 'dataType': req_schema_name, + 'required': True, + }) + + schema = view_class.schemas[req_schema_name] + models[req_schema_name] = schema + + if '__api_query_params' in dir(method): + for param_spec in method.__api_query_params: + new_param = { + 'paramType': 'query', + 'name': param_spec['name'], + 'description': param_spec['help'], + 'dataType': TYPE_CONVERTER[param_spec['type']], + 'required': param_spec['required'], + } + + if len(param_spec['choices']) > 0: + new_param['enum'] = list(param_spec['choices']) + + parameters.append(new_param) + + new_operation = { 'method': method_name, 'nickname': method_metadata(method, 'nickname'), 'type': 'void', 'summary': method.__doc__ if method.__doc__ else '', '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) apis.append({ @@ -103,6 +112,19 @@ def swagger_route_data(): 'termsOfServiceUrl': 'https://quay.io/tos', '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, 'models': models, } diff --git a/endpoints/callbacks.py b/endpoints/callbacks.py index a32819aff..9f012ce3e 100644 --- a/endpoints/callbacks.py +++ b/endpoints/callbacks.py @@ -19,7 +19,6 @@ client = app.config['HTTPCLIENT'] callback = Blueprint('callback', __name__) - def exchange_github_code_for_token(code): code = request.args.get('code') payload = { diff --git a/endpoints/web.py b/endpoints/web.py index c2198fb67..5455ec573 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -8,6 +8,7 @@ from flask.ext.login import current_user from urlparse import urlparse from data import model +from data.model.oauth import DatabaseAuthorizationProvider from app import app from auth.permissions import AdministerOrganizationPermission from util.invoice import renderInvoiceToPdf @@ -228,3 +229,26 @@ def build_status_badge(namespace, repository): response = make_response(STATUS_TAGS[status_name]) response.content_type = 'image/svg+xml' 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) diff --git a/initdb.py b/initdb.py index a1e2fe646..3cbd8008b 100644 --- a/initdb.py +++ b/initdb.py @@ -9,6 +9,7 @@ from peewee import (SqliteDatabase, create_model_tables, drop_model_tables, from data.database import * from data import model +from data.model import oauth from app import app @@ -338,6 +339,8 @@ def populate_database(): org.stripe_id = TEST_STRIPE_ID org.save() + oauth.create_application(org, 'http://localhost:8000/o2c.html', client_id='deadbeef') + model.create_robot('neworgrobot', org) owners = model.get_organization_team('buynlarge', 'owners') diff --git a/requirements-nover.txt b/requirements-nover.txt index 8f8f6ff5d..39142b7c5 100644 --- a/requirements-nover.txt +++ b/requirements-nover.txt @@ -26,3 +26,4 @@ loremipsum pygithub flask-restful jsonschema +git+https://github.com/NateFerrero/oauth2lib.git \ No newline at end of file diff --git a/test/data/test.db b/test/data/test.db index 9779208a8859fc77e4860974aa2c534dc515b959..8c7053601aec6f5efe2b6dc877ab6c95adae8382 100644 GIT binary patch delta 8164 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 delta 6202 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