From e4b659f1071036805569afb9f564d9b961d36a48 Mon Sep 17 00:00:00 2001
From: Joseph Schorr
Date: Wed, 25 Mar 2015 18:43:12 -0400
Subject: [PATCH 1/5] Add support for encrypted client tokens via basic auth
(for the docker CLI) and a feature flag to disable normal passwords
---
auth/auth.py | 3 +-
config.py | 4 ++
data/users.py | 26 ++++++++++
endpoints/api/user.py | 51 ++++++++++++++++++-
endpoints/index.py | 4 +-
.../directives/config/config-setup-tool.html | 30 ++++++++++-
static/js/pages/user-admin.js | 15 ++++++
static/js/services/ui-service.js | 42 +++++++++++++++
static/partials/user-admin.html | 32 ++++++++++++
test/test_api_security.py | 23 ++++++++-
10 files changed, 222 insertions(+), 8 deletions(-)
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..c7a6128db 100644
--- a/data/users.py
+++ b/data/users.py
@@ -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)
diff --git a/endpoints/api/user.py b/endpoints/api/user.py
index 6c3cafd63..d10807940 100644
--- a/endpoints/api/user.py
+++ b/endpoints/api/user.py
@@ -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')
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..9be7ddd37 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 plain-text!
+ Enable this requirement now.
+
+
+
+ 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.
+
Click the "Generate" button below to generate a client token that can be used in place of your password for the Docker
+ command line.
+
+
+
+
@@ -370,6 +381,27 @@
+
+
+
+
+
+
+
Generate Client Token
+
+
+
Your generated client token:
+
+
+
+
+
+
+
+
+
diff --git a/test/test_api_security.py b/test/test_api_security.py
index df01fe8c9..2560b7bcd 100644
--- a/test/test_api_security.py
+++ b/test/test_api_security.py
@@ -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)
From d23bb6616d15db045adb41d3859e37496edc696f Mon Sep 17 00:00:00 2001
From: Joseph Schorr
Date: Thu, 26 Mar 2015 13:22:16 -0400
Subject: [PATCH 2/5] Fix error message to exactly match current output
---
data/users.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/data/users.py b/data/users.py
index c7a6128db..f8fce77a5 100644
--- a/data/users.py
+++ b/data/users.py
@@ -161,7 +161,7 @@ class UserAuthentication(object):
if result:
return (result, '')
else:
- return (result, 'Invalid password')
+ return (result, 'Invalid password.')
def __getattr__(self, name):
From aaf1b23e98bc7643bdb75c99eef097f51b10d879 Mon Sep 17 00:00:00 2001
From: Joseph Schorr
Date: Thu, 26 Mar 2015 15:10:58 -0400
Subject: [PATCH 3/5] Address CL concerns and switch to a real encryption
system
---
data/users.py | 65 ++++++++++++++++---
endpoints/api/user.py | 10 +--
.../directives/config/config-setup-tool.html | 4 +-
static/js/pages/user-admin.js | 2 +-
static/partials/user-admin.html | 48 +++++++++-----
util/aes.py | 32 +++++++++
6 files changed, 124 insertions(+), 37 deletions(-)
create mode 100644 util/aes.py
diff --git a/data/users.py b/data/users.py
index f8fce77a5..bcd0fad62 100644
--- a/data/users.py
+++ b/data/users.py
@@ -1,7 +1,10 @@
import ldap
import logging
+import json
+import itertools
+import uuid
-from flask.sessions import SecureCookieSessionInterface, BadSignature
+from util.aes import AESCipher
from util.validation import generate_valid_usernames
from data import model
@@ -107,6 +110,7 @@ class LDAPUsers(object):
return found_user is not None
+
class UserAuthentication(object):
def __init__(self, app=None):
self.app = app
@@ -139,23 +143,68 @@ 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 a float.
+ try:
+ secret_key = float(app_secret_key)
+ 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:
- 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:
+ 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:
diff --git a/endpoints/api/user.py b/endpoints/api/user.py
index d10807940..9ccb1d7aa 100644
--- a/endpoints/api/user.py
+++ b/endpoints/api/user.py
@@ -5,7 +5,6 @@ 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
@@ -370,15 +369,8 @@ class ClientKey(ApiResource):
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
+ 'key': authentication.encrypt_user_password(password)
}
diff --git a/static/directives/config/config-setup-tool.html b/static/directives/config/config-setup-tool.html
index 9be7ddd37..aa0c60e5d 100644
--- a/static/directives/config/config-setup-tool.html
+++ b/static/directives/config/config-setup-tool.html
@@ -311,13 +311,13 @@
- It is highly recommended to require encrypted client tokens. LDAP passwords used in the Docker client will be stored in plain-text!
+ 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 plain-text by the Docker client.
+ prevent LDAP passwords from being saved as plaintext by the Docker client.
diff --git a/static/js/pages/user-admin.js b/static/js/pages/user-admin.js
index 43a56388a..8be13df30 100644
--- a/static/js/pages/user-admin.js
+++ b/static/js/pages/user-admin.js
@@ -208,7 +208,7 @@
}, ApiService.errorDisplay('Could not generate token'));
};
- UIService.showPasswordDialog('Enter your password to generate a client token:', generateToken);
+ UIService.showPasswordDialog('Enter your password to generated an encrypted version:', generateToken);
};
$scope.detachExternalLogin = function(kind) {
diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html
index 4d803689d..9dee0f5ca 100644
--- a/static/partials/user-admin.html
+++ b/static/partials/user-admin.html
@@ -32,8 +32,7 @@
Click the "Generate" button below to generate a client token that can be used in place of your password for the Docker
- command line.
-
-
-
-
@@ -150,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
@@ -163,6 +175,9 @@
Password changed successfully
+
Note: Changing your password will also invalidate any generated encrypted passwords.
+
+
-
diff --git a/util/aes.py b/util/aes.py
new file mode 100644
index 000000000..10f1ac030
--- /dev/null
+++ b/util/aes.py
@@ -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:])]
\ No newline at end of file
From 4d1792db1cc89333744044910a7503892d461fe1 Mon Sep 17 00:00:00 2001
From: Joseph Schorr
Date: Thu, 26 Mar 2015 15:47:44 -0400
Subject: [PATCH 4/5] getrandbits creates an int, not a float
---
data/users.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/data/users.py b/data/users.py
index bcd0fad62..628f5a0c3 100644
--- a/data/users.py
+++ b/data/users.py
@@ -148,9 +148,9 @@ class UserAuthentication(object):
from app import app
app_secret_key = app.config['SECRET_KEY']
- # First try parsing the key as a float.
+ # First try parsing the key as an int.
try:
- secret_key = float(app_secret_key)
+ secret_key = int(app_secret_key)
except ValueError:
secret_key = app_secret_key
From f8afd8b5ce59fa4aeb8bf6b20a1b211a84073441 Mon Sep 17 00:00:00 2001
From: Joseph Schorr
Date: Thu, 26 Mar 2015 16:13:35 -0400
Subject: [PATCH 5/5] Make sure to parse the big int into a byte string
---
data/users.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/data/users.py b/data/users.py
index 628f5a0c3..13556a552 100644
--- a/data/users.py
+++ b/data/users.py
@@ -3,6 +3,7 @@ import logging
import json
import itertools
import uuid
+import struct
from util.aes import AESCipher
from util.validation import generate_valid_usernames
@@ -150,7 +151,8 @@ class UserAuthentication(object):
# First try parsing the key as an int.
try:
- secret_key = int(app_secret_key)
+ big_int = int(app_secret_key)
+ secret_key = bytearray.fromhex('{:02x}'.format(big_int))
except ValueError:
secret_key = app_secret_key