Add a banner to the Quay UI when an app specific token is about to expire

This commit is contained in:
Joseph Schorr 2017-12-12 17:51:54 -05:00
parent 5b4f5f9859
commit 888b564a9b
9 changed files with 60 additions and 9 deletions

View file

@ -74,5 +74,4 @@ def test_valid_app_specific_token(app):
app_specific_token = model.appspecifictoken.create_token(user, 'some token') app_specific_token = model.appspecifictoken.create_token(user, 'some token')
token = _token(APP_SPECIFIC_TOKEN_USERNAME, app_specific_token.token_code) token = _token(APP_SPECIFIC_TOKEN_USERNAME, app_specific_token.token_code)
result = validate_basic_auth(token) result = validate_basic_auth(token)
print result.tuple()
assert result == ValidateResult(AuthKind.basic, appspecifictoken=app_specific_token) assert result == ValidateResult(AuthKind.basic, appspecifictoken=app_specific_token)

View file

@ -17,7 +17,7 @@ def test_valid_robot(app):
assert result == ValidateResult(AuthKind.credentials, robot=robot) assert result == ValidateResult(AuthKind.credentials, robot=robot)
def test_valid_robot_for_disabled_user(app): def test_valid_robot_for_disabled_user(app):
user = model.user.get_user('devtable') user = model.user.get_user('devtable')
user.enabled = False user.enabled = False
user.save() user.save()
@ -50,7 +50,7 @@ def test_invalid_user(app):
def test_valid_app_specific_token(app): def test_valid_app_specific_token(app):
user = model.user.get_user('devtable') user = model.user.get_user('devtable')
app_specific_token = model.appspecifictoken.create_token(user, 'some token') app_specific_token = model.appspecifictoken.create_token(user, 'some token')
result, kind = validate_credentials(APP_SPECIFIC_TOKEN_USERNAME, app_specific_token.token_code) result, kind = validate_credentials(APP_SPECIFIC_TOKEN_USERNAME, app_specific_token.token_code)
assert kind == CredentialKind.app_specific_token assert kind == CredentialKind.app_specific_token
assert result == ValidateResult(AuthKind.credentials, appspecifictoken=app_specific_token) assert result == ValidateResult(AuthKind.credentials, appspecifictoken=app_specific_token)

View file

@ -11,13 +11,17 @@ from util.timedeltastring import convert_to_timedelta
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def _default_expiration(): def _default_expiration():
expiration_str = config.app_config.get('APP_SPECIFIC_TOKEN_EXPIRATION') expiration_str = config.app_config.get('APP_SPECIFIC_TOKEN_EXPIRATION')
return datetime.now() + convert_to_timedelta(expiration_str) if expiration_str else expiration_str return datetime.now() + convert_to_timedelta(expiration_str) if expiration_str else expiration_str
_default_expiration_opt = 'deo' # Define a "unique" value so that callers can specifiy an expiration of None and *not* have it
# use the default.
_default_expiration_opt = '__deo'
def create_token(user, title, expiration=_default_expiration_opt): def create_token(user, title, expiration=_default_expiration_opt):
""" Creates and returns an app specific token for the given user. If no expiration is specified """ Creates and returns an app specific token for the given user. If no expiration is specified
(including `None`), then the default from config is used. """ (including `None`), then the default from config is used. """

View file

@ -1,19 +1,25 @@
""" Manages app specific tokens for the current user. """ """ Manages app specific tokens for the current user. """
import logging import logging
import math
from datetime import timedelta
from flask import request from flask import request
import features import features
from app import app
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from data import model from data import model
from endpoints.api import (ApiResource, nickname, resource, validate_json_request, from endpoints.api import (ApiResource, nickname, resource, validate_json_request,
log_action, require_user_admin, require_fresh_login, log_action, require_user_admin, require_fresh_login,
path_param, NotFound, format_date, show_if) path_param, NotFound, format_date, show_if, query_param, parse_args,
truthy_bool)
from util.timedeltastring import convert_to_timedelta
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def token_view(token, include_code=False): def token_view(token, include_code=False):
data = { data = {
'uuid': token.uuid, 'uuid': token.uuid,
@ -30,6 +36,11 @@ def token_view(token, include_code=False):
return data return data
# The default window to use when looking up tokens that will be expiring.
_DEFAULT_TOKEN_EXPIRATION_WINDOW = '4w'
@resource('/v1/user/apptoken') @resource('/v1/user/apptoken')
@show_if(features.APP_SPECIFIC_TOKENS) @show_if(features.APP_SPECIFIC_TOKENS)
class AppTokens(ApiResource): class AppTokens(ApiResource):
@ -51,11 +62,23 @@ class AppTokens(ApiResource):
@require_user_admin @require_user_admin
@nickname('listAppTokens') @nickname('listAppTokens')
def get(self): @parse_args()
@query_param('expiring', 'If true, only returns those tokens expiring soon', type=truthy_bool)
def get(self, parsed_args):
""" Lists the app specific tokens for the user. """ """ Lists the app specific tokens for the user. """
tokens = model.appspecifictoken.list_tokens(get_authenticated_user()) expiring = parsed_args['expiring']
if expiring:
expiration = app.config.get('APP_SPECIFIC_TOKEN_EXPIRATION')
token_expiration = convert_to_timedelta(expiration or _DEFAULT_TOKEN_EXPIRATION_WINDOW)
seconds = math.ceil(token_expiration.total_seconds() * 0.1) or 1
soon = timedelta(seconds=seconds)
tokens = model.appspecifictoken.get_expiring_tokens(get_authenticated_user(), soon)
else:
tokens = model.appspecifictoken.list_tokens(get_authenticated_user())
return { return {
'tokens': [token_view(token, include_code=False) for token in tokens], 'tokens': [token_view(token, include_code=False) for token in tokens],
'only_expiring': expiring,
} }
@require_user_admin @require_user_admin

View file

@ -17,6 +17,10 @@ def test_app_specific_tokens(app, client):
assert token_uuid in set([token['uuid'] for token in resp['tokens']]) assert token_uuid in set([token['uuid'] for token in resp['tokens']])
assert not set([token['token_code'] for token in resp['tokens'] if 'token_code' in token]) assert not set([token['token_code'] for token in resp['tokens'] if 'token_code' in token])
# List the tokens expiring soon and ensure the one added is not present.
resp = conduct_api_call(cl, AppTokens, 'GET', {'expiring': True}, None, 200).json
assert token_uuid not in set([token['uuid'] for token in resp['tokens']])
# Get the token and ensure we have its code. # Get the token and ensure we have its code.
resp = conduct_api_call(cl, AppToken, 'GET', {'token_uuid': token_uuid}, None, 200).json resp = conduct_api_call(cl, AppToken, 'GET', {'token_uuid': token_uuid}, None, 200).json
assert resp['token']['uuid'] == token_uuid assert resp['token']['uuid'] == token_uuid

View file

@ -1,4 +1,11 @@
<div class="announcement inline quay-message-bar-element" ng-show="messages.length"> <div class="announcement inline quay-message-bar-element" ng-show="messages.length">
<div ng-repeat="token in NotificationService.expiringAppTokens">
<div class="quay-service-status-description warning">
Your external application token <strong style="display: inline-block; padding: 4px;">{{ token.title }}</strong>
will be expiring <strong style="display: inline-block; padding: 4px;"><time-ago datetime="token.expiration"></time-ago></strong>.
Please create a new token and revoke this token in user settings.
</div>
</div>
<div ng-repeat="message in messages"> <div ng-repeat="message in messages">
<div class="quay-service-status-description" ng-class="message.severity"> <div class="quay-service-status-description" ng-class="message.severity">
<span ng-switch on="message.media_type"> <span ng-switch on="message.media_type">

View file

@ -9,8 +9,10 @@ angular.module('quay').directive('quayMessageBar', function () {
transclude: false, transclude: false,
restrict: 'C', restrict: 'C',
scope: {}, scope: {},
controller: function ($scope, $element, ApiService) { controller: function ($scope, $element, ApiService, NotificationService) {
$scope.messages = []; $scope.messages = [];
$scope.NotificationService = NotificationService;
ApiService.getGlobalMessages().then(function (data) { ApiService.getGlobalMessages().then(function (data) {
$scope.messages = data['messages'] || []; $scope.messages = data['messages'] || [];
}, function (resp) { }, function (resp) {

View file

@ -14,7 +14,8 @@ export class AppSpecificTokenManagerComponent {
private tokenCredentials: any; private tokenCredentials: any;
private revokeTokenInfo: any; private revokeTokenInfo: any;
constructor(@Inject('ApiService') private ApiService: any, @Inject('UserService') private UserService: any) { constructor(@Inject('ApiService') private ApiService: any, @Inject('UserService') private UserService: any,
@Inject('NotificationService') private NotificationService: any) {
this.loadTokens(); this.loadTokens();
} }
@ -49,6 +50,9 @@ export class AppSpecificTokenManagerComponent {
this.ApiService.revokeAppToken(null, params).then((resp) => { this.ApiService.revokeAppToken(null, params).then((resp) => {
this.loadTokens(); this.loadTokens();
// Update the notification service so it hides any banners if we revoked an expiring token.
this.NotificationService.update();
callback(true); callback(true);
}, errorHandler); }, errorHandler);
} }

View file

@ -11,6 +11,7 @@ function($rootScope, $interval, UserService, ApiService, StringBuilderService, P
'notifications': [], 'notifications': [],
'notificationClasses': [], 'notificationClasses': [],
'notificationSummaries': [], 'notificationSummaries': [],
'expiringAppTokens': [],
'additionalNotifications': false 'additionalNotifications': false
}; };
@ -272,6 +273,13 @@ function($rootScope, $interval, UserService, ApiService, StringBuilderService, P
notificationService.additionalNotifications = resp['additional']; notificationService.additionalNotifications = resp['additional'];
notificationService.notificationClasses = notificationService.getClasses(notificationService.notifications); notificationService.notificationClasses = notificationService.getClasses(notificationService.notifications);
}); });
var params = {
'expiring': true
};
ApiService.listAppTokens(null, params).then(function(resp) {
notificationService.expiringAppTokens = resp['tokens'];
});
}; };
notificationService.reset = function() { notificationService.reset = function() {