LDAP improvements:

- Better logging
  - Better error messages
  - Add unit tests
  - Clean up the setup tool for LDAP
This commit is contained in:
Joseph Schorr 2015-05-11 21:23:18 -04:00
parent 3e1abba284
commit efab02ae47
5 changed files with 173 additions and 28 deletions

View file

@ -11,12 +11,23 @@ from util.validation import generate_valid_usernames
from data import model from data import model
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
if os.environ.get('LDAP_DEBUG') == '1':
logger.setLevel(logging.DEBUG)
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
logger.addHandler(ch)
class DatabaseUsers(object): class DatabaseUsers(object):
def verify_user(self, username_or_email, password): def verify_user(self, username_or_email, password):
""" Simply delegate to the model implementation. """ """ Simply delegate to the model implementation. """
return model.verify_user(username_or_email, password) result = model.verify_user(username_or_email, password)
if not result:
return (None, 'Invalid Username or Password')
return (result, None)
def user_exists(self, username): def user_exists(self, username):
return model.get_user(username) is not None return model.get_user(username) is not None
@ -30,9 +41,10 @@ class LDAPConnection(object):
self._conn = None self._conn = None
def __enter__(self): def __enter__(self):
trace_level = 2 if os.environ.get('LDAP_DEBUG') else 0 trace_level = 2 if os.environ.get('LDAP_DEBUG') == '1' else 0
self._conn = ldap.initialize(self._ldap_uri, trace_level=trace_level) self._conn = ldap.initialize(self._ldap_uri, trace_level=trace_level)
self._conn.simple_bind_s(self._user_dn, self._user_pw) self._conn.simple_bind_s(self._user_dn, self._user_pw)
return self._conn return self._conn
def __exit__(self, exc_type, value, tb): def __exit__(self, exc_type, value, tb):
@ -55,7 +67,7 @@ class LDAPUsers(object):
query = u'(|({0}={2})({1}={2}))'.format(self._uid_attr, self._email_attr, query = u'(|({0}={2})({1}={2}))'.format(self._uid_attr, self._email_attr,
username_or_email) username_or_email)
logger.debug('Conducting user search: %s => %s', user_search_dn, query) logger.debug('Conducting user search: %s under %s', query, user_search_dn)
try: try:
user = conn.search_s(user_search_dn, ldap.SCOPE_SUBTREE, query.encode('utf-8')) user = conn.search_s(user_search_dn, ldap.SCOPE_SUBTREE, query.encode('utf-8'))
except ldap.LDAPError: except ldap.LDAPError:
@ -75,12 +87,12 @@ class LDAPUsers(object):
# Make sure that even if the server supports anonymous binds, we don't allow it # Make sure that even if the server supports anonymous binds, we don't allow it
if not password: if not password:
return None return (None, 'Anonymous binding not allowed')
found_user = self._ldap_user_search(username_or_email) found_user = self._ldap_user_search(username_or_email)
if found_user is None: if found_user is None:
return None return (None, 'Username not found')
found_dn, found_response = found_user found_dn, found_response = found_user
@ -91,9 +103,15 @@ class LDAPUsers(object):
pass pass
except ldap.INVALID_CREDENTIALS: except ldap.INVALID_CREDENTIALS:
logger.exception('Invalid LDAP credentials') logger.exception('Invalid LDAP credentials')
return None return (None, 'Invalid password')
# Now check if we have a federated login for this user # Now check if we have a federated login for this user
if not found_response.get(self._uid_attr):
return (None, 'Missing uid field "%s" in user record' % self._uid_attr)
if not found_response.get(self._email_attr):
return (None, 'Missing mail field "%s" in user record' % self._email_attr)
username = found_response[self._uid_attr][0].decode('utf-8') username = found_response[self._uid_attr][0].decode('utf-8')
email = found_response[self._email_attr][0] email = found_response[self._email_attr][0]
db_user = model.verify_federated_login('ldap', username) db_user = model.verify_federated_login('ldap', username)
@ -107,7 +125,7 @@ class LDAPUsers(object):
if not valid_username: if not valid_username:
logger.error('Unable to pick a username for user: %s', username) logger.error('Unable to pick a username for user: %s', username)
return None return (None, 'Unable to pick a username. Please report this to your administrator.')
db_user = model.create_federated_user(valid_username, email, 'ldap', username, db_user = model.create_federated_user(valid_username, email, 'ldap', username,
set_password_notification=False) set_password_notification=False)
@ -116,7 +134,7 @@ class LDAPUsers(object):
db_user.email = email db_user.email = email
db_user.save() db_user.save()
return db_user return (db_user, None)
def user_exists(self, username): def user_exists(self, username):
found_user = self._ldap_user_search(username) found_user = self._ldap_user_search(username)
@ -225,12 +243,7 @@ class UserAuthentication(object):
else: else:
password = decrypted password = decrypted
result = self.state.verify_user(username_or_email, password) return 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

