diff --git a/data/database.py b/data/database.py index 2defc7b56..1fce70fd4 100644 --- a/data/database.py +++ b/data/database.py @@ -291,6 +291,7 @@ class OAuthAuthorizationCode(BaseModel): class OAuthAccessToken(BaseModel): + uuid = CharField(default=uuid_generator, index=True) application = ForeignKeyField(OAuthApplication) authorized_user = ForeignKeyField(User) scope = CharField() diff --git a/data/model/oauth.py b/data/model/oauth.py index 037d0c53c..f8aaed9a2 100644 --- a/data/model/oauth.py +++ b/data/model/oauth.py @@ -233,6 +233,26 @@ def delete_application(org, client_id): application.delete_instance(recursive=True, delete_nullable=True) return application + +def lookup_access_token_for_user(user, token_uuid): + try: + return OAuthAccessToken.get(OAuthAccessToken.authorized_user == user, + OAuthAccessToken.uuid == token_uuid) + except OAuthAccessToken.DoesNotExist: + return None + + +def list_access_tokens_for_user(user): + query = (OAuthAccessToken + .select() + .join(OAuthApplication) + .switch(OAuthAccessToken) + .join(User) + .where(OAuthAccessToken.authorized_user == user)) + + return query + + def list_applications_for_org(org): query = (OAuthApplication .select() @@ -240,3 +260,11 @@ def list_applications_for_org(org): .where(OAuthApplication.organization == org)) return query + + +def create_access_token_for_testing(user, client_id, scope): + expires_at = datetime.now() + timedelta(seconds=10000) + application = get_application_for_client_id(client_id) + OAuthAccessToken.create(application=application, authorized_user=user, scope=scope, + token_type='token', access_token='test', + expires_at=expires_at, refresh_token='', data='') diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 7b935ef26..343b4a010 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -385,4 +385,57 @@ class UserNotificationList(ApiResource): notifications = model.list_notifications(get_authenticated_user()) return { 'notifications': [notification_view(notification) for notification in notifications] - } \ No newline at end of file + } + + +def authorization_view(access_token): + oauth_app = access_token.application + return { + 'application': { + 'name': oauth_app.name, + 'description': oauth_app.description, + 'url': oauth_app.application_uri, + 'gravatar': compute_hash(oauth_app.gravatar_email or oauth_app.organization.email), + 'organization': { + 'name': oauth_app.organization.username, + 'gravatar': compute_hash(oauth_app.organization.email) + } + }, + 'scopes': scopes.get_scope_information(access_token.scope), + 'uuid': access_token.uuid + } + +@resource('/v1/user/authorizations') +@internal_only +class UserAuthorizationList(ApiResource): + @require_user_admin + @nickname('listUserAuthorizations') + def get(self): + access_tokens = model.oauth.list_access_tokens_for_user(get_authenticated_user()) + + return { + 'authorizations': [authorization_view(token) for token in access_tokens] + } + + +@resource('/v1/user/authorizations/') +@internal_only +class UserAuthorization(ApiResource): + @require_user_admin + @nickname('getUserAuthorization') + def get(self, access_token_uuid): + access_token = model.oauth.lookup_access_token_for_user(get_authenticated_user(), access_token_uuid) + if not access_token: + raise NotFound() + + return authorization_view(access_token) + + @require_user_admin + @nickname('deleteUserAuthorization') + def delete(self, access_token_uuid): + access_token = model.oauth.lookup_access_token_for_user(get_authenticated_user(), access_token_uuid) + if not access_token: + raise NotFound() + + access_token.delete_instance(recursive=True, delete_nullable=True) + return 'Deleted', 204 diff --git a/initdb.py b/initdb.py index 266d462d9..a4b1709f0 100644 --- a/initdb.py +++ b/initdb.py @@ -361,6 +361,8 @@ def populate_database(): client_id='deadpork', description = 'This is another test application') + model.oauth.create_access_token_for_testing(new_user_1, 'deadbeef', 'repo:admin') + model.create_robot('neworgrobot', org) owners = model.get_organization_team('buynlarge', 'owners') diff --git a/static/css/quay.css b/static/css/quay.css index 6d461b6e7..ac7c01e66 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -3574,4 +3574,19 @@ pre.command:before { margin-top: 10px; padding-top: 20px; border-top: 1px solid #eee; +} + +.auth-info .by:before { + content: "by"; + margin-right: 4px; +} + +.auth-info .by { + color: #aaa; + font-size: 12px; +} + +.auth-info .scope { + cursor: pointer; + margin-right: 4px; } \ No newline at end of file diff --git a/static/js/controllers.js b/static/js/controllers.js index dad3c47c1..a81da70c3 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -1628,12 +1628,42 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use $scope.org = {}; $scope.githubRedirectUri = KeyService.githubRedirectUri; $scope.githubClientId = KeyService.githubClientId; + $scope.authorizedApps = null; $('.form-change').popover(); $scope.logsShown = 0; $scope.invoicesShown = 0; + $scope.loadAuthedApps = function() { + if ($scope.authorizedApps) { return; } + + ApiService.listUserAuthorizations().then(function(resp) { + $scope.authorizedApps = resp['authorizations']; + }); + }; + + $scope.deleteAccess = function(accessTokenInfo) { + var params = { + 'access_token_uuid': accessTokenInfo['uuid'] + }; + + ApiService.deleteUserAuthorization(null, params).then(function(resp) { + $scope.authorizedApps.splice($scope.authorizedApps.indexOf(accessTokenInfo), 1); + }, function(resp) { + bootbox.dialog({ + "message": resp.message || 'Could not revoke authorization', + "title": "Cannot revoke authorization", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + }); + }; + $scope.loadLogs = function() { if (!$scope.hasPaidBusinessPlan) { return; } $scope.logsShown++; diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html index ffc70257c..09414cfd4 100644 --- a/static/partials/user-admin.html +++ b/static/partials/user-admin.html @@ -33,6 +33,7 @@
  • Account E-mail
  • Change Password
  • GitHub Login
  • +
  • Authorized Applications
  • Usage Logs
  • Convert to Organization
  • @@ -41,6 +42,55 @@
    + +
    +
    + +
    +
    + You have not authorized any external applications +
    +
    +
    + These are the applications you have authorized to view information and perform actions on Quay.io on your behalf. +
    + + + + + + + + + + + + + +
    Application NameAuthorized PermissionsRevoke
    + + + {{ authInfo.application.name }} + + + {{ authInfo.application.name }} + + {{ authInfo.application.organization.name }} + + + {{ scopeInfo.scope }} + + + +
    +
    +
    + +
    +
    diff --git a/test/data/test.db b/test/data/test.db index e55773dc0..6b803027f 100644 Binary files a/test/data/test.db and b/test/data/test.db differ diff --git a/test/test_api_security.py b/test/test_api_security.py index e71e24c26..cc2ccd7c1 100644 --- a/test/test_api_security.py +++ b/test/test_api_security.py @@ -17,7 +17,7 @@ from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, Bu BuildTriggerList) from endpoints.api.webhook import Webhook, WebhookList from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout, - Signin, User) + Signin, User, UserAuthorizationList, UserAuthorization) from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs @@ -3039,5 +3039,56 @@ class TestOrganizationApplicationResetClientSecret(ApiTestCase): self._run_test('POST', 200, 'devtable', None) + +class TestUserAuthorizationList(ApiTestCase): + def setUp(self): + ApiTestCase.setUp(self) + self._set_url(UserAuthorizationList) + + def test_get_anonymous(self): + self._run_test('GET', 401, None, None) + + def test_get_freshuser(self): + self._run_test('GET', 200, 'freshuser', None) + + def test_get_reader(self): + self._run_test('GET', 200, 'reader', None) + + def test_get_devtable(self): + self._run_test('GET', 200, 'devtable', None) + + + +class TestUserAuthorization(ApiTestCase): + def setUp(self): + ApiTestCase.setUp(self) + self._set_url(UserAuthorization, access_token_uuid='fake') + + def test_get_anonymous(self): + self._run_test('GET', 401, None, None) + + def test_get_freshuser(self): + self._run_test('GET', 404, 'freshuser', None) + + def test_get_reader(self): + self._run_test('GET', 404, 'reader', None) + + def test_get_devtable(self): + self._run_test('GET', 404, 'devtable', None) + + + def test_delete_anonymous(self): + self._run_test('DELETE', 401, None, None) + + def test_delete_freshuser(self): + self._run_test('DELETE', 404, 'freshuser', None) + + def test_delete_reader(self): + self._run_test('DELETE', 404, 'reader', None) + + def test_delete_devtable(self): + self._run_test('DELETE', 404, 'devtable', None) + + if __name__ == '__main__': unittest.main() diff --git a/test/test_api_usage.py b/test/test_api_usage.py index 5f57e2db9..0a5847d80 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -18,7 +18,9 @@ from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, Bu TriggerBuildList, ActivateBuildTrigger, BuildTrigger, BuildTriggerList) from endpoints.api.webhook import Webhook, WebhookList -from endpoints.api.user import PrivateRepositories, ConvertToOrganization, Signout, Signin, User +from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Signout, Signin, User, + UserAuthorizationList, UserAuthorization) + from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList from endpoints.api.logs import UserLogs, OrgLogs @@ -1624,5 +1626,32 @@ class TestBuildTriggers(ApiTestCase): self.assertEquals("build-name", start_json['display_name']) self.assertEquals(['bar'], start_json['job_config']['docker_tags']) + + +class TestUserAuthorizations(ApiTestCase): + def test_list_get_delete_user_authorizations(self): + self.login(ADMIN_ACCESS_USER) + + json = self.getJsonResponse(UserAuthorizationList) + + self.assertEquals(1, len(json['authorizations'])) + + authorization = json['authorizations'][0] + + assert 'uuid' in authorization + assert 'scopes' in authorization + assert 'application' in authorization + + # Retrieve the authorization. + get_json = self.getJsonResponse(UserAuthorization, params=dict(access_token_uuid = authorization['uuid'])) + self.assertEquals(authorization, get_json) + + # Delete the authorization. + self.deleteResponse(UserAuthorization, params=dict(access_token_uuid = authorization['uuid'])) + + # Verify it has been deleted. + self.getJsonResponse(UserAuthorization, params=dict(access_token_uuid = authorization['uuid']), + expected_code=404) + if __name__ == '__main__': unittest.main()