From d469b4189992463f7a8c3acd5f1b62f800a6f820 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 14 Mar 2014 18:57:28 -0400 Subject: [PATCH] Add an oauth authorization page --- auth/scopes.py | 46 ++++++++++++++++---- data/database.py | 3 ++ data/model/oauth.py | 45 +++++++++++++++----- endpoints/api/discovery.py | 4 +- endpoints/web.py | 50 +++++++++++++++++++++- initdb.py | 7 +++- static/css/quay.css | 83 ++++++++++++++++++++++++++++++++++++- templates/base.html | 36 ++++++++-------- templates/oauthorize.html | 54 ++++++++++++++++++++++++ test/data/test.db | Bin 185344 -> 499712 bytes 10 files changed, 287 insertions(+), 41 deletions(-) create mode 100644 templates/oauthorize.html diff --git a/auth/scopes.py b/auth/scopes.py index 6dbda6c72..30d88169d 100644 --- a/auth/scopes.py +++ b/auth/scopes.py @@ -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 diff --git a/data/database.py b/data/database.py index 840265fae..55e1e0471 100644 --- a/data/database.py +++ b/data/database.py @@ -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): diff --git a/data/model/oauth.py b/data/model/oauth.py index 5f64f79f9..475893b4d 100644 --- a/data/model/oauth.py +++ b/data/model/oauth.py @@ -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: @@ -150,4 +175,4 @@ def validate_access_token(access_token): .get()) return found except OAuthAccessToken.DoesNotExist: - return None \ No newline at end of file + return None diff --git a/endpoints/api/discovery.py b/endpoints/api/discovery.py index 087b51c1c..b2b7f3ba5 100644 --- a/endpoints/api/discovery.py +++ b/endpoints/api/discovery.py @@ -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", }, }, }, diff --git a/endpoints/web.py b/endpoints/web.py index 5455ec573..5ead821da 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -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: diff --git a/initdb.py b/initdb.py index 3cbd8008b..77fa9f162 100644 --- a/initdb.py +++ b/initdb.py @@ -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) diff --git a/static/css/quay.css b/static/css/quay.css index bde24600a..f953428a2 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -3392,4 +3392,85 @@ pre.command:before { .slideinout.ng-hide-add, .slideinout.ng-hide-remove { display: block !important; -} \ No newline at end of file +} + +.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; +} diff --git a/templates/base.html b/templates/base.html index 5bbda45f3..852302f90 100644 --- a/templates/base.html +++ b/templates/base.html @@ -47,22 +47,22 @@ - - - - - - - - - + + + + + + + + + - - + + - - - + + + {% block added_dependencies %} @@ -73,10 +73,10 @@ window.__token = '{{ csrf_token() }}'; - - - - + + + +