Add some sort of oauth.
This commit is contained in:
parent
220649e579
commit
25ceb90fc6
13 changed files with 290 additions and 46 deletions
|
@ -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
25
auth/scopes.py
Normal 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)}
|
|
@ -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
1
data/model/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
from data.model.legacy import *
|
|
@ -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
142
data/model/oauth.py
Normal 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)
|
|
@ -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):
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -26,3 +26,4 @@ loremipsum
|
||||||
pygithub
|
pygithub
|
||||||
flask-restful
|
flask-restful
|
||||||
jsonschema
|
jsonschema
|
||||||
|
git+https://github.com/NateFerrero/oauth2lib.git
|
Binary file not shown.
Reference in a new issue