@ -51,3 +51,4 @@ cachetools
mock mock
psutil psutil
stringscore stringscore
mockldap

View file

@ -37,6 +37,7 @@ jsonschema==2.4.0
marisa-trie==0.7 marisa-trie==0.7
mixpanel-py==3.2.1 mixpanel-py==3.2.1
mock==1.0.1 mock==1.0.1
mockldap==0.2.4
paramiko==1.15.2 paramiko==1.15.2
peewee==2.4.7 peewee==2.4.7
psutil==2.2.1 psutil==2.2.1

View file

@ -310,12 +310,12 @@
</p> </p>
</div> </div>
<div class="alert alert-warning" ng-if="config.AUTHENTICATION_TYPE == 'LDAP' && !config.FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH"> <div class="co-alert co-alert-warning" ng-if="config.AUTHENTICATION_TYPE == 'LDAP' && !config.FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH">
It is <strong>highly recommended</strong> to require encrypted client passwords. LDAP passwords used in the Docker client will be stored in <strong>plaintext</strong>! It is <strong>highly recommended</strong> to require encrypted client passwords. 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>. <a href="javascript:void(0)" ng-click="config.FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH = true">Enable this requirement now</a>.
</div> </div>
<div class="alert alert-success" ng-if="config.AUTHENTICATION_TYPE == 'LDAP' && config.FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH"> <div class="co-alert co-alert-success" ng-if="config.AUTHENTICATION_TYPE == 'LDAP' && config.FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH">
Note: The "Require Encrypted Client Passwords" feature is currently enabled which will Note: The "Require Encrypted Client Passwords" feature is currently enabled which will
prevent LDAP passwords from being saved as plaintext by the Docker client. prevent LDAP passwords from being saved as plaintext by the Docker client.
</div> </div>
@ -343,29 +343,76 @@
</div> </div>
</td> </td>
</tr> </tr>
<tr>
<td>Administrator DN:</td>
<td><span class="config-string-field" binding="config.LDAP_ADMIN_DN"></span></td>
</tr>
<tr> <tr>
<td>Base DN:</td> <td>Base DN:</td>
<td><span class="config-list-field" item-title="DN" binding="config.LDAP_BASE_DN"></span></td> <td>
<span class="config-list-field" item-title="DN" binding="config.LDAP_BASE_DN"></span>
<div class="help-text">
A list of Distinguished Name pieces which forms the base path for
looking up all LDAP records.
</div>
<div class="help-text">
Example: [dc=my,dc=domain,dc=com]
</div>
</td>
</tr> </tr>
<tr> <tr>
<td>Administrator Password:</td> <td>User Relative DN:</td>
<td><span class="config-string-field" binding="config.LDAP_ADMIN_PASSWD"></span></td> <td>
<span class="config-list-field" item-title="RDN" binding="config.LDAP_USER_RDN"></span>
<div class="help-text">
A list of Distinguished Name pieces which forms the base path for
looking up all user LDAP records, relative to the Base DN defined above.
</div>
<div class="help-text">
Example: [ou=employees]
</div>
</td>
</tr> </tr>
<tr> <tr>
<td>E-mail Attribute:</td> <td>Administrator DN:</td>
<td><span class="config-string-field" binding="config.LDAP_EMAIL_ATTR"></span></td> <td><span class="config-string-field" binding="config.LDAP_ADMIN_DN"></span>
<div class="help-text">
The Distinguished Name for the Administrator account. This account must be able to login and view the records for all user accounts.
</div>
<div class="help-text">
Example: uid=admin,ou=employees,dc=my,dc=domain,dc=com
</div>
</td>
</tr>
<tr>
<td>Administrator DN Password:</td>
<td>
<div class="co-alert co-alert-warning">
Note: This will be stored in
<strong>plaintext</strong> inside the config.yaml, so setting up a dedicated account or using
<a href="http://tools.ietf.org/id/draft-stroeder-hashed-userpassword-values-01.html" target="_blank">a password hash</a> is <strong>highly</strong> recommended.
</div>
<span class="config-string-field" binding="config.LDAP_ADMIN_PASSWD"></span>
<div class="help-text">
The password for the Administrator DN.
</div>
</td>
</tr> </tr>
<tr> <tr>
<td>UID Attribute:</td> <td>UID Attribute:</td>
<td><span class="config-string-field" binding="config.LDAP_UID_ATTR"></span></td> <td>
<span class="config-string-field" binding="config.LDAP_UID_ATTR" default-value="uid"></span>
<div class="help-text">
The name of the property field in your LDAP user records that stores your
users' username. Typically "uid".
</div>
</td>
</tr> </tr>
<tr> <tr>
<td>User RDN:</td> <td>Mail Attribute:</td>
<td><span class="config-list-field" item-title="RDN" binding="config.LDAP_USER_RDN"></span></td> <td>
<span class="config-string-field" binding="config.LDAP_EMAIL_ATTR" default-value="mail"></span>
<div class="help-text">
The name of the property field in your LDAP user records that stores your
users' e-mail address(es). Typically "mail".
</div>
</td>
</tr> </tr>
</table> </table>
</div> </div>

