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.
+
+ 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.