Add an oauth authorization page

This commit is contained in:
Joseph Schorr 2014-03-14 18:57:28 -04:00
parent ab60a10a93
commit d469b41899
10 changed files with 287 additions and 41 deletions

View file

@ -1,25 +1,33 @@
READ_REPO = { READ_REPO = {
'scope': 'repo:read', 'scope': 'repo:read',
'description': ('Grants read-only access to all repositories for which the granting user or ' 'icon': 'fa-hdd-o',
' robot has access.') '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 = { WRITE_REPO = {
'scope': 'repo:write', 'scope': 'repo:write',
'description': ('Grants read-write access to all repositories for which the granting user or ' 'icon': 'fa-hdd-o',
'robot has access, and is a superset of repo:read.') '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 = { ADMIN_REPO = {
'scope': 'repo:admin', 'scope': 'repo:admin',
'description': ('Grants administrator access to all repositories for which the granting user or ' 'icon': 'fa-hdd-o',
'robot has access, and is a superset of repo:read and repo:write.') '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 = { CREATE_REPO = {
'scope': 'repo:create', 'scope': 'repo:create',
'description': ('Grants create repository access to all namespaces for which the granting user ' 'icon': 'fa-plus',
'or robot is allowed to create repositories.') '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)} 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): def validate_scope_string(scopes):
decoded = scopes_from_scope_string(scopes) decoded = scopes_from_scope_string(scopes)
return None not in decoded and len(decoded) > 0 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

View file

@ -275,7 +275,10 @@ class OAuthApplication(BaseModel):
client_id = CharField(index=True, default=random_string_generator(length=20)) client_id = CharField(index=True, default=random_string_generator(length=20))
client_secret = CharField(default=random_string_generator(length=40)) client_secret = CharField(default=random_string_generator(length=40))
redirect_uri = CharField() redirect_uri = CharField()
application_uri = CharField()
organization = ForeignKeyField(User) organization = ForeignKeyField(User)
name = CharField()
description = TextField(default='')
class OAuthAuthorizationCode(BaseModel): class OAuthAuthorizationCode(BaseModel):

View file

@ -11,11 +11,13 @@ class DatabaseAuthorizationProvider(AuthorizationProvider):
raise NotImplementedError('Subclasses must fill in the ability to get the authorized_user.') raise NotImplementedError('Subclasses must fill in the ability to get the authorized_user.')
def validate_client_id(self, client_id): 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: try:
OAuthApplication.get(client_id=client_id) return OAuthApplication.get(client_id=client_id)
return True
except OAuthApplication.DoesNotExist: except OAuthApplication.DoesNotExist:
return False return None
def validate_client_secret(self, client_id, client_secret): def validate_client_secret(self, client_id, client_secret):
try: try:
@ -33,12 +35,35 @@ class DatabaseAuthorizationProvider(AuthorizationProvider):
except OAuthApplication.DoesNotExist: except OAuthApplication.DoesNotExist:
return False return False
def validate_scope(self, client_id, scope): def validate_scope(self, client_id, scopes_string):
return scopes.validate_scope_string(scope) return scopes.validate_scope_string(scopes_string)
def validate_access(self): def validate_access(self):
return self.get_authorized_user() is not None 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): def from_authorization_code(self, client_id, code, scope):
try: try:
found = (OAuthAuthorizationCode found = (OAuthAuthorizationCode
@ -109,7 +134,7 @@ class DatabaseAuthorizationProvider(AuthorizationProvider):
is_valid_client_id = self.validate_client_id(client_id) is_valid_client_id = self.validate_client_id(client_id)
is_valid_access = self.validate_access() is_valid_access = self.validate_access()
scope = params.get('scope', '') 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 # Return proper error responses on invalid conditions
if not is_valid_client_id: if not is_valid_client_id:
@ -120,7 +145,7 @@ class DatabaseAuthorizationProvider(AuthorizationProvider):
err = 'access_denied' err = 'access_denied'
return self._make_redirect_error_response(redirect_uri, err) return self._make_redirect_error_response(redirect_uri, err)
if not is_valid_scope: if not are_valid_scopes:
err = 'invalid_scope' err = 'invalid_scope'
return self._make_redirect_error_response(redirect_uri, err) 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) return self._make_response(headers={'Location': url}, status_code=302)
def create_application(org, redirect_uri, **kwargs): def create_application(org, name, application_uri, redirect_uri, **kwargs):
return OAuthApplication.create(organization=org, redirect_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): def validate_access_token(access_token):
try: try:

View file

@ -102,7 +102,7 @@ def swagger_route_data():
swagger_data = { swagger_data = {
'apiVersion': 'v1', 'apiVersion': 'v1',
'swaggerVersion': '1.2', 'swaggerVersion': '1.2',
'basePath': 'http://ci.devtable.com:5000', 'basePath': 'http://localhost:5000',
'resourcePath': '/', 'resourcePath': '/',
'info': { 'info': {
'title': 'Quay.io API', 'title': 'Quay.io API',
@ -119,7 +119,7 @@ def swagger_route_data():
"implicit": { "implicit": {
"tokenName": "access_token", "tokenName": "access_token",
"loginEndpoint": { "loginEndpoint": {
"url": "http://ci.devtable.com:5000/oauth/authorize", "url": "http://localhost:5000/oauth/authorize",
}, },
}, },
}, },

View file

@ -14,8 +14,10 @@ from auth.permissions import AdministerOrganizationPermission
from util.invoice import renderInvoiceToPdf from util.invoice import renderInvoiceToPdf
from util.seo import render_snapshot from util.seo import render_snapshot
from util.cache import no_cache 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.names import parse_repository_name
from util.gravatar import compute_hash
from auth import scopes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -239,6 +241,27 @@ class FlaskAuthorizationProvider(DatabaseAuthorizationProvider):
return make_response(body, status_code, headers) 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']) @web.route('/oauth/authorize', methods=['GET'])
@no_cache @no_cache
def request_authorization_code(): def request_authorization_code():
@ -248,6 +271,31 @@ def request_authorization_code():
redirect_uri = request.args.get('redirect_uri', None) redirect_uri = request.args.get('redirect_uri', None)
scope = request.args.get('scope', 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': if response_type == 'token':
return provider.get_token_response(response_type, client_id, redirect_uri, scope=scope) return provider.get_token_response(response_type, client_id, redirect_uri, scope=scope)
else: else:

View file

@ -339,7 +339,12 @@ 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') 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) model.create_robot('neworgrobot', org)

View file

@ -3393,3 +3393,84 @@ pre.command:before {
.slideinout.ng-hide-add, .slideinout.ng-hide-remove { .slideinout.ng-hide-add, .slideinout.ng-hide-remove {
display: block !important; 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;
}

View file

@ -47,22 +47,22 @@
<script src="//cdn.jsdelivr.net/g/bootbox@4.1.0,underscorejs@1.5.2,restangular@1.2.0"></script> <script src="//cdn.jsdelivr.net/g/bootbox@4.1.0,underscorejs@1.5.2,restangular@1.2.0"></script>
<!-- ,typeahead.js@0.10.1 --> <!-- ,typeahead.js@0.10.1 -->
<script src="static/lib/loading-bar.js"></script> <script src="/static/lib/loading-bar.js"></script>
<script src="static/lib/angular-strap.min.js"></script> <script src="/static/lib/angular-strap.min.js"></script>
<script src="static/lib/angulartics.js"></script> <script src="/static/lib/angulartics.js"></script>
<script src="static/lib/angulartics-mixpanel.js"></script> <script src="/static/lib/angulartics-mixpanel.js"></script>
<script src="static/lib/angulartics-google-analytics.js"></script> <script src="/static/lib/angulartics-google-analytics.js"></script>
<script src="static/lib/angular-md5.js"></script> <script src="/static/lib/angular-md5.js"></script>
<script src="static/lib/bindonce.min.js"></script> <script src="/static/lib/bindonce.min.js"></script>
<script src="static/lib/ansi2html.js"></script> <script src="/static/lib/ansi2html.js"></script>
<script src="static/lib/typeahead.bundle.min.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-moment.min.js"></script>
<script src="static/lib/angular-cookies.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.Converter.js"></script>
<script src="static/lib/pagedown/Markdown.Editor.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.Sanitizer.js"></script>
{% block added_dependencies %} {% block added_dependencies %}
@ -73,10 +73,10 @@
window.__token = '{{ csrf_token() }}'; window.__token = '{{ csrf_token() }}';
</script> </script>
<script src="static/js/tour.js"></script> <script src="/static/js/tour.js"></script>
<script src="static/js/app.js"></script> <script src="/static/js/app.js"></script>
<script src="static/js/controllers.js"></script> <script src="/static/js/controllers.js"></script>
<script src="static/js/graphing.js"></script> <script src="/static/js/graphing.js"></script>
<!-- start Mixpanel --><script type="text/javascript"> <!-- start Mixpanel --><script type="text/javascript">
var isProd = document.location.hostname === 'quay.io'; var isProd = document.location.hostname === 'quay.io';

54
templates/oauthorize.html Normal file
View 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.