First stab at token auth. The UI could use a little bit of polishing.

This commit is contained in:
yackob03 2013-10-16 14:24:10 -04:00
parent f1746417b1
commit 283f9b81ae
9 changed files with 360 additions and 91 deletions

View file

@ -32,19 +32,34 @@ def process_basic_auth(auth):
credentials = b64decode(normalized[1]).split(':') credentials = b64decode(normalized[1]).split(':')
if len(credentials) != 2: if len(credentials) != 2:
logger.debug('Invalid basic auth credential formet.') logger.debug('Invalid basic auth credential format.')
authenticated = model.verify_user(credentials[0], credentials[1]) if credentials[0] == '$token':
# Use as token auth
try:
token = model.load_token_data(credentials[1])
logger.debug('Successfully validated token: %s' % credentials[1])
ctx = _request_ctx_stack.top
ctx.validated_token = token
if authenticated: identity_changed.send(app, identity=Identity(token.code, 'token'))
logger.debug('Successfully validated user: %s' % authenticated.username) return
ctx = _request_ctx_stack.top
ctx.authenticated_user = authenticated
new_identity = QuayDeferredPermissionUser(authenticated.username, except model.DataModelException:
'username') logger.debug('Invalid token: %s' % credentials[1])
identity_changed.send(app, identity=new_identity)
return else:
authenticated = model.verify_user(credentials[0], credentials[1])
if authenticated:
logger.debug('Successfully validated user: %s' % authenticated.username)
ctx = _request_ctx_stack.top
ctx.authenticated_user = authenticated
new_identity = QuayDeferredPermissionUser(authenticated.username,
'username')
identity_changed.send(app, identity=new_identity)
return
# We weren't able to authenticate via basic auth. # We weren't able to authenticate via basic auth.
logger.debug('Basic auth present but could not be validated.') logger.debug('Basic auth present but could not be validated.')
@ -54,42 +69,37 @@ def process_basic_auth(auth):
def process_token(auth): def process_token(auth):
normalized = [part.strip() for part in auth.split(' ') if part] normalized = [part.strip() for part in auth.split(' ') if part]
if normalized[0].lower() != 'token' or len(normalized) != 2: if normalized[0].lower() != 'token' or len(normalized) != 2:
logger.debug('Invalid token format.') logger.debug('Not an auth token: %s' % auth)
return return
token_details = normalized[1].split(',') token_details = normalized[1].split(',')
if len(token_details) != 2: if len(token_details) != 1:
logger.debug('Invalid token format.') logger.warning('Invalid token format: %s' % auth)
return abort(401)
token_vals = {val[0]: val[1] for val in token_vals = {val[0]: val[1] for val in
(detail.split('=') for detail in token_details)} (detail.split('=') for detail in token_details)}
if ('signature' not in token_vals or 'repository' not in token_vals): if 'signature' not in token_vals:
logger.debug('Invalid token components.') logger.warning('Token does not contain signature: %s' % auth)
return abort(401)
unquoted = token_vals['repository'][1:-1] try:
namespace, repository = parse_namespace_repository(unquoted) token_data = model.load_token_data(token_vals['signature'])
logger.debug('Validing signature: %s' % token_vals['signature'])
validated = model.verify_token(token_vals['signature'], namespace,
repository)
if validated: except model.InvalidTokenException:
session['repository'] = repository logger.warning('Token could not be validated: %s' %
session['namespace'] = namespace token_vals['signature'])
abort(401)
logger.debug('Successfully validated token: %s' % validated.code) session['repository'] = token_data.repository.name
ctx = _request_ctx_stack.top session['namespace'] = token_data.repository.namespace
ctx.validated_token = validated
identity_changed.send(app, identity=Identity(validated.code, 'token')) logger.debug('Successfully validated token: %s' % token_data.code)
ctx = _request_ctx_stack.top
ctx.validated_token = token_data
return identity_changed.send(app, identity=Identity(token_data.code, 'token'))
# WE weren't able to authenticate the token
logger.debug('Token present but could not be validated.')
abort(401)
def process_auth(f): def process_auth(f):

View file

@ -80,19 +80,14 @@ def on_identity_loaded(sender, identity):
identity_changed.send(app, identity=switch_to_deferred) identity_changed.send(app, identity=switch_to_deferred)
elif identity.auth_type == 'token': elif identity.auth_type == 'token':
logger.debug('Computing permissions for token: %s' % identity.id) logger.debug('Loading permissions for token: %s' % identity.id)
token_data = model.load_token_data(identity.id)
token = model.get_token(identity.id) repo_grant = _RepositoryNeed(token_data.repository.namespace,
token_data.repository.name,
if token.user: token_data.role.name)
query = model.get_user_repo_permissions(token.user, token.repository) logger.debug('Delegate token added permission: {0}'.format(repo_grant))
for permission in query: identity.provides.add(repo_grant)
t_grant = _RepositoryNeed(token.repository.namespace,
token.repository.name, permission.role.name)
logger.debug('Token added permission: {0}'.format(t_grant))
identity.provides.add(t_grant)
else:
logger.debug('Token was anonymous.')
else: else:
logger.error('Unknown identity auth type: %s' % identity.auth_type) logger.error('Unknown identity auth type: %s' % identity.auth_type)

View file

@ -99,10 +99,13 @@ def random_string_generator(length=16):
class AccessToken(BaseModel): class AccessToken(BaseModel):
code = CharField(default=random_string_generator(), unique=True, index=True) friendly_name = CharField(null=True)
user = ForeignKeyField(User, null=True) code = CharField(default=random_string_generator(length=64), unique=True,
index=True)
repository = ForeignKeyField(Repository) repository = ForeignKeyField(Repository)
created = DateTimeField(default=datetime.now) created = DateTimeField(default=datetime.now)
role = ForeignKeyField(Role)
temporary = BooleanField(default=True)
class EmailConfirmation(BaseModel): class EmailConfirmation(BaseModel):

View file

@ -26,6 +26,10 @@ class InvalidPasswordException(DataModelException):
pass pass
class InvalidTokenException(DataModelException):
pass
def create_user(username, password, email): def create_user(username, password, email):
if not validate_email(email): if not validate_email(email):
raise InvalidEmailAddressException('Invalid email address: %s' % email) raise InvalidEmailAddressException('Invalid email address: %s' % email)
@ -159,25 +163,6 @@ def verify_user(username, password):
return None return None
def create_access_token(user, repository):
new_token = AccessToken.create(user=user, repository=repository)
return new_token
def verify_token(code, namespace_name, repository_name):
joined = AccessToken.select(AccessToken, Repository).join(Repository)
tokens = list(joined.where(AccessToken.code == code,
Repository.namespace == namespace_name,
Repository.name == repository_name))
if tokens:
return tokens[0]
return None
def get_token(code):
return AccessToken.get(AccessToken.code == code)
def get_visible_repositories(username=None, include_public=True, limit=None, def get_visible_repositories(username=None, include_public=True, limit=None,
sort=False): sort=False):
if not username and not include_public: if not username and not include_public:
@ -485,3 +470,69 @@ def get_private_repo_count(username):
joined = Repository.select().join(Visibility) joined = Repository.select().join(Visibility)
return joined.where(Repository.namespace == username, return joined.where(Repository.namespace == username,
Visibility.name == 'private').count() Visibility.name == 'private').count()
def create_access_token(repository, role):
role = Role.get(Role.name == role)
new_token = AccessToken.create(repository=repository, temporary=True,
role=role)
return new_token
def create_delegate_token(namespace_name, repository_name, friendly_name):
read_only = Role.get(name='read')
repo = Repository.get(Repository.name == repository_name,
Repository.namespace == namespace_name)
new_token = AccessToken.create(repository=repo, role=read_only,
friendly_name=friendly_name, temporary=False)
return new_token
def get_repository_delegate_tokens(namespace_name, repository_name):
selected = AccessToken.select(AccessToken, Role)
with_repo = selected.join(Repository)
with_role = with_repo.switch(AccessToken).join(Role)
return with_role.where(Repository.name == repository_name,
Repository.namespace == namespace_name,
AccessToken.temporary == False)
def get_repo_delegate_token(namespace_name, repository_name, code):
repo_query = get_repository_delegate_tokens(namespace_name, repository_name)
found = list(repo_query.where(AccessToken.code == code))
if found:
return found[0]
else:
raise InvalidTokenException('Unable to find token with code: %s' % code)
def set_repo_delegate_token_role(namespace_name, repository_name, code, role):
token = get_repo_delegate_token(namespace_name, repository_name, code)
if role != 'read' and role != 'write':
raise DataModelException('Invalid role for delegate token: %s' % role)
new_role = Role.get(Role.name == role)
token.role = new_role
token.save()
return token
def delete_delegate_token(namespace_name, repository_name, code):
token = get_repo_delegate_token(namespace_name, repository_name, code)
token.delete_instance()
def load_token_data(code):
""" Load the permissions for any token by code. """
selected = AccessToken.select(AccessToken, Repository, Role)
with_role = selected.join(Role)
with_repo = with_role.switch(AccessToken).join(Repository)
fetched = list(with_repo.where(AccessToken.code == code))
if fetched:
return fetched[0]
else:
raise InvalidTokenException('Invalid delegate token code: %s' % code)

View file

@ -456,6 +456,92 @@ def delete_permissions(namespace, repository, username):
abort(403) # Permission denied abort(403) # Permission denied
def token_view(token_obj):
return {
'friendlyName': token_obj.friendly_name,
'code': token_obj.code,
'role': token_obj.role.name,
}
@app.route('/api/repository/<path:repository>/tokens/', methods=['GET'])
@api_login_required
@parse_repository_name
def list_repo_tokens(namespace, repository):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
tokens = model.get_repository_delegate_tokens(namespace, repository)
return jsonify({
'tokens': {token.code: token_view(token) for token in tokens}
})
abort(403) # Permission denied
@app.route('/api/repository/<path:repository>/tokens/<code>', methods=['GET'])
@api_login_required
@parse_repository_name
def get_tokens(namespace, repository, code):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
perm = model.get_repo_delegate_token(namespace, repository, code)
return jsonify(token_view(perm))
abort(403) # Permission denied
@app.route('/api/repository/<path:repository>/tokens/', methods=['POST'])
@api_login_required
@parse_repository_name
def create_token(namespace, repository):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
token_params = request.get_json()
token = model.create_delegate_token(namespace, repository,
token_params['friendlyName'])
resp = jsonify(token_view(token))
resp.status_code = 201
return resp
abort(403) # Permission denied
@app.route('/api/repository/<path:repository>/tokens/<code>', methods=['PUT'])
@api_login_required
@parse_repository_name
def change_token(namespace, repository, code):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
new_permission = request.get_json()
logger.debug('Setting permission to: %s for code %s' %
(new_permission['role'], code))
token = model.set_repo_delegate_token_role(namespace, repository, code,
new_permission['role'])
resp = jsonify(token_view(token))
return resp
abort(403) # Permission denied
@app.route('/api/repository/<path:repository>/tokens/<code>',
methods=['DELETE'])
@api_login_required
@parse_repository_name
def delete_token(namespace, repository, code):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
model.delete_delegate_token(namespace, repository, code)
return make_response('Deleted', 204)
abort(403) # Permission denied
def subscription_view(stripe_subscription, used_repos): def subscription_view(stripe_subscription, used_repos):
return { return {
'currentPeriodStart': stripe_subscription.current_period_start, 'currentPeriodStart': stripe_subscription.current_period_start,

View file

@ -19,25 +19,26 @@ from auth.permissions import (ModifyRepositoryPermission,
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def generate_headers(f): def generate_headers(role='read'):
@wraps(f) def decorator_method(f):
def wrapper(namespace, repository, *args, **kwargs): @wraps(f)
response = f(namespace, repository, *args, **kwargs) def wrapper(namespace, repository, *args, **kwargs):
response = f(namespace, repository, *args, **kwargs)
response.headers['X-Docker-Endpoints'] = app.config['REGISTRY_SERVER'] response.headers['X-Docker-Endpoints'] = app.config['REGISTRY_SERVER']
has_token_request = request.headers.get('X-Docker-Token', '') has_token_request = request.headers.get('X-Docker-Token', '')
if has_token_request: if has_token_request:
repo = model.get_repository(namespace, repository) repo = model.get_repository(namespace, repository)
token = model.create_access_token(get_authenticated_user(), repo) token = model.create_access_token(repo, role)
token_str = 'signature=%s,repository="%s/%s"' % (token.code, namespace, token_str = 'signature=%s' % token.code
repository) response.headers['WWW-Authenticate'] = token_str
response.headers['WWW-Authenticate'] = token_str response.headers['X-Docker-Token'] = token_str
response.headers['X-Docker-Token'] = token_str
return response return response
return wrapper return wrapper
return decorator_method
@app.route('/v1/users', methods=['POST']) @app.route('/v1/users', methods=['POST'])
@ -47,6 +48,13 @@ def create_user():
username = user_data['username'] username = user_data['username']
password = user_data['password'] password = user_data['password']
if username == '$token':
try:
token = model.load_token_data(password)
return make_response('Verified', 201)
except model.InvalidTokenException:
abort(401)
existing_user = model.get_user(username) existing_user = model.get_user(username)
if existing_user: if existing_user:
verified = model.verify_user(username, password) verified = model.verify_user(username, password)
@ -100,13 +108,17 @@ def update_user(username):
@app.route('/v1/repositories/<path:repository>', methods=['PUT']) @app.route('/v1/repositories/<path:repository>', methods=['PUT'])
@process_auth @process_auth
@parse_repository_name @parse_repository_name
@generate_headers @generate_headers(role='write')
def create_repository(namespace, repository): def create_repository(namespace, repository):
image_descriptions = json.loads(request.data) image_descriptions = json.loads(request.data)
repo = model.get_repository(namespace, repository) repo = model.get_repository(namespace, repository)
if repo: if not repo and get_authenticated_user() is None:
logger.debug('Attempt to create new repository with token auth.')
abort(400)
elif repo:
permission = ModifyRepositoryPermission(namespace, repository) permission = ModifyRepositoryPermission(namespace, repository)
if not permission.can(): if not permission.can():
abort(403) abort(403)
@ -135,7 +147,10 @@ def create_repository(namespace, repository):
response = make_response('Created', 201) response = make_response('Created', 201)
mixpanel.track(get_authenticated_user().username, 'push_repo') if get_authenticated_user():
mixpanel.track(get_authenticated_user().username, 'push_repo')
else:
mixpanel.track(get_validated_token().code, 'push_repo')
return response return response
@ -143,7 +158,7 @@ def create_repository(namespace, repository):
@app.route('/v1/repositories/<path:repository>/images', methods=['PUT']) @app.route('/v1/repositories/<path:repository>/images', methods=['PUT'])
@process_auth @process_auth
@parse_repository_name @parse_repository_name
@generate_headers @generate_headers(role='write')
def update_images(namespace, repository): def update_images(namespace, repository):
permission = ModifyRepositoryPermission(namespace, repository) permission = ModifyRepositoryPermission(namespace, repository)
@ -164,7 +179,7 @@ def update_images(namespace, repository):
@app.route('/v1/repositories/<path:repository>/images', methods=['GET']) @app.route('/v1/repositories/<path:repository>/images', methods=['GET'])
@process_auth @process_auth
@parse_repository_name @parse_repository_name
@generate_headers @generate_headers(role='read')
def get_repository_images(namespace, repository): def get_repository_images(namespace, repository):
permission = ReadRepositoryPermission(namespace, repository) permission = ReadRepositoryPermission(namespace, repository)
@ -196,7 +211,7 @@ def get_repository_images(namespace, repository):
@app.route('/v1/repositories/<path:repository>/images', methods=['DELETE']) @app.route('/v1/repositories/<path:repository>/images', methods=['DELETE'])
@process_auth @process_auth
@parse_repository_name @parse_repository_name
@generate_headers @generate_headers(role='write')
def delete_repository_images(namespace, repository): def delete_repository_images(namespace, repository):
pass pass

View file

@ -469,6 +469,40 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) {
}); });
}; };
$scope.createToken = function() {
var friendlyName = {
'friendlyName': $scope.newToken.friendlyName
};
var permissionPost = Restangular.one('repository/' + namespace + '/' + name + '/tokens/');
permissionPost.customPOST(friendlyName).then(function(newToken) {
$scope.tokens[newToken.code] = newToken;
});
};
$scope.deleteToken = function(tokenCode) {
var deleteAction = Restangular.one('repository/' + namespace + '/' + name + '/tokens/' + tokenCode);
deleteAction.customDELETE().then(function() {
delete $scope.tokens[tokenCode];
});
};
$scope.changeTokenAccess = function(tokenCode, newAccess) {
var role = {
'role': newAccess
};
var deleteAction = Restangular.one('repository/' + namespace + '/' + name + '/tokens/' + tokenCode);
deleteAction.customPUT(role).then(function(updated) {
$scope.tokens[updated.code] = updated;
});
};
$scope.showToken = function(tokenCode) {
$scope.shownToken = $scope.tokens[tokenCode];
$('#tokenmodal').modal({});
};
$scope.askChangeAccess = function(newAccess) { $scope.askChangeAccess = function(newAccess) {
$('#make' + newAccess + 'Modal').modal({}); $('#make' + newAccess + 'Modal').modal({});
}; };
@ -512,7 +546,7 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) {
var repositoryFetch = Restangular.one('repository/' + namespace + '/' + name); var repositoryFetch = Restangular.one('repository/' + namespace + '/' + name);
repositoryFetch.get().then(function(repo) { repositoryFetch.get().then(function(repo) {
$scope.repo = repo; $scope.repo = repo;
$scope.loading = !($scope.permissions && $scope.repo); $scope.loading = !($scope.permissions && $scope.repo && $scope.tokens);
}, function() { }, function() {
$scope.permissions = null; $scope.permissions = null;
$rootScope.title = 'Unknown Repository'; $rootScope.title = 'Unknown Repository';
@ -524,12 +558,23 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) {
permissionsFetch.get().then(function(resp) { permissionsFetch.get().then(function(resp) {
$rootScope.title = 'Settings - ' + namespace + '/' + name; $rootScope.title = 'Settings - ' + namespace + '/' + name;
$scope.permissions = resp.permissions; $scope.permissions = resp.permissions;
$scope.loading = !($scope.permissions && $scope.repo); $scope.loading = !($scope.permissions && $scope.repo && $scope.tokens);
}, function() { }, function() {
$scope.permissions = null; $scope.permissions = null;
$rootScope.title = 'Unknown Repository'; $rootScope.title = 'Unknown Repository';
$scope.loading = false; $scope.loading = false;
}); });
// Fetch the tokens.
var tokensFetch = Restangular.one('repository/' + namespace + '/' + name + '/tokens/');
tokensFetch.get().then(function(resp) {
$scope.tokens = resp.tokens;
$scope.loading = !($scope.permissions && $scope.repo && $scope.tokens);
}, function() {
$scope.tokens = null;
$scope.loading = false;
});
} }
function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService, KeyService, $routeParams) { function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService, KeyService, $routeParams) {

View file

@ -56,7 +56,53 @@
</table> </table>
</div> </div>
</div> </div>
<br>
<!-- Token Permissions -->
<div class="panel panel-default">
<div class="panel-heading">Access Token Permissions</div>
<div class="panel-body">
<table class="permissions">
<thead>
<tr>
<td>Token</td>
<td>Permissions</td>
<td></td>
</tr>
</thead>
<tr ng-repeat="(code, token) in tokens">
<td class="user">
<i class="icon-key"></i>
<a ng-click="showToken(token.code)">{{ token.friendlyName }}</a>
</td>
<td class="user-permissions">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-default" ng-click="changeTokenAccess(token.code, 'read')" ng-class="{read: 'active', write: ''}[token.role]">Read only</button>
<button type="button" class="btn btn-default" ng-click="changeTokenAccess(token.code, 'write')" ng-class="{read: '', write: 'active'}[token.role]">Write</button>
</div>
</td>
<td>
<span class="delete-ui" tabindex="0" title="Delete Token">
<span class="delete-ui-button" ng-click="deleteToken(token.code)"><button class="btn btn-danger">Delete</button></span>
<i class="icon-remove"></i>
</span>
</td>
</tr>
<tr>
<form name="createTokenForm" ng-submit="createToken()">
<td>
<input class="form-control" placeholder="New token friendly name..." ng-model="newToken.friendlyName">
</td>
<td>
<button type="submit" class="btn btn-sm btn-default">Create</button>
</td>
</form>
</tr>
</table>
</div>
</div>
<!-- Public/Private --> <!-- Public/Private -->
<div class="panel panel-default"> <div class="panel panel-default">
@ -113,6 +159,24 @@
</div><!-- /.modal-content --> </div><!-- /.modal-content -->
</div><!-- /.modal-dialog --> </div><!-- /.modal-dialog -->
</div><!-- /.modal --> </div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="tokenmodal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Token code for {{ shownToken.friendlyName }}</h4>
</div>
<div class="modal-body">
{{ shownToken.code }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog --> <!-- Modal message dialog -->

BIN
test.db

Binary file not shown.