Add an oauth authorization page
This commit is contained in:
parent
ab60a10a93
commit
d469b41899
10 changed files with 287 additions and 41 deletions
|
@ -1,25 +1,33 @@
|
|||
READ_REPO = {
|
||||
'scope': 'repo:read',
|
||||
'description': ('Grants read-only access to all repositories for which the granting user or '
|
||||
' robot has access.')
|
||||
'icon': 'fa-hdd-o',
|
||||
'title': 'View all visible repositories',
|
||||
'description': ('This application will be able to view and pull all repositories visible to the '
|
||||
'granting user or robot account')
|
||||
}
|
||||
|
||||
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.')
|
||||
'icon': 'fa-hdd-o',
|
||||
'title': 'Read/Write to any accessible repositories',
|
||||
'description': ('This application will be able to view, push and pull to all repositories to which the '
|
||||
'granting user or robot account has write access')
|
||||
}
|
||||
|
||||
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.')
|
||||
'icon': 'fa-hdd-o',
|
||||
'title': 'Administer Repositories',
|
||||
'description': ('This application will have administrator access to all repositories to which the '
|
||||
'granting user or robot account has access')
|
||||
}
|
||||
|
||||
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.')
|
||||
'icon': 'fa-plus',
|
||||
'title': 'Create Repositories',
|
||||
'description': ('This application will be able to create repositories in to any namespaces that '
|
||||
'the granting user or robot account is allowed to create repositories')
|
||||
}
|
||||
|
||||
ALL_SCOPES = {scope['scope']:scope for scope in (READ_REPO, WRITE_REPO, ADMIN_REPO, CREATE_REPO)}
|
||||
|
@ -32,3 +40,25 @@ def scopes_from_scope_string(scopes):
|
|||
def validate_scope_string(scopes):
|
||||
decoded = scopes_from_scope_string(scopes)
|
||||
return None not in decoded and len(decoded) > 0
|
||||
|
||||
|
||||
def is_subset_string(full_string, expected_string):
|
||||
""" Returns true if the scopes found in expected_string are also found
|
||||
in full_string.
|
||||
"""
|
||||
full_scopes = scopes_from_scope_string(full_string)
|
||||
expected_scopes = scopes_from_scope_string(expected_string)
|
||||
return expected_scopes.issubset(full_scopes)
|
||||
|
||||
def get_scope_information(scopes_string):
|
||||
scopes = scopes_from_scope_string(scopes_string)
|
||||
scope_info = []
|
||||
for scope in scopes:
|
||||
scope_info.append({
|
||||
'title': ALL_SCOPES[scope]['title'],
|
||||
'scope': ALL_SCOPES[scope]['scope'],
|
||||
'description': ALL_SCOPES[scope]['description'],
|
||||
'icon': ALL_SCOPES[scope]['icon'],
|
||||
})
|
||||
|
||||
return scope_info
|
||||
|
|
|
@ -275,7 +275,10 @@ 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()
|
||||
application_uri = CharField()
|
||||
organization = ForeignKeyField(User)
|
||||
name = CharField()
|
||||
description = TextField(default='')
|
||||
|
||||
|
||||
class OAuthAuthorizationCode(BaseModel):
|
||||
|
|
|
@ -11,11 +11,13 @@ class DatabaseAuthorizationProvider(AuthorizationProvider):
|
|||
raise NotImplementedError('Subclasses must fill in the ability to get the authorized_user.')
|
||||
|
||||
def validate_client_id(self, client_id):
|
||||
return self.get_application_for_client_id(client_id) is not None
|
||||
|
||||
def get_application_for_client_id(self, client_id):
|
||||
try:
|
||||
OAuthApplication.get(client_id=client_id)
|
||||
return True
|
||||
return OAuthApplication.get(client_id=client_id)
|
||||
except OAuthApplication.DoesNotExist:
|
||||
return False
|
||||
return None
|
||||
|
||||
def validate_client_secret(self, client_id, client_secret):
|
||||
try:
|
||||
|
@ -33,12 +35,35 @@ class DatabaseAuthorizationProvider(AuthorizationProvider):
|
|||
except OAuthApplication.DoesNotExist:
|
||||
return False
|
||||
|
||||
def validate_scope(self, client_id, scope):
|
||||
return scopes.validate_scope_string(scope)
|
||||
def validate_scope(self, client_id, scopes_string):
|
||||
return scopes.validate_scope_string(scopes_string)
|
||||
|
||||
def validate_access(self):
|
||||
return self.get_authorized_user() is not None
|
||||
|
||||
def lookup_access_token(self, client_id):
|
||||
try:
|
||||
found = (OAuthAccessToken
|
||||
.select()
|
||||
.join(OAuthApplication)
|
||||
.where(OAuthApplication.client_id == client_id)
|
||||
.get())
|
||||
return found
|
||||
except OAuthAccessToken.DoesNotExist:
|
||||
return None
|
||||
|
||||
def validate_has_scopes(self, client_id, scope):
|
||||
access_token = self.lookup_access_token(client_id)
|
||||
if not access_token:
|
||||
return False
|
||||
|
||||
# Make sure the token is not expired.
|
||||
if access_token.expires_at <= datetime.now():
|
||||
return False
|
||||
|
||||
# Make sure the token contains the given scopes (at least).
|
||||
return scopes.is_subset_string(access_token.scope, scope)
|
||||
|
||||
def from_authorization_code(self, client_id, code, scope):
|
||||
try:
|
||||
found = (OAuthAuthorizationCode
|
||||
|
@ -109,7 +134,7 @@ class DatabaseAuthorizationProvider(AuthorizationProvider):
|
|||
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)
|
||||
are_valid_scopes = self.validate_scope(client_id, scope)
|
||||
|
||||
# Return proper error responses on invalid conditions
|
||||
if not is_valid_client_id:
|
||||
|
@ -120,7 +145,7 @@ class DatabaseAuthorizationProvider(AuthorizationProvider):
|
|||
err = 'access_denied'
|
||||
return self._make_redirect_error_response(redirect_uri, err)
|
||||
|
||||
if not is_valid_scope:
|
||||
if not are_valid_scopes:
|
||||
err = 'invalid_scope'
|
||||
return self._make_redirect_error_response(redirect_uri, err)
|
||||
|
||||
|
@ -138,8 +163,8 @@ class DatabaseAuthorizationProvider(AuthorizationProvider):
|
|||
|
||||
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)
|
||||
def create_application(org, name, application_uri, redirect_uri, **kwargs):
|
||||
return OAuthApplication.create(organization=org, name=name, application_uri=application_uri, redirect_uri=redirect_uri, **kwargs)
|
||||
|
||||
def validate_access_token(access_token):
|
||||
try:
|
||||
|
|
|
@ -102,7 +102,7 @@ def swagger_route_data():
|
|||
swagger_data = {
|
||||
'apiVersion': 'v1',
|
||||
'swaggerVersion': '1.2',
|
||||
'basePath': 'http://ci.devtable.com:5000',
|
||||
'basePath': 'http://localhost:5000',
|
||||
'resourcePath': '/',
|
||||
'info': {
|
||||
'title': 'Quay.io API',
|
||||
|
@ -119,7 +119,7 @@ def swagger_route_data():
|
|||
"implicit": {
|
||||
"tokenName": "access_token",
|
||||
"loginEndpoint": {
|
||||
"url": "http://ci.devtable.com:5000/oauth/authorize",
|
||||
"url": "http://localhost:5000/oauth/authorize",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -14,8 +14,10 @@ from auth.permissions import AdministerOrganizationPermission
|
|||
from util.invoice import renderInvoiceToPdf
|
||||
from util.seo import render_snapshot
|
||||
from util.cache import no_cache
|
||||
from endpoints.common import common_login, render_page_template
|
||||
from endpoints.common import common_login, render_page_template, generate_csrf_token
|
||||
from util.names import parse_repository_name
|
||||
from util.gravatar import compute_hash
|
||||
from auth import scopes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -239,6 +241,27 @@ class FlaskAuthorizationProvider(DatabaseAuthorizationProvider):
|
|||
return make_response(body, status_code, headers)
|
||||
|
||||
|
||||
@web.route('/oauth/authorizeapp', methods=['POST'])
|
||||
def authorize_application():
|
||||
if not current_user.is_authenticated():
|
||||
abort(401)
|
||||
return
|
||||
|
||||
provider = FlaskAuthorizationProvider()
|
||||
client_id = request.form.get('client_id', None)
|
||||
redirect_uri = request.form.get('redirect_uri', None)
|
||||
scope = request.form.get('scope', None)
|
||||
csrf = request.form.get('csrf', None)
|
||||
|
||||
# Verify the csrf token.
|
||||
if csrf != generate_csrf_token():
|
||||
abort(404)
|
||||
return
|
||||
|
||||
# Add the access token.
|
||||
return provider.get_token_response('token', client_id, redirect_uri, scope=scope)
|
||||
|
||||
|
||||
@web.route('/oauth/authorize', methods=['GET'])
|
||||
@no_cache
|
||||
def request_authorization_code():
|
||||
|
@ -248,6 +271,31 @@ def request_authorization_code():
|
|||
redirect_uri = request.args.get('redirect_uri', None)
|
||||
scope = request.args.get('scope', None)
|
||||
|
||||
if not provider.validate_has_scopes(client_id, scope):
|
||||
if not provider.validate_redirect_uri(client_id, redirect_uri):
|
||||
abort(404)
|
||||
return
|
||||
|
||||
# Load the scope information.
|
||||
scope_info = scopes.get_scope_information(scope)
|
||||
|
||||
# Load the application information.
|
||||
oauth_app = provider.get_application_for_client_id(client_id)
|
||||
oauth_app_view = {
|
||||
'name': oauth_app.name,
|
||||
'description': oauth_app.description,
|
||||
'url': oauth_app.application_uri,
|
||||
'organization': {
|
||||
'name': oauth_app.organization.username,
|
||||
'gravatar': compute_hash(oauth_app.organization.email)
|
||||
}
|
||||
}
|
||||
|
||||
# Show the authorization page.
|
||||
return render_page_template('oauthorize.html', scopes=scope_info, application=oauth_app_view,
|
||||
enumerate=enumerate, client_id=client_id, redirect_uri=redirect_uri,
|
||||
scope=scope, csrf_token_val=generate_csrf_token())
|
||||
|
||||
if response_type == 'token':
|
||||
return provider.get_token_response(response_type, client_id, redirect_uri, scope=scope)
|
||||
else:
|
||||
|
|
|
@ -339,7 +339,12 @@ def populate_database():
|
|||
org.stripe_id = TEST_STRIPE_ID
|
||||
org.save()
|
||||
|
||||
oauth.create_application(org, 'http://localhost:8000/o2c.html', client_id='deadbeef')
|
||||
oauth.create_application(org, 'Some Test App', 'http://localhost:8000', 'http://localhost:8000/o2c.html',
|
||||
client_id='deadbeef')
|
||||
|
||||
oauth.create_application(org, 'Some Other Test App', 'http://quay.io', 'http://localhost:8000/o2c.html',
|
||||
client_id='deadpork',
|
||||
description = 'This is another test application')
|
||||
|
||||
model.create_robot('neworgrobot', org)
|
||||
|
||||
|
|
|
@ -3393,3 +3393,84 @@ pre.command:before {
|
|||
.slideinout.ng-hide-add, .slideinout.ng-hide-remove {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.auth-header img {
|
||||
float: left;
|
||||
margin-top: 8px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.auth-scopes .reason {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.auth-scopes ul {
|
||||
margin-top: 10px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.auth-scopes li {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.auth-scopes .scope {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.auth-scopes .scope-container:last-child {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
|
||||
.auth-scopes .panel-default {
|
||||
border: 0px;
|
||||
margin-bottom: 0px;
|
||||
padding-bottom: 10px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.auth-scopes .panel-default:last-child {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
|
||||
.auth-scopes .panel-heading {
|
||||
border: 0px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.auth-scopes .scope .title {
|
||||
min-width: 300px;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.auth-scopes .scope .title:hover {
|
||||
color: #428bca;
|
||||
}
|
||||
|
||||
.auth-scopes .scope .description {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.auth-scopes .scope i {
|
||||
margin-right: 10px;
|
||||
margin-top: 2px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.auth-container .button-bar {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.auth-container .button-bar button {
|
||||
margin: 6px;
|
||||
}
|
||||
|
|
|
@ -47,22 +47,22 @@
|
|||
<script src="//cdn.jsdelivr.net/g/bootbox@4.1.0,underscorejs@1.5.2,restangular@1.2.0"></script>
|
||||
<!-- ,typeahead.js@0.10.1 -->
|
||||
|
||||
<script src="static/lib/loading-bar.js"></script>
|
||||
<script src="static/lib/angular-strap.min.js"></script>
|
||||
<script src="static/lib/angulartics.js"></script>
|
||||
<script src="static/lib/angulartics-mixpanel.js"></script>
|
||||
<script src="static/lib/angulartics-google-analytics.js"></script>
|
||||
<script src="static/lib/angular-md5.js"></script>
|
||||
<script src="static/lib/bindonce.min.js"></script>
|
||||
<script src="static/lib/ansi2html.js"></script>
|
||||
<script src="static/lib/typeahead.bundle.min.js"></script>
|
||||
<script src="/static/lib/loading-bar.js"></script>
|
||||
<script src="/static/lib/angular-strap.min.js"></script>
|
||||
<script src="/static/lib/angulartics.js"></script>
|
||||
<script src="/static/lib/angulartics-mixpanel.js"></script>
|
||||
<script src="/static/lib/angulartics-google-analytics.js"></script>
|
||||
<script src="/static/lib/angular-md5.js"></script>
|
||||
<script src="/static/lib/bindonce.min.js"></script>
|
||||
<script src="/static/lib/ansi2html.js"></script>
|
||||
<script src="/static/lib/typeahead.bundle.min.js"></script>
|
||||
|
||||
<script src="static/lib/angular-moment.min.js"></script>
|
||||
<script src="static/lib/angular-cookies.min.js"></script>
|
||||
<script src="/static/lib/angular-moment.min.js"></script>
|
||||
<script src="/static/lib/angular-cookies.min.js"></script>
|
||||
|
||||
<script src="static/lib/pagedown/Markdown.Converter.js"></script>
|
||||
<script src="static/lib/pagedown/Markdown.Editor.js"></script>
|
||||
<script src="static/lib/pagedown/Markdown.Sanitizer.js"></script>
|
||||
<script src="/static/lib/pagedown/Markdown.Converter.js"></script>
|
||||
<script src="/static/lib/pagedown/Markdown.Editor.js"></script>
|
||||
<script src="/static/lib/pagedown/Markdown.Sanitizer.js"></script>
|
||||
|
||||
{% block added_dependencies %}
|
||||
|
||||
|
@ -73,10 +73,10 @@
|
|||
window.__token = '{{ csrf_token() }}';
|
||||
</script>
|
||||
|
||||
<script src="static/js/tour.js"></script>
|
||||
<script src="static/js/app.js"></script>
|
||||
<script src="static/js/controllers.js"></script>
|
||||
<script src="static/js/graphing.js"></script>
|
||||
<script src="/static/js/tour.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/controllers.js"></script>
|
||||
<script src="/static/js/graphing.js"></script>
|
||||
|
||||
<!-- start Mixpanel --><script type="text/javascript">
|
||||
var isProd = document.location.hostname === 'quay.io';
|
||||
|
|
54
templates/oauthorize.html
Normal file
54
templates/oauthorize.html
Normal file
|
@ -0,0 +1,54 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
<title>Authorize {{ application.name }} · Quay.io</title>
|
||||
{% endblock %}
|
||||
|
||||
{% block body_content %}
|
||||
<div class="container auth-container">
|
||||
<div class="auth-header">
|
||||
<img src="//www.gravatar.com/avatar/{{ application.organization.gravatar }}?s=48&d=identicon">
|
||||
<h2><a href="{{ application.url }}" target="_blank">{{ application.name }}</a></h2>
|
||||
<h4>
|
||||
{{ application.organization.name }}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="auth-scopes">
|
||||
<div class="reason">This application would like permission to:</div>
|
||||
<div class="panel-group">
|
||||
{% for index, scope in enumerate(scopes) %}
|
||||
<div class="scope panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<div class="title-container">
|
||||
<i class="fa {{ scope.icon }} fa-lg"></i>
|
||||
<div class="title" data-toggle="collapse" data-parent="#scopeGroup" data-target="#description-{{ index }}">
|
||||
<a data-toggle="collapse" href="#collapseOne">
|
||||
{{ scope.title }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="description-{{ index }}" class="panel-collapse collapse out">
|
||||
<div class="panel-body">
|
||||
{{ scope.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-bar">
|
||||
<form method="post" action="/oauth/authorizeapp">
|
||||
<input type="hidden" name="client_id" value="{{ client_id }}">
|
||||
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
|
||||
<input type="hidden" name="scope" value="{{ scope }}">
|
||||
<input type="hidden" name="csrf" value="{{ csrf_token_val }}">
|
||||
<button type="submit" class="btn btn-success">Authorize Application</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
Binary file not shown.
Reference in a new issue