Add ability for users to see their authorized applications and revoke the access

This commit is contained in:
Joseph Schorr 2014-03-24 20:57:02 -04:00
parent e92cf37583
commit c82d1ffe98
10 changed files with 262 additions and 3 deletions

View file

@ -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()

View file

@ -221,6 +221,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()
@ -228,3 +248,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='')

View file

@ -385,4 +385,57 @@ class UserNotificationList(ApiResource):
notifications = model.list_notifications(get_authenticated_user())
return {
'notifications': [notification_view(notification) for notification in notifications]
}
}
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/<access_token_uuid>')
@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

View file

@ -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')

View file

@ -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;
}

View file

@ -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++;

View file

@ -33,6 +33,7 @@
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#email">Account E-mail</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#password">Change Password</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#github">GitHub Login</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#authorized" ng-click="loadAuthedApps()">Authorized Applications</a></li>
<li ng-show="hasPaidBusinessPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#logs" ng-click="loadLogs()">Usage Logs</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#migrate" id="migrateTab">Convert to Organization</a></li>
</ul>
@ -41,6 +42,55 @@
<!-- Content -->
<div class="col-md-10">
<div class="tab-content">
<!-- Authorized applications tab -->
<div id="authorized" class="tab-pane">
<div class="quay-spinner" ng-show="!authorizedApps"></div>
<div class="panel" ng-show="authorizedApps != null">
<div class="panel-body" ng-show="!authorizedApps.length">
You have not authorized any external applications
</div>
<div class="panel-body" ng-show="authorizedApps.length">
<div class="alert alert-info">
These are the applications you have authorized to view information and perform actions on Quay.io on your behalf.
</div>
<table class="table">
<thead>
<th>Application Name</th>
<th>Authorized Permissions</th>
<th style="width: 150px">Revoke</th>
</thead>
<tr class="auth-info" ng-repeat="authInfo in authorizedApps">
<td>
<img src="//www.gravatar.com/avatar/{{ authInfo.gravatar }}?s=16&d=identicon">
<a href="{{ authInfo.application.url }}" ng-if="authInfo.application.url" target="_blank"
title="{{ authInfo.application.description || authInfo.application.name }}" bs-tooltip>
{{ authInfo.application.name }}
</a>
<span ng-if="!authInfo.application.url" title="{{ authInfo.application.description || authInfo.application.name }}" bs-tooltip>
{{ authInfo.application.name }}
</span>
<span class="by">{{ authInfo.application.organization.name }}</span>
</td>
<td>
<span class="label label-default scope"
ng-class="{'repo:admin': 'label-primary', 'repo:write': 'label-success', 'repo:create': 'label-success'}[scopeInfo.scope]"
ng-repeat="scopeInfo in authInfo.scopes" title="{{ scopeInfo.description }}" bs-tooltip>
{{ scopeInfo.scope }}
</span>
</td>
<td>
<span class="delete-ui" delete-title="'Revoke Authorization'" button-title="'Revoke'" perform-delete="deleteAccess(authInfo)"></span>
</td>
</tr>
</table>
</div>
</div>
</div>
<!-- Logs tab -->
<div id="logs" class="tab-pane">
<div class="logs-view" user="user" visible="logsShown"></div>

Binary file not shown.

View file

@ -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()

View file

@ -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()