Merge branch 'enigma'

This commit is contained in:
Joseph Schorr 2015-03-26 16:19:17 -04:00
commit 7d13299782
11 changed files with 313 additions and 10 deletions

View file

@ -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)

View file

@ -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 = {

View file

@ -1,6 +1,11 @@
import ldap import ldap
import logging import logging
import json
import itertools
import uuid
import struct
from util.aes import AESCipher
from util.validation import generate_valid_usernames from util.validation import generate_valid_usernames
from data import model from data import model
@ -106,6 +111,7 @@ class LDAPUsers(object):
return found_user is not None return found_user is not None
class UserAuthentication(object): class UserAuthentication(object):
def __init__(self, app=None): def __init__(self, app=None):
self.app = app self.app = app
@ -138,5 +144,76 @@ class UserAuthentication(object):
app.extensions['authentication'] = users app.extensions['authentication'] = users
return users return users
def _get_secret_key(self):
""" Returns the secret key to use for encrypting and decrypting. """
from app import app
app_secret_key = app.config['SECRET_KEY']
# First try parsing the key as an int.
try:
big_int = int(app_secret_key)
secret_key = bytearray.fromhex('{:02x}'.format(big_int))
except ValueError:
secret_key = app_secret_key
# Next try parsing it as an UUID.
try:
secret_key = uuid.UUID(app_secret_key).bytes
except ValueError:
secret_key = app_secret_key
# Otherwise, use the bytes directly.
return ''.join(itertools.islice(itertools.cycle(secret_key), 32))
def encrypt_user_password(self, password):
""" Returns an encrypted version of the user's password. """
data = {
'password': password
}
message = json.dumps(data)
cipher = AESCipher(self._get_secret_key())
return cipher.encrypt(message)
def _decrypt_user_password(self, encrypted):
""" Attempts to decrypt the given password and returns it. """
cipher = AESCipher(self._get_secret_key())
try:
message = cipher.decrypt(encrypted)
except ValueError:
return None
except TypeError:
return None
try:
data = json.loads(message)
except ValueError:
return None
return data.get('password', encrypted)
def verify_user(self, username_or_email, password, basic_auth=False):
# First try to decode the password as a signed token.
if basic_auth:
import features
decrypted = self._decrypt_user_password(password)
if decrypted is None:
# 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)
else:
password = decrypted
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)

View file

@ -1,6 +1,7 @@
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
@ -335,13 +336,51 @@ 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)
return {
'key': authentication.encrypt_user_password(password)
}
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 +446,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')

View file

@ -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.')

View file

@ -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>plaintext</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 plaintext 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>

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 generated an encrypted version:', generateToken);
};
$scope.detachExternalLogin = function(kind) { $scope.detachExternalLogin = function(kind) {
var params = { var params = {
'servicename': kind 'servicename': kind

View file

@ -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;
}]); }]);

View file

@ -32,7 +32,7 @@
<!-- Non-billing --> <!-- Non-billing -->
<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">Password</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">
@ -139,8 +139,31 @@
</div> </div>
</div> </div>
<!-- Change password tab --> <!-- Password tab -->
<div id="password" class="tab-pane"> <div id="password" class="tab-pane">
<!-- Encrypted Password -->
<div class="row">
<div class="panel">
<div class="panel-title">Generate Encrypted Password</div>
<div class="panel-body">
<div class="alert alert-info" ng-if="!Features.REQUIRE_ENCRYPTED_BASIC_AUTH">
Due to Docker storing passwords entered on the command line in <strong>plaintext</strong>, it is highly recommended to use the button below to generate an an encrypted version of your password.
</div>
<div class="alert alert-warning" ng-if="Features.REQUIRE_ENCRYPTED_BASIC_AUTH">
This installation is set to <strong>require</strong> encrypted passwords when
using the Docker command line interface. To generate an encrypted password, click the button below.
</div>
<button class="btn btn-primary" ng-click="generateClientToken()">
<i class="fa fa-key" style="margin-right: 6px;"></i>Generate Encrypted Password
</button>
</div>
</div>
</div>
<!-- Change Password -->
<div class="row"> <div class="row">
<div class="panel"> <div class="panel">
<div class="panel-title">Change Password</div> <div class="panel-title">Change Password</div>
@ -152,6 +175,9 @@
<span class="help-block" ng-show="changePasswordSuccess">Password changed successfully</span> <span class="help-block" ng-show="changePasswordSuccess">Password changed successfully</span>
<div ng-show="!updatingUser" class="panel-body"> <div ng-show="!updatingUser" class="panel-body">
<div class="alert alert-warning">Note: Changing your password will also invalidate any generated encrypted passwords.</div>
<form class="form-change col-md-6" id="changePasswordForm" name="changePasswordForm" ng-submit="changePassword()" <form class="form-change col-md-6" id="changePasswordForm" name="changePasswordForm" ng-submit="changePassword()"
ng-show="!awaitingConfirmation && !registering"> ng-show="!awaitingConfirmation && !registering">
<input type="password" class="form-control" placeholder="Your new password" ng-model="cuser.password" required <input type="password" class="form-control" placeholder="Your new password" ng-model="cuser.password" required
@ -370,6 +396,26 @@
</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">&times;</button>
<h4 class="modal-title">Encrypted Password</h4>
</div>
<div class="modal-body">
<div style="margin-bottom: 10px;">Your generated encrypted password:</div>
<div class="copy-box" value="generatedClientToken"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Dismiss</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">

View file

@ -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)

32
util/aes.py Normal file
View file

@ -0,0 +1,32 @@
import base64
import hashlib
from Crypto import Random
from Crypto.Cipher import AES
class AESCipher(object):
""" Helper class for encrypting and decrypting data via AES.
Copied From: http://stackoverflow.com/a/21928790
"""
def __init__(self, key):
self.bs = 32
self.key = key
def encrypt(self, raw):
raw = self._pad(raw)
iv = Random.new().read(AES.block_size)
cipher = AES.new(self.key, AES.MODE_CBC, iv)
return base64.b64encode(iv + cipher.encrypt(raw))
def decrypt(self, enc):
enc = base64.b64decode(enc)
iv = enc[:AES.block_size]
cipher = AES.new(self.key, AES.MODE_CBC, iv)
return self._unpad(cipher.decrypt(enc[AES.block_size:])).decode('utf-8')
def _pad(self, s):
return s + (self.bs - len(s) % self.bs) * chr(self.bs - len(s) % self.bs)
@staticmethod
def _unpad(s):
return s[:-ord(s[len(s)-1:])]