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:
parent
a7a8571396
commit
e4b659f107
10 changed files with 222 additions and 8 deletions
|
@ -114,7 +114,8 @@ def _process_basic_auth(auth):
|
||||||
logger.debug('Invalid robot or password for robot: %s' % credentials[0])
|
logger.debug('Invalid robot or password for robot: %s' % credentials[0])
|
||||||
|
|
||||||
else:
|
else:
|
||||||
authenticated = authentication.verify_user(credentials[0], credentials[1])
|
(authenticated, error_message) = authentication.verify_user(credentials[0], credentials[1],
|
||||||
|
basic_auth=True)
|
||||||
|
|
||||||
if authenticated:
|
if authenticated:
|
||||||
logger.debug('Successfully validated user: %s' % authenticated.username)
|
logger.debug('Successfully validated user: %s' % authenticated.username)
|
||||||
|
|
|
@ -165,6 +165,10 @@ class DefaultConfig(object):
|
||||||
# Feature Flag: Whether users can be renamed
|
# Feature Flag: Whether users can be renamed
|
||||||
FEATURE_USER_RENAME = False
|
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', {})
|
BUILD_MANAGER = ('enterprise', {})
|
||||||
|
|
||||||
DISTRIBUTED_STORAGE_CONFIG = {
|
DISTRIBUTED_STORAGE_CONFIG = {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import ldap
|
import ldap
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from flask.sessions import SecureCookieSessionInterface, BadSignature
|
||||||
from util.validation import generate_valid_usernames
|
from util.validation import generate_valid_usernames
|
||||||
from data import model
|
from data import model
|
||||||
|
|
||||||
|
@ -138,5 +139,30 @@ class UserAuthentication(object):
|
||||||
app.extensions['authentication'] = users
|
app.extensions['authentication'] = users
|
||||||
return 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):
|
def __getattr__(self, name):
|
||||||
return getattr(self.state, name, None)
|
return getattr(self.state, name, None)
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from random import SystemRandom
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask.ext.login import logout_user
|
from flask.ext.login import logout_user
|
||||||
from flask.ext.principal import identity_changed, AnonymousIdentity
|
from flask.ext.principal import identity_changed, AnonymousIdentity
|
||||||
|
from flask.sessions import SecureCookieSessionInterface
|
||||||
from peewee import IntegrityError
|
from peewee import IntegrityError
|
||||||
|
|
||||||
from app import app, billing as stripe, authentication, avatar
|
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):
|
def conduct_signin(username_or_email, password):
|
||||||
needs_email_verification = False
|
needs_email_verification = False
|
||||||
invalid_credentials = False
|
invalid_credentials = False
|
||||||
|
|
||||||
verified = None
|
verified = None
|
||||||
try:
|
try:
|
||||||
verified = authentication.verify_user(username_or_email, password)
|
(verified, error_message) = authentication.verify_user(username_or_email, password)
|
||||||
except model.TooManyUsersException as ex:
|
except model.TooManyUsersException as ex:
|
||||||
raise license_error(exception=ex)
|
raise license_error(exception=ex)
|
||||||
|
|
||||||
|
@ -407,7 +454,7 @@ class ConvertToOrganization(ApiResource):
|
||||||
|
|
||||||
# Ensure that the sign in credentials work.
|
# Ensure that the sign in credentials work.
|
||||||
admin_password = convert_data['adminPassword']
|
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:
|
if not admin_user:
|
||||||
raise request_error(reason='invaliduser',
|
raise request_error(reason='invaliduser',
|
||||||
message='The admin user credentials are not valid')
|
message='The admin user credentials are not valid')
|
||||||
|
|
|
@ -109,7 +109,7 @@ def create_user():
|
||||||
issue='robot-login-failure')
|
issue='robot-login-failure')
|
||||||
|
|
||||||
if authentication.user_exists(username):
|
if authentication.user_exists(username):
|
||||||
verified = authentication.verify_user(username, password)
|
(verified, error_message) = authentication.verify_user(username, password, basic_auth=True)
|
||||||
if verified:
|
if verified:
|
||||||
# Mark that the user was logged in.
|
# Mark that the user was logged in.
|
||||||
event = userevents.get_event(username)
|
event = userevents.get_event(username)
|
||||||
|
@ -121,7 +121,7 @@ def create_user():
|
||||||
event = userevents.get_event(username)
|
event = userevents.get_event(username)
|
||||||
event.publish_event_data('docker-cli', {'action': 'loginfailure'})
|
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:
|
elif not features.USER_CREATION:
|
||||||
abort(400, 'User creation is disabled. Please speak to your administrator.')
|
abort(400, 'User creation is disabled. Please speak to your administrator.')
|
||||||
|
|
|
@ -34,7 +34,7 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>User Creation:</td>
|
<td class="non-input">User Creation:</td>
|
||||||
<td colspan="2">
|
<td colspan="2">
|
||||||
<div class="co-checkbox">
|
<div class="co-checkbox">
|
||||||
<input id="ftuc" type="checkbox" ng-model="config.FEATURE_USER_CREATION">
|
<input id="ftuc" type="checkbox" ng-model="config.FEATURE_USER_CREATION">
|
||||||
|
@ -46,6 +46,23 @@
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -293,6 +310,16 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<table class="config-table">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="non-input">Authentication:</td>
|
<td class="non-input">Authentication:</td>
|
||||||
|
@ -305,7 +332,6 @@
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
||||||
<table class="config-table" ng-if="config.AUTHENTICATION_TYPE == 'LDAP'">
|
<table class="config-table" ng-if="config.AUTHENTICATION_TYPE == 'LDAP'">
|
||||||
<tr>
|
<tr>
|
||||||
<td>LDAP URI:</td>
|
<td>LDAP URI:</td>
|
||||||
|
|
|
@ -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) {
|
$scope.detachExternalLogin = function(kind) {
|
||||||
var params = {
|
var params = {
|
||||||
'servicename': kind
|
'servicename': kind
|
||||||
|
|
|
@ -92,5 +92,47 @@ angular.module('quay').factory('UIService', [function() {
|
||||||
return new CheckStateController(items, opt_checked);
|
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;
|
return uiService;
|
||||||
}]);
|
}]);
|
||||||
|
|
|
@ -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 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="#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="#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="#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><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">
|
<li quay-show="Features.USER_LOG_ACCESS || hasPaidBusinessPlan">
|
||||||
|
@ -99,6 +100,16 @@
|
||||||
|
|
||||||
</div>
|
</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 -->
|
<!-- Logs tab -->
|
||||||
<div id="logs" class="tab-pane">
|
<div id="logs" class="tab-pane">
|
||||||
<div class="logs-view" user="user" makevisible="logsShown"></div>
|
<div class="logs-view" user="user" makevisible="logsShown"></div>
|
||||||
|
@ -370,6 +381,27 @@
|
||||||
</div><!-- /.modal -->
|
</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">×</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 -->
|
<!-- Modal message dialog -->
|
||||||
<div class="modal fade" id="reallyconvertModal">
|
<div class="modal fade" id="reallyconvertModal">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
|
|
|
@ -26,7 +26,8 @@ from endpoints.api.repoemail import RepositoryAuthorizedEmail
|
||||||
from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList
|
from endpoints.api.repositorynotification import RepositoryNotification, RepositoryNotificationList
|
||||||
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout,
|
from endpoints.api.user import (PrivateRepositories, ConvertToOrganization, Recovery, Signout,
|
||||||
Signin, User, UserAuthorizationList, UserAuthorization, UserNotification,
|
Signin, User, UserAuthorizationList, UserAuthorization, UserNotification,
|
||||||
VerifyUser, DetachExternal, StarredRepositoryList, StarredRepository)
|
VerifyUser, DetachExternal, StarredRepositoryList, StarredRepository,
|
||||||
|
ClientKey)
|
||||||
from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList
|
from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList
|
||||||
from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList
|
from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList
|
||||||
from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs
|
from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs
|
||||||
|
@ -528,6 +529,26 @@ class TestVerifyUser(ApiTestCase):
|
||||||
self._run_test('POST', 200, 'devtable', {u'password': 'password'})
|
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):
|
class TestListPlans(ApiTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
ApiTestCase.setUp(self)
|
ApiTestCase.setUp(self)
|
||||||
|
|
Reference in a new issue