From 6c0e7b97417e520b011f09e3ddadde67feb1456e Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 26 Nov 2018 18:12:04 +0100 Subject: [PATCH] ldap: rewrite Connector to use Source DB Entries --- passbook/ldap/auth.py | 11 +- passbook/ldap/ldap_connector.py | 157 ++++++++---------- passbook/ldap/tests/__init__.py | 0 passbook/ldap/tests/ldap_mock.json | 200 ----------------------- passbook/ldap/tests/test_account_ldap.py | 53 ------ passbook/lib/default.yml | 46 +++--- 6 files changed, 104 insertions(+), 363 deletions(-) delete mode 100644 passbook/ldap/tests/__init__.py delete mode 100644 passbook/ldap/tests/ldap_mock.json delete mode 100644 passbook/ldap/tests/test_account_ldap.py diff --git a/passbook/ldap/auth.py b/passbook/ldap/auth.py index 23ebe666f..24a26d875 100644 --- a/passbook/ldap/auth.py +++ b/passbook/ldap/auth.py @@ -4,6 +4,7 @@ from logging import getLogger from django.contrib.auth.backends import ModelBackend from passbook.ldap.ldap_connector import LDAPConnector +from passbook.ldap.models import LDAPSource LOGGER = getLogger(__name__) @@ -15,7 +16,9 @@ class LDAPBackend(ModelBackend): """Try to authenticate a user via ldap""" if 'password' not in kwargs: return None - if not LDAPConnector.enabled: - return None - _ldap = LDAPConnector() - return _ldap.auth_user(**kwargs) + for source in LDAPSource.objects.filter(enabled=True): + _ldap = LDAPConnector(source) + user = _ldap.auth_user(**kwargs) + if user: + return user + return None diff --git a/passbook/ldap/ldap_connector.py b/passbook/ldap/ldap_connector.py index 4c9d9af42..c76f1ba39 100644 --- a/passbook/ldap/ldap_connector.py +++ b/passbook/ldap/ldap_connector.py @@ -1,6 +1,4 @@ """Wrapper for ldap3 to easily manage user""" -import os -import sys from logging import getLogger from time import time @@ -8,6 +6,7 @@ import ldap3 import ldap3.core.exceptions from passbook.core.models import User +from passbook.ldap.models import LDAPSource from passbook.lib.config import CONFIG LOGGER = getLogger(__name__) @@ -17,46 +16,40 @@ LOGIN_FIELD = CONFIG.y('ldap.login_field', 'userPrincipalName') class LDAPConnector: - """Wrapper for ldap3 to easily manage user""" + """Wrapper for ldap3 to easily manage user authentication and creation""" - con = None - domain = None - base_dn = None - mock = False - create_users_enabled = False + _server = None + _connection = None + _source = None - def __init__(self, mock=False, con_args=None, server_args=None): - super().__init__() - self.create_users_enabled = CONFIG.y('ldap.create_users') + def __init__(self, source: LDAPSource): + self._source = source - if not LDAPConnector.enabled: + if not self._source.enabled: LOGGER.debug("LDAP not Enabled") - if not con_args: - con_args = {} - if not server_args: - server_args = {} + # if not con_args: + # con_args = {} + # if not server_args: + # server_args = {} # Either use mock argument or test is in argv - self.domain = CONFIG.y('ldap.domain') - self.base_dn = CONFIG.y('ldap.base_dn') - if mock or any('test' in arg for arg in sys.argv): - self.mock = True - self.create_users_enabled = True - con_args['client_strategy'] = ldap3.MOCK_SYNC - server_args['get_info'] = ldap3.OFFLINE_AD_2012_R2 + # if mock or any('test' in arg for arg in sys.argv): + # self.mock = True + # self.create_users_enabled = True + # con_args['client_strategy'] = ldap3.MOCK_SYNC + # server_args['get_info'] = ldap3.OFFLINE_AD_2012_R2 + # if self.mock: + # json_path = os.path.join(os.path.dirname(__file__), 'tests', 'ldap_mock.json') + # self._connection.strategy.entries_from_json(json_path) - self.server = ldap3.Server(CONFIG.y('ldap.server.name'), **server_args) - self.con = ldap3.Connection(self.server, raise_exceptions=True, - user=CONFIG.y('ldap.bind.username'), - password=CONFIG.y('ldap.bind.password'), **con_args) + self._server = ldap3.Server(source.server_uri) # Implement URI parsing + self._connection = ldap3.Connection(self._server, raise_exceptions=True, + user=source.bind_cn, + password=source.bind_password) - if self.mock: - json_path = os.path.join(os.path.dirname(__file__), 'tests', 'ldap_mock.json') - self.con.strategy.entries_from_json(json_path) - - self.con.bind() - if CONFIG.y('ldap.server.use_tls'): - self.con.start_tls() + self._connection.bind() + # if CONFIG.y('ldap.server.use_tls'): + # self._connection.start_tls() # @staticmethod # def cleanup_mock(): @@ -72,9 +65,9 @@ class LDAPConnector: # for obj in to_apply: # try: # if obj.action == LDAPModification.ACTION_ADD: - # self.con.add(obj.dn, obj.data) + # self._connection.add(obj.dn, obj.data) # elif obj.action == LDAPModification.ACTION_MODIFY: - # self.con.modify(obj.dn, obj.data) + # self._connection.modify(obj.dn, obj.data) # # Object has been successfully applied to LDAP # obj.delete() @@ -90,29 +83,31 @@ class LDAPConnector: # action=action, # data=data) - @property - def enabled(self): - """Returns whether LDAP is enabled or not""" - return CONFIG.y('ldap.enabled') + # @property + # def enabled(self): + # """Returns whether LDAP is enabled or not""" + # return CONFIG.y('ldap.enabled') @staticmethod def encode_pass(password): """Encodes a plain-text password so it can be used by AD""" return '"{}"'.format(password).encode('utf-16-le') - def lookup(self, generate_only=False, **fields): - """Search email in LDAP and return the DN. - Returns False if nothing was found.""" + def generate_filter(self, **fields): + """Generate LDAP filter from **fields.""" filters = [] for item, value in fields.items(): filters.append("(%s=%s)" % (item, value)) ldap_filter = "(&%s)" % "".join(filters) LOGGER.debug("Constructed filter: '%s'", ldap_filter) - if generate_only: - return ldap_filter + return ldap_filter + + def lookup(self, ldap_filter: str): + """Search email in LDAP and return the DN. + Returns False if nothing was found.""" try: - self.con.search(self.base_dn, ldap_filter) - results = self.con.response + self._connection.search(self._source.search_base, ldap_filter) + results = self._connection.response if len(results) >= 1: if 'dn' in results[0]: return str(results[0]['dn']) @@ -167,30 +162,30 @@ class LDAPConnector: def auth_user(self, password, **filters): """Try to bind as either user_dn or mail with password. Returns True on success, otherwise False""" - if not LDAPConnector.enabled: - return None filters.pop('request') + if not self._source.enabled: + return None # FIXME: Adapt user_uid # email = filters.pop(CONFIG.get('passport').get('ldap').get, '') email = filters.pop('email') - user_dn = self.lookup(**{LOGIN_FIELD: email}) + user_dn = self.lookup(self.generate_filter(**{LOGIN_FIELD: email})) if not user_dn: return None # Try to bind as new user LOGGER.debug("Binding as '%s'", user_dn) try: - t_con = ldap3.Connection(self.server, user=user_dn, - password=password, raise_exceptions=True) - t_con.bind() - if self.con.search( - search_base=self.base_dn, - search_filter=self.lookup(generate_only=True, **{LOGIN_FIELD: email}), + temp_connection = ldap3.Connection(self._server, user=user_dn, + password=password, raise_exceptions=True) + temp_connection.bind() + if self._connection.search( + search_base=self._source.search_base, + search_filter=self.generate_filter(**{LOGIN_FIELD: email}), search_scope=ldap3.SUBTREE, attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES], get_operational_attributes=True, size_limit=1, ): - response = self.con.response[0] + response = self._connection.response[0] # If user has no email set in AD, use UPN if 'mail' not in response.get('attributes'): response['attributes']['mail'] = response['attributes']['userPrincipalName'] @@ -205,14 +200,14 @@ class LDAPConnector: def is_email_used(self, mail): """Checks whether an email address is already registered in LDAP""" - if self.create_users_enabled: - return self.lookup(mail=mail) + if self._source.create_user: + return self.lookup(self.generate_filter(mail=mail)) return False def create_ldap_user(self, user, raw_password): """Creates a new LDAP User from a django user and raw_password. Returns True on success, otherwise False""" - if not self.create_users_enabled: + if self._source.create_user: LOGGER.debug("User creation not enabled") return False # The dn of our new entry/object @@ -222,7 +217,7 @@ class LDAPConnector: username_trunk = username[:20] if len(username) > 20 else username # AD doesn't like sAMAccountName's with . at the end username_trunk = username_trunk[:-1] if username_trunk[-1] == '.' else username_trunk - user_dn = 'cn=' + username + ',' + self.base_dn + user_dn = 'cn=' + username + ',' + self._source.search_base LOGGER.debug('New DN: %s', user_dn) attrs = { 'distinguishedName': str(user_dn), @@ -233,11 +228,11 @@ class LDAPConnector: 'displayName': str(user.username), 'name': str(user.first_name), 'mail': str(user.email), - 'userPrincipalName': str(username + '@' + self.domain), + 'userPrincipalName': str(username + '@' + self._source.domain), 'objectClass': ['top', 'person', 'organizationalPerson', 'user'], } try: - self.con.add(user_dn, attributes=attrs) + self._connection.add(user_dn, attributes=attrs) except ldap3.core.exceptions.LDAPException as exception: LOGGER.warning("Failed to create user ('%s'), saved to DB", exception) # LDAPConnector.handle_ldap_error(user_dn, LDAPModification.ACTION_ADD, attrs) @@ -246,50 +241,42 @@ class LDAPConnector: def _do_modify(self, diff, **fields): """Do the LDAP modification itself""" - user_dn = self.lookup(**fields) + user_dn = self.lookup(self.generate_filter(**fields)) try: - self.con.modify(user_dn, diff) + self._connection.modify(user_dn, diff) except ldap3.core.exceptions.LDAPException as exception: LOGGER.warning("Failed to modify %s ('%s'), saved to DB", user_dn, exception) # LDAPConnector.handle_ldap_error(user_dn, LDAPModification.ACTION_MODIFY, diff) LOGGER.debug("modified account '%s' [%s]", user_dn, ','.join(diff.keys())) - return 'result' in self.con.result and self.con.result['result'] == 0 + return 'result' in self._connection.result and self._connection.result['result'] == 0 def disable_user(self, **fields): - """ - Disables LDAP user based on mail or user_dn. - Returns True on success, otherwise False - """ + """Disables LDAP user based on mail or user_dn. + Returns True on success, otherwise False""" diff = { 'userAccountControl': [(ldap3.MODIFY_REPLACE, [str(66050)])], } return self._do_modify(diff, **fields) def enable_user(self, **fields): - """ - Enables LDAP user based on mail or user_dn. - Returns True on success, otherwise False - """ + """Enables LDAP user based on mail or user_dn. + Returns True on success, otherwise False""" diff = { 'userAccountControl': [(ldap3.MODIFY_REPLACE, [str(66048)])], } return self._do_modify(diff, **fields) def change_password(self, new_password, **fields): - """ - Changes LDAP user's password based on mail or user_dn. - Returns True on success, otherwise False - """ + """Changes LDAP user's password based on mail or user_dn. + Returns True on success, otherwise False""" diff = { 'unicodePwd': [(ldap3.MODIFY_REPLACE, [LDAPConnector.encode_pass(new_password)])], } return self._do_modify(diff, **fields) def add_to_group(self, group_dn, **fields): - """ - Adds mail or user_dn to group_dn - Returns True on success, otherwise False - """ + """Adds mail or user_dn to group_dn + Returns True on success, otherwise False""" user_dn = self.lookup(**fields) diff = { 'member': [(ldap3.MODIFY_ADD), [user_dn]] @@ -297,10 +284,8 @@ class LDAPConnector: return self._do_modify(diff, user_dn=group_dn) def remove_from_group(self, group_dn, **fields): - """ - Removes mail or user_dn from group_dn - Returns True on success, otherwise False - """ + """Removes mail or user_dn from group_dn + Returns True on success, otherwise False""" user_dn = self.lookup(**fields) diff = { 'member': [(ldap3.MODIFY_DELETE), [user_dn]] diff --git a/passbook/ldap/tests/__init__.py b/passbook/ldap/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/passbook/ldap/tests/ldap_mock.json b/passbook/ldap/tests/ldap_mock.json deleted file mode 100644 index 9a69e2b41..000000000 --- a/passbook/ldap/tests/ldap_mock.json +++ /dev/null @@ -1,200 +0,0 @@ -{ - "entries": [ - { - "attributes": { - "dSCorePropagationData": [ - "1601-01-01 00:00:00+00:00" - ], - "distinguishedName": "OU=customers,DC=mock,DC=beryju,DC=org", - "instanceType": 4, - "name": "customers_dev", - "objectCategory": "CN=Organizational-Unit,CN=Schema,CN=Configuration,DC=mock,DC=beryju,DC=org", - "objectClass": [ - "top", - "organizationalUnit" - ], - "objectGUID": "976832bb-f359-4ebc-b7c4-cb6c2ac171cb", - "ou": [ - "customers_dev" - ], - "uSNChanged": 139575, - "uSNCreated": 139575, - "whenChanged": "2016-12-26 17:08:44+00:00", - "whenCreated": "2016-12-26 17:08:20+00:00" - }, - "dn": "OU=customers,DC=mock,DC=beryju,DC=org", - "raw": { - "dSCorePropagationData": [ - "16010101000000.0Z" - ], - "distinguishedName": [ - "OU=customers,DC=mock,DC=beryju,DC=org" - ], - "instanceType": [ - "4" - ], - "name": [ - "customers_dev" - ], - "objectCategory": [ - "CN=Organizational-Unit,CN=Schema,CN=Configuration,DC=mock,DC=beryju,DC=org" - ], - "objectClass": [ - "top", - "organizationalUnit" - ], - "objectGUID": [ - { - "encoded": "uzJol1nzvE63xMtsKsFxyw==", - "encoding": "base64" - } - ], - "ou": [ - "customers_dev" - ], - "uSNChanged": [ - "139575" - ], - "uSNCreated": [ - "139575" - ], - "whenChanged": [ - "20161226170844.0Z" - ], - "whenCreated": [ - "20161226170820.0Z" - ] - } - }, - { - "attributes": { - "accountExpires": "9999-12-31 23:59:59.999999", - "cn": "mockadm", - "codePage": 0, - "countryCode": 0, - "dSCorePropagationData": [ - "1601-01-01 00:00:00+00:00" - ], - "description": [ - "t=1484309644.2392948" - ], - "displayName": "mockadm", - "distinguishedName": "CN=mockadm,OU=customers,DC=mock,DC=beryju,DC=org", - "givenName": "admin@admin.admin", - "instanceType": 4, - "mail": "mockadm@mock.beryju.org", - "name": "mockadm", - "objectCategory": "CN=Person,CN=Schema,CN=Configuration,DC=mock,DC=beryju,DC=org", - "objectClass": [ - "top", - "person", - "organizationalPerson", - "user" - ], - "objectGUID": "d28cd23f-a3bc-40a3-93e4-f47b344197c1", - "objectSid": "S-1-5-21-3376105463-1408393234-2945003003-2175", - "primaryGroupID": 513, - "pwdLastSet": "2017-01-13 12:14:04.251018+00:00", - "sAMAccountName": "mockadm", - "sAMAccountType": 805306368, - "uSNChanged": 179076, - "uSNCreated": 179076, - "userAccountControl": 66050, - "userPrincipalName": "mockadm@mock.beryju.org", - "whenChanged": "2017-01-13 12:27:52+00:00", - "whenCreated": "2017-01-13 12:14:04+00:00", - "userPassword": "b3ryju0rg!" - }, - "dn": "CN=mockadm,OU=customers,DC=mock,DC=beryju,DC=org", - "raw": { - "accountExpires": [ - "9223372036854775807" - ], - "cn": [ - "mockadm" - ], - "codePage": [ - "0" - ], - "countryCode": [ - "0" - ], - "dSCorePropagationData": [ - "16010101000000.0Z" - ], - "description": [ - "t=1484309644.2392948" - ], - "displayName": [ - "mockadm" - ], - "distinguishedName": [ - "CN=mockadm,OU=customers,DC=mock,DC=beryju,DC=org" - ], - "givenName": [ - "admin@admin.admin" - ], - "instanceType": [ - "4" - ], - "mail": [ - "admin@admin.admin" - ], - "name": [ - "mockadm" - ], - "objectCategory": [ - "CN=Person,CN=Schema,CN=Configuration,DC=mock,DC=beryju,DC=org" - ], - "objectClass": [ - "top", - "person", - "organizationalPerson", - "user" - ], - "objectGUID": [ - { - "encoded": "P9KM0ryjo0CT5PR7NEGXwQ==", - "encoding": "base64" - } - ], - "objectSid": [ - { - "encoded": "AQUAAAAAAAUVAAAA90c7yRJg8lP7LYmvfwgAAA==", - "encoding": "base64" - } - ], - "primaryGroupID": [ - "513" - ], - "sAMAccountName": [ - "mockadm" - ], - "sAMAccountType": [ - "805306368" - ], - "uSNChanged": [ - "179076" - ], - "uSNCreated": [ - "179076" - ], - "userAccountControl": [ - "66050" - ], - "userPrincipalName": [ - "mockadm@mock.beryju.org" - ], - "whenChanged": [ - "20170113122752.0Z" - ], - "whenCreated": [ - "20170113121404.0Z" - ], - "userPassword": [ - "b3ryju0rg!" - ] - } - } - ] -} \ No newline at end of file diff --git a/passbook/ldap/tests/test_account_ldap.py b/passbook/ldap/tests/test_account_ldap.py deleted file mode 100644 index f15e65bfa..000000000 --- a/passbook/ldap/tests/test_account_ldap.py +++ /dev/null @@ -1,53 +0,0 @@ -"""passbook ldap settings""" - -import os - -from django.test import TestCase - -from passbook.core.models import User -# from supervisr.mod.auth.ldap.forms import GeneralSettingsForm -from passbook.ldap.ldap_connector import LDAPConnector - - -class TestAccountLDAP(TestCase): - """passbook ldap settings""" - - def setUp(self): - os.environ['RECAPTCHA_TESTING'] = 'True' - # FIXME: Loading mock settings from different config file - # Setting.set('domain', 'mock.beryju.org') - # Setting.set('base', 'OU=customers,DC=mock,DC=beryju,DC=org') - # Setting.set('server', 'dc1.mock.beryju.org') - # Setting.set('server:tls', False) - # Setting.set('mode', GeneralSettingsForm.MODE_CREATE_USERS) - # Setting.set('bind:user', 'CN=mockadm,OU=customers,DC=mock,DC=beryju,DC=org') - # Setting.set('bind:password', 'b3ryju0rg!') - self.ldap = LDAPConnector(mock=True) - self.password = 'b3ryju0rg!' - self.user = User.objects.create_user( - username='test@test.test', - email='test@test.test', - first_name='Test user') - self.user.save() - self.user.is_active = False - self.user.set_password(self.password) - self.user.save() - self.assertTrue(self.ldap.create_ldap_user(self.user, self.password)) - - def test_change_password(self): - """Test ldap change_password""" - self.assertTrue(self.ldap.change_password('b4ryju1rg!', mail=self.user.email)) - self.assertTrue(self.ldap.change_password('b3ryju0rg!', mail=self.user.email)) - - def test_disable_enable(self): - """Test ldap enable and disable""" - self.assertTrue(self.ldap.disable_user(mail=self.user.email)) - self.assertTrue(self.ldap.enable_user(mail=self.user.email)) - - def test_email_used(self): - """Test ldap is_email_used""" - self.assertTrue(self.ldap.is_email_used(self.user.email)) - - def test_auth(self): - """Test ldap auth""" - # self.assertTrue(self.ldap.auth_user(self.password, mail=self.user.email)) diff --git a/passbook/lib/default.yml b/passbook/lib/default.yml index 0a83dc00f..5a64501cf 100644 --- a/passbook/lib/default.yml +++ b/passbook/lib/default.yml @@ -61,30 +61,36 @@ passbook: remember_age: 2592000 # 60 * 60 * 24 * 30, one month # Provider-specific settings ldap: - # Completely enable or disable LDAP provider - enabled: false - # AD Domain, used to generate `userPrincipalName` - domain: corp.contoso.com - # Base DN in which passbook should look for users - base_dn: dn=corp,dn=contoso,dn=com - # LDAP field which is used to set the django username - username_field: sAMAccountName - # LDAP server to connect to, can be set to `` - server: - name: corp.contoso.com - use_tls: false - # Bind credentials, used for account creation - bind: - username: Administraotr@corp.contoso.com - password: VerySecurePassword! + # # Completely enable or disable LDAP provider + # enabled: false + # # AD Domain, used to generate `userPrincipalName` + # domain: corp.contoso.com + # # Base DN in which passbook should look for users + # base_dn: dn=corp,dn=contoso,dn=com + # # LDAP field which is used to set the django username + # username_field: sAMAccountName + # # LDAP server to connect to, can be set to `` + # server: + # name: corp.contoso.com + # use_tls: false + # # Bind credentials, used for account creation + # bind: + # username: Administraotr@corp.contoso.com + # password: VerySecurePassword! # Which field from `uid_fields` maps to which LDAP Attribute login_field_map: username: sAMAccountName email: mail # or userPrincipalName - # Create new users in LDAP upon sign-up - create_users: true - # Reset LDAP password when user reset their password - reset_password: true + user_attribute_map: + active_directory: + sAMAccountName: username + mail: email + given_name: first_name + name: last_name + # # Create new users in LDAP upon sign-up + # create_users: true + # # Reset LDAP password when user reset their password + # reset_password: true oauth_client: # List of python packages with sources types to load. types: