diff --git a/auth/auth.py b/auth/auth.py index 79e07e3be..30e2f68db 100644 --- a/auth/auth.py +++ b/auth/auth.py @@ -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) diff --git a/config.py b/config.py index 339ffca34..2d50138af 100644 --- a/config.py +++ b/config.py @@ -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 = { diff --git a/data/users.py b/data/users.py index 9e01e4d45..13556a552 100644 --- a/data/users.py +++ b/data/users.py @@ -1,6 +1,11 @@ import ldap import logging +import json +import itertools +import uuid +import struct +from util.aes import AESCipher from util.validation import generate_valid_usernames from data import model @@ -106,6 +111,7 @@ class LDAPUsers(object): return found_user is not None + class UserAuthentication(object): def __init__(self, app=None): self.app = app @@ -138,5 +144,76 @@ class UserAuthentication(object): app.extensions['authentication'] = 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): return getattr(self.state, name, None) diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 6c3cafd63..9ccb1d7aa 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -1,6 +1,7 @@ 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 @@ -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): 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 +446,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') diff --git a/endpoints/index.py b/endpoints/index.py index 2df427601..e42696777 100644 --- a/endpoints/index.py +++ b/endpoints/index.py @@ -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.') diff --git a/static/directives/config/config-setup-tool.html b/static/directives/config/config-setup-tool.html index 6c774dcf7..aa0c60e5d 100644 --- a/static/directives/config/config-setup-tool.html +++ b/static/directives/config/config-setup-tool.html @@ -34,7 +34,7 @@ - User Creation: + User Creation:
@@ -46,6 +46,23 @@
+ + Encrypted Client Tokens: + +
+ + +
+
+ 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. +
+
+ This feature is highly recommended for setups with LDAP authentication, as Docker currently stores passwords in plaintext on user's machines. +
+ + @@ -293,6 +310,16 @@

+
+ It is highly recommended to require encrypted client tokens. LDAP passwords used in the Docker client will be stored in plaintext! + Enable this requirement now. +
+ +
+ Note: The "Require Encrypted Client Tokens" feature is currently enabled which will + prevent LDAP passwords from being saved as plaintext by the Docker client. +
+ @@ -305,7 +332,6 @@
Authentication:
- diff --git a/static/js/pages/user-admin.js b/static/js/pages/user-admin.js index 6af34d264..8be13df30 100644 --- a/static/js/pages/user-admin.js +++ b/static/js/pages/user-admin.js @@ -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) { var params = { 'servicename': kind diff --git a/static/js/services/ui-service.js b/static/js/services/ui-service.js index 2e857e8fa..4b8464e9e 100644 --- a/static/js/services/ui-service.js +++ b/static/js/services/ui-service.js @@ -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 + + '' + + '' + + '', + "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; }]); diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html index 2e3249764..9dee0f5ca 100644 --- a/static/partials/user-admin.html +++ b/static/partials/user-admin.html @@ -32,7 +32,7 @@
  • Account E-mail
  • Robot Accounts
  • -
  • Change Password
  • +
  • Password
  • External Logins
  • Authorized Applications
  • @@ -139,8 +139,31 @@ - +
    + +
    +
    +
    Generate Encrypted Password
    + +
    +
    + Due to Docker storing passwords entered on the command line in plaintext, it is highly recommended to use the button below to generate an an encrypted version of your password. +
    + +
    + This installation is set to require encrypted passwords when + using the Docker command line interface. To generate an encrypted password, click the button below. +
    + + +
    +
    +
    + +
    Change Password
    @@ -152,6 +175,9 @@ Password changed successfully
    +
    Note: Changing your password will also invalidate any generated encrypted passwords.
    + +
    + + + +
  • LDAP URI: