Add ability for users to see their authorized applications and revoke the access
This commit is contained in:
parent
e92cf37583
commit
c82d1ffe98
10 changed files with 262 additions and 3 deletions
|
@ -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()
|
||||
|
|
|
@ -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='')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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++;
|
||||
|
|
|
@ -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.
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
Reference in a new issue