83
test/test_ldap.py Normal file
View file

@ -0,0 +1,83 @@
import unittest
from app import app
from initdb import setup_database_for_testing, finished_database_for_testing
from data import model
from data.users import LDAPUsers
from mockldap import MockLdap
class TestLDAP(unittest.TestCase):
def setUp(self):
setup_database_for_testing(self)
self.app = app.test_client()
self.ctx = app.test_request_context()
self.ctx.__enter__()
self.mockldap = MockLdap({
'dc=quay,dc=io': {'dc': ['quay', 'io']},
'ou=employees,dc=quay,dc=io': {
'dc': ['quay', 'io'],
'ou': 'employees'
},
'uid=testy,ou=employees,dc=quay,dc=io': {
'dc': ['quay', 'io'],
'ou': 'employees',
'uid': 'testy',
'userPassword': ['password']
},
'uid=someuser,ou=employees,dc=quay,dc=io': {
'dc': ['quay', 'io'],
'ou': 'employees',
'uid': ['someuser'],
'userPassword': ['somepass'],
'mail': ['foo@bar.com']
},
'uid=nomail,ou=employees,dc=quay,dc=io': {
'dc': ['quay', 'io'],
'ou': 'employees',
'uid': ['nomail'],
'userPassword': ['somepass']
}
})
self.mockldap.start()
def tearDown(self):
self.mockldap.stop()
finished_database_for_testing(self)
self.ctx.__exit__(True, None, None)
def test_login(self):
base_dn = ['dc=quay', 'dc=io']
admin_dn = 'uid=testy,ou=employees,dc=quay,dc=io'
admin_passwd = 'password'
user_rdn = ['ou=employees']
uid_attr = 'uid'
email_attr = 'mail'
ldap = LDAPUsers('ldap://localhost', base_dn, admin_dn, admin_passwd, user_rdn,
uid_attr, email_attr)
(response, _) = ldap.verify_user('someuser', 'somepass')
self.assertEquals(response.username, 'someuser')
def test_missing_mail(self):
base_dn = ['dc=quay', 'dc=io']
admin_dn = 'uid=testy,ou=employees,dc=quay,dc=io'
admin_passwd = 'password'
user_rdn = ['ou=employees']
uid_attr = 'uid'
email_attr = 'mail'
ldap = LDAPUsers('ldap://localhost', base_dn, admin_dn, admin_passwd, user_rdn,
uid_attr, email_attr)
(response, err_msg) = ldap.verify_user('nomail', 'somepass')
self.assertIsNone(response)
self.assertEquals('Missing mail field "mail" in user record', err_msg)
if __name__ == '__main__':
unittest.main()