Add support for encrypted client tokens via basic auth (for the docker CLI) and a feature flag to disable normal passwords

This commit is contained in:
Joseph Schorr 2015-03-25 18:43:12 -04:00
parent a7a8571396
commit e4b659f107
10 changed files with 222 additions and 8 deletions

View file

@ -114,7 +114,8 @@ def _process_basic_auth(auth):
logger.debug('Invalid robot or password for robot: %s' % credentials[0])
else:
authenticated = authentication.verify_user(credentials[0], credentials[1])
(authenticated, error_message) = authentication.verify_user(credentials[0], credentials[1],
basic_auth=True)
if authenticated:
logger.debug('Successfully validated user: %s' % authenticated.username)

View file

@ -165,6 +165,10 @@ class DefaultConfig(object):
# Feature Flag: Whether users can be renamed
FEATURE_USER_RENAME = False
# Feature Flag: Whether non-encrypted passwords (as opposed to encrypted tokens) can be used for
# basic auth.
FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH = False
BUILD_MANAGER = ('enterprise', {})
DISTRIBUTED_STORAGE_CONFIG = {

View file

@ -1,6 +1,7 @@
import ldap
import logging
from flask.sessions import SecureCookieSessionInterface, BadSignature
from util.validation import generate_valid_usernames
from data import model
@ -138,5 +139,30 @@ class UserAuthentication(object):
app.extensions['authentication'] = users
return users
def verify_user(self, username_or_email, password, basic_auth=False):
# First try to decode the password as a signed token.
if basic_auth:
from app import app
import features
ser = SecureCookieSessionInterface().get_signing_serializer(app)
try:
token_data = ser.loads(password)
password = token_data.get('password', password)
except BadSignature:
# This is a normal password.
if features.REQUIRE_ENCRYPTED_BASIC_AUTH:
msg = ('Client login with passwords is disabled. Please generate a client token ' +
'and use it in place of your password.')
return (None, msg)
result = self.state.verify_user(username_or_email, password)
if result:
return (result, '')
else:
return (result, 'Invalid password')
def __getattr__(self, name):
return getattr(self.state, name, None)

View file

@ -1,9 +1,11 @@
import logging
import json
from random import SystemRandom
from flask import request
from flask.ext.login import logout_user
from flask.ext.principal import identity_changed, AnonymousIdentity
from flask.sessions import SecureCookieSessionInterface
from peewee import IntegrityError
from app import app, billing as stripe, authentication, avatar
@ -335,13 +337,58 @@ class PrivateRepositories(ApiResource):
}
@resource('/v1/user/clientkey')
@internal_only
class ClientKey(ApiResource):
""" Operations for returning an encrypted key which can be used in place of a password
for the Docker client. """
schemas = {
'GenerateClientKey': {
'id': 'GenerateClientKey',
'type': 'object',
'required': [
'password',
],
'properties': {
'password': {
'type': 'string',
'description': 'The user\'s password',
},
}
}
}
@require_user_admin
@nickname('generateUserClientKey')
@validate_json_request('GenerateClientKey')
def post(self):
""" Return's the user's private client key. """
username = get_authenticated_user().username
password = request.get_json()['password']
(result, error_message) = authentication.verify_user(username, password)
if not result:
raise request_error(message=error_message)
ser = SecureCookieSessionInterface().get_signing_serializer(app)
data_to_sign = {
'password': password,
'nonce': SystemRandom().randint(0, 10000000000)
}
encrypted = ser.dumps(data_to_sign)
return {
'key': encrypted
}
def conduct_signin(username_or_email, password):
needs_email_verification = False
invalid_credentials = False
verified = None
try:
verified = authentication.verify_user(username_or_email, password)
(verified, error_message) = authentication.verify_user(username_or_email, password)
except model.TooManyUsersException as ex:
raise license_error(exception=ex)
@ -407,7 +454,7 @@ class ConvertToOrganization(ApiResource):
# Ensure that the sign in credentials work.
admin_password = convert_data['adminPassword']
admin_user = authentication.verify_user(admin_username, admin_password)
(admin_user, error_message) = authentication.verify_user(admin_username, admin_password)
if not admin_user:
raise request_error(reason='invaliduser',
message='The admin user credentials are not valid')

View file

@ -109,7 +109,7 @@ def create_user():
issue='robot-login-failure')
if authentication.user_exists(username):
verified = authentication.verify_user(username, password)
(verified, error_message) = authentication.verify_user(username, password, basic_auth=True)
if verified:
# Mark that the user was logged in.
event = userevents.get_event(username)
@ -121,7 +121,7 @@ def create_user():
event = userevents.get_event(username)
event.publish_event_data('docker-cli', {'action': 'loginfailure'})
abort(400, 'Invalid password.', issue='login-failure')
abort(400, error_message, issue='login-failure')
elif not features.USER_CREATION:
abort(400, 'User creation is disabled. Please speak to your administrator.')

View file

@ -34,7 +34,7 @@
</td>
</tr>
<tr>
<td>User Creation:</td>
<td class="non-input">User Creation:</td>
<td colspan="2">
<div class="co-checkbox">
<input id="ftuc" type="checkbox" ng-model="config.FEATURE_USER_CREATION">
@ -46,6 +46,23 @@
</div>
</td>
</tr>
<tr>
<td class="non-input">Encrypted Client Tokens:</td>
<td colspan="2">
<div class="co-checkbox">
<input id="ftet" type="checkbox" ng-model="config.FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH">
<label for="ftet">Require Encrypted Client Tokens</label>
</div>
<div class="help-text">
If enabled, users will not be able to login from the Docker command
line with a non-encrypted password and must generate an encrypted
token to use.
</div>
<div class="help-text" ng-if="config.AUTHENTICATION_TYPE == 'LDAP'">
This feature is <strong>highly recommended</strong> for setups with LDAP authentication, as Docker currently stores passwords in <strong>plaintext</strong> on user's machines.
</div>
</td>
</tr>
</table>
</div>
</div>
@ -293,6 +310,16 @@
</p>
</div>
<div class="alert alert-warning" ng-if="config.AUTHENTICATION_TYPE == 'LDAP' && !config.FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH">
It is <strong>highly recommended</strong> to require encrypted client tokens. LDAP passwords used in the Docker client will be stored in <strong>plain-text</strong>!
<a href="javascript:void(0)" ng-click="config.FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH = true">Enable this requirement now</a>.
</div>
<div class="alert alert-success" ng-if="config.AUTHENTICATION_TYPE == 'LDAP' && config.FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH">
Note: The "Require Encrypted Client Tokens" feature is currently enabled which will
prevent LDAP passwords from being saved as plain-text by the Docker client.
</div>
<table class="config-table">
<tr>
<td class="non-input">Authentication:</td>
@ -305,7 +332,6 @@
</tr>
</table>
<table class="config-table" ng-if="config.AUTHENTICATION_TYPE == 'LDAP'">
<tr>
<td>LDAP URI:</td>

View file

@ -196,6 +196,21 @@
});
};
$scope.generateClientToken = function() {
var generateToken = function(password) {
var data = {
'password': password
};
ApiService.generateUserClientKey(data).then(function(resp) {
$scope.generatedClientToken = resp['key'];
$('#clientTokenModal').modal({});
}, ApiService.errorDisplay('Could not generate token'));
};
UIService.showPasswordDialog('Enter your password to generate a client token:', generateToken);
};
$scope.detachExternalLogin = function(kind) {
var params = {
'servicename': kind

View file

@ -92,5 +92,47 @@ angular.module('quay').factory('UIService', [function() {
return new CheckStateController(items, opt_checked);
};
uiService.showPasswordDialog = function(message, callback, opt_canceledCallback) {
var success = function() {
var password = $('#passDialogBox').val();
$('#passDialogBox').val('');
callback(password);
};
var canceled = function() {
$('#passDialogBox').val('');
opt_canceledCallback && opt_canceledCallback();
};
var box = bootbox.dialog({
"message": message +
'<form style="margin-top: 10px" action="javascript:void(0)">' +
'<input id="passDialogBox" class="form-control" type="password" placeholder="Current Password">' +
'</form>',
"title": 'Please Verify',
"buttons": {
"verify": {
"label": "Verify",
"className": "btn-success",
"callback": success
},
"close": {
"label": "Cancel",
"className": "btn-default",
"callback": canceled
}
}
});
box.bind('shown.bs.modal', function(){
box.find("input").focus();
box.find("form").submit(function() {
if (!$('#passDialogBox').val()) { return; }
box.modal('hide');
success();
});
});
};
return uiService;
}]);

View file

@ -33,6 +33,7 @@
<li quay-classes="{'!Features.BILLING': 'active'}"><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="#robots">Robot Accounts</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="#clienttoken">Client Token</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#external" quay-show="Features.GITHUB_LOGIN || Features.GOOGLE_LOGIN">External Logins</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#authorized" ng-click="loadAuthedApps()">Authorized Applications</a></li>
<li quay-show="Features.USER_LOG_ACCESS || hasPaidBusinessPlan">
@ -99,6 +100,16 @@
</div>
<!-- Client token tab -->
<div id="clienttoken" class="tab-pane">
<div class="alert alert-success">Click the "Generate" button below to generate a client token that can be used <strong>in place of your password</strong> for the Docker
command line.</div>
<button class="btn btn-primary" ng-click="generateClientToken()">
<i class="fa fa-key" style="margin-right: 6px;"></i>Generate Client Token
</button>
</div>
<!-- Logs tab -->
<div id="logs" class="tab-pane">
<div class="logs-view" user="user" makevisible="logsShown"></div>
@ -370,6 +381,27 @@
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="clientTokenModal">
<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">Generate Client Token</h4>
</div>
<div class="modal-body">
<div style="margin-bottom: 10px;">Your generated client token:</div>
<div class="copy-box" value="generatedClientToken"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Modal message dialog -->
<div class="modal fade" id="reallyconvertModal">
<div class="modal-dialog">

View file

@ -26,7 +26,8 @@ from endpoints.api.repoemail import RepositoryAuthorizedEmail
from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout,
Signin, User, UserAuthorizationList, UserAuthorization, UserNotification,
VerifyUser, DetachExternal, StarredRepositoryList, StarredRepository)
VerifyUser, DetachExternal, StarredRepositoryList, StarredRepository,
ClientKey)
from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList
from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList
from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs
@ -528,6 +529,26 @@ class TestVerifyUser(ApiTestCase):
self._run_test('POST', 200, 'devtable', {u'password': 'password'})
class TestClientKey(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(ClientKey)
def test_post_anonymous(self):
self._run_test('POST', 401, None, {u'password': 'LQ0N'})
def test_post_freshuser(self):
self._run_test('POST', 400, 'freshuser', {u'password': 'LQ0N'})
def test_post_reader(self):
self._run_test('POST', 200, 'reader', {u'password': 'password'})
def test_post_devtable(self):
self._run_test('POST', 200, 'devtable', {u'password': 'password'})
class TestListPlans(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)