diff --git a/modoboa/core/app_settings.py b/modoboa/core/app_settings.py index 400bac25f..427a62da9 100644 --- a/modoboa/core/app_settings.py +++ b/modoboa/core/app_settings.py @@ -136,6 +136,40 @@ class GeneralParametersForm(param_forms.AdminParametersForm): "Tell if the LDAP server is an Active Directory one") ) + ldap_admin_groups = forms.CharField( + label=ugettext_lazy("Administrator groups"), + initial="", + help_text=ugettext_lazy( + "Members of those LDAP Posix groups will be created as domain " + "administrators. Use ';' characters to separate groups." + ), + required=False + ) + + ldap_group_type = forms.ChoiceField( + label=ugettext_lazy("Group type"), + initial="posixgroup", + choices=constants.LDAP_GROUP_TYPES, + help_text=ugettext_lazy( + "The LDAP group type to use with your directory." + ) + ) + + ldap_groups_search_base = forms.CharField( + label=ugettext_lazy("Groups search base"), + initial="", + help_text=ugettext_lazy( + "The distinguished name of the search base used to find groups" + ), + required=False + ) + + ldap_password_attribute = forms.CharField( + label=ugettext_lazy("Password attribute"), + initial="userPassword", + help_text=ugettext_lazy("The attribute used to store user passwords"), + ) + # LDAP authentication settings ldap_auth_sep = SeparatorField( label=ugettext_lazy("LDAP authentication settings")) @@ -198,57 +232,10 @@ class GeneralParametersForm(param_forms.AdminParametersForm): required=False, ) - ldap_admin_groups = forms.CharField( - label=ugettext_lazy("Administrator groups"), - initial="", - help_text=ugettext_lazy( - "Members of those LDAP Posix groups will be created as domain " - "administrators. Use ';' characters to separate groups." - ), - required=False - ) - - ldap_group_type = forms.ChoiceField( - label=ugettext_lazy("Group type"), - initial="posixgroup", - choices=constants.LDAP_GROUP_TYPES, - help_text=ugettext_lazy( - "The LDAP group type to use with your directory." - ) - ) - - ldap_groups_search_base = forms.CharField( - label=ugettext_lazy("Groups search base"), - initial="", - help_text=ugettext_lazy( - "The distinguished name of the search base used to find groups" - ), - required=False - ) - # LDAP sync. settings ldap_sync_sep = SeparatorField( label=ugettext_lazy("LDAP synchronization settings")) - ldap_enable_sync = YesNoField( - label=ugettext_lazy("Enable LDAP synchronization"), - initial=False, - help_text=ugettext_lazy( - "Enable automatic synchronization between local database and " - "LDAP directory") - ) - - ldap_sync_delete_remote_account = YesNoField( - label=ugettext_lazy( - "Delete remote LDAP account when local account is deleted" - ), - initial=False, - help_text=ugettext_lazy( - "Delete remote LDAP account when local account is deleted, " - "otherwise it will be disabled." - ) - ) - ldap_sync_bind_dn = forms.CharField( label=ugettext_lazy("Bind DN"), initial="", @@ -270,6 +257,25 @@ class GeneralParametersForm(param_forms.AdminParametersForm): required=False ) + ldap_enable_sync = YesNoField( + label=ugettext_lazy("Enable export to LDAP"), + initial=False, + help_text=ugettext_lazy( + "Enable automatic synchronization between local database and " + "LDAP directory") + ) + + ldap_sync_delete_remote_account = YesNoField( + label=ugettext_lazy( + "Delete remote LDAP account when local account is deleted" + ), + initial=False, + help_text=ugettext_lazy( + "Delete remote LDAP account when local account is deleted, " + "otherwise it will be disabled." + ) + ) + ldap_sync_account_dn_template = forms.CharField( label=ugettext_lazy("Account DN template"), initial="", @@ -280,10 +286,40 @@ class GeneralParametersForm(param_forms.AdminParametersForm): required=False ) - ldap_password_attribute = forms.CharField( - label=ugettext_lazy("Password attribute"), - initial="userPassword", - help_text=ugettext_lazy("The attribute used to store user passwords"), + ldap_enable_import = YesNoField( + label=ugettext_lazy("Enable import from LDAP"), + initial=False, + help_text=ugettext_lazy( + "Enable account synchronization from LDAP directory to local " + "database" + ) + ) + + ldap_import_search_base = forms.CharField( + label=ugettext_lazy("Users search base"), + initial="", + help_text=ugettext_lazy( + "The distinguished name of the search base used to find users" + ), + required=False, + ) + + ldap_import_search_filter = forms.CharField( + label=ugettext_lazy("Search filter"), + initial="(cn=*)", + help_text=ugettext_lazy( + "An optional filter string (e.g. '(objectClass=person)'). " + "In order to be valid, it must be enclosed in parentheses." + ), + required=False, + ) + + ldap_import_username_attr = forms.CharField( + label=ugettext_lazy("Username attribute"), + initial="cn", + help_text=ugettext_lazy( + "The name of the LDAP attribute where the username can be found." + ), ) dash_sep = SeparatorField(label=ugettext_lazy("Dashboard")) @@ -407,6 +443,11 @@ class GeneralParametersForm(param_forms.AdminParametersForm): "ldap_admin_groups": "authentication_type=ldap", "ldap_group_type": "authentication_type=ldap", "ldap_groups_search_base": "authentication_type=ldap", + "ldap_sync_delete_remote_account": "ldap_enable_sync=True", + "ldap_sync_account_dn_template": "ldap_enable_sync=True", + "ldap_import_search_base": "ldap_enable_import=True", + "ldap_import_search_filter": "ldap_enable_import=True", + "ldap_import_username_attr": "ldap_enable_import=True", "check_new_versions": "enable_api_communication=True", "send_statistics": "enable_api_communication=True", "send_new_versions_email": "check_new_versions=True", diff --git a/modoboa/core/tests/test_views.py b/modoboa/core/tests/test_views.py index 45905d3b6..9f7b24787 100644 --- a/modoboa/core/tests/test_views.py +++ b/modoboa/core/tests/test_views.py @@ -80,6 +80,10 @@ SETTINGS_SAMPLE = { "core-ldap_enable_secondary_server": False, "core-ldap_secondary_server_address": "localhost", "core-ldap_secondary_server_port": 389, + "core-ldap_enable_import": False, + "core-ldap_import_search_base": "", + "core-ldap_import_search_filter": "", + "core-ldap_import_username_attr": "cn" } diff --git a/modoboa/ldapsync/lib.py b/modoboa/ldapsync/lib.py index d29879e8a..f8e6734b1 100644 --- a/modoboa/ldapsync/lib.py +++ b/modoboa/ldapsync/lib.py @@ -3,9 +3,12 @@ import ldap import ldap.modlist as modlist +from django.conf import settings from django.utils.encoding import force_bytes, force_str from django.utils.translation import ugettext as _ +from modoboa.core import models as core_models +from modoboa.lib.email_utils import split_mailbox from modoboa.lib.exceptions import InternalError @@ -15,8 +18,10 @@ def create_connection(srv_address, srv_port, config, username, password): uri = "{}://{}".format( "ldaps" if config["ldap_secured"] == "ssl" else "ldap", uri) conn = ldap.initialize(uri) + conn.protocol_version = 3 conn.set_option(ldap.OPT_X_TLS_DEMAND, True) conn.set_option(ldap.OPT_DEBUG_LEVEL, 255) + conn.set_option(ldap.OPT_REFERRALS, 0) conn.simple_bind_s( force_str(username if username else config["ldap_sync_bind_dn"]), force_str(password if password else config["ldap_sync_bind_password"]) @@ -142,3 +147,92 @@ def delete_ldap_account(user, config): except ldap.LDAPError as e: raise InternalError( _("Failed to disable LDAP account: {}").format(e)) + + +def find_user_groups(conn, config, dn, entry): + """Retrieve groups for given user.""" + condition = ( + config["ldap_is_active_directory"] or + config["ldap_group_type"] == "groupofnames" + ) + if condition: + flt = "(member={})".format(dn) + elif config["ldap_group_type"] == "posixgroup": + flt = "(memberUid={})".format(force_str(entry["uid"][0])) + + result = conn.search_s( + config["ldap_groups_search_base"], + ldap.SCOPE_SUBTREE, + flt + ) + groups = [] + for dn, entry in result: + if not dn: + continue + groups.append(dn.split(',')[0].split('=')[1]) + return groups + + +def user_is_disabled(config, entry): + """Check if LDAP user is disabled or not.""" + if config["ldap_is_active_directory"]: + if "userAccountControl" in entry: + value = int(force_str(entry["userAccountControl"][0])) + return value == 514 + # FIXME: is there a way to detect a disabled user with OpenLDAP? + return False + + +def import_accounts_from_ldap(config): + """Import user accounts from LDAP directory.""" + conn = get_connection(config) + result = conn.search_s( + config["ldap_import_search_base"], + ldap.SCOPE_SUBTREE, + config["ldap_import_search_filter"] + ) + admin_groups = config["ldap_admin_groups"].split(";") + for dn, entry in result: + role = "SimpleUsers" + groups = find_user_groups(conn, config, dn, entry) + for grp in admin_groups: + if grp.strip() in groups: + role = "DomainAdmins" + break + username = force_str(entry[config["ldap_import_username_attr"]][0]) + if role == "SimpleUsers": + lpart, domain = split_mailbox(username) + if domain is None: + # Try to find associated email + username = None + for attr in ["mail", "userPrincipalName"]: + if attr in entry: + username = force_str(entry[attr][0]) + break + if username is None: + print("Skipping {} because no email found".format(dn)) + continue + defaults = { + "username": username.lower(), + "is_local": False, + "language": settings.LANGUAGE_CODE + } + user, created = core_models.User.objects.get_or_create( + username__iexact=username, + defaults=defaults + ) + if created: + core_models.populate_callback(user, role) + + attr_map = { + "first_name": "givenName", + "email": "mail", + "last_name": "sn" + } + for attr, ldap_attr in attr_map.items(): + if ldap_attr in entry: + setattr(user, attr, force_str(entry[ldap_attr][0])) + user.is_active = not user_is_disabled(config, entry) + user.save() + + # FIXME: handle delete and rename operations? diff --git a/modoboa/ldapsync/management/__init__.py b/modoboa/ldapsync/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modoboa/ldapsync/management/commands/__init__.py b/modoboa/ldapsync/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modoboa/ldapsync/management/commands/import_from_ldap_directory.py b/modoboa/ldapsync/management/commands/import_from_ldap_directory.py new file mode 100644 index 000000000..4e87e7e94 --- /dev/null +++ b/modoboa/ldapsync/management/commands/import_from_ldap_directory.py @@ -0,0 +1,20 @@ +"""Command to populate local database from a remote LDAP directory.""" + +from django.core.management.base import BaseCommand + +from modoboa.parameters import tools as param_tools + +from ... import lib + + +class Command(BaseCommand): + """Command definition.""" + + help = "Populate database from remote LDAP directory" + + def handle(self, *args, **options): + """Command entry point.""" + config = dict(param_tools.get_global_parameters("core")) + if not config["ldap_enable_import"]: + return + lib.import_accounts_from_ldap(config) diff --git a/modoboa/ldapsync/tests.py b/modoboa/ldapsync/tests.py index e5a633307..293d8d27f 100644 --- a/modoboa/ldapsync/tests.py +++ b/modoboa/ldapsync/tests.py @@ -2,9 +2,11 @@ from unittest import skipIf +from django.core.management import call_command from django.utils.encoding import force_bytes, force_str from modoboa.core import factories as core_factories +from modoboa.core import models as core_models from modoboa.lib.tests import NO_LDAP, ModoTestCase from modoboa.parameters import tools as param_tools @@ -14,11 +16,11 @@ if not NO_LDAP: @skipIf(NO_LDAP, "No ldap module installed") -class LDAPSyncTestCase(ModoTestCase): +class LDAPExportTestCase(ModoTestCase): """Synchronization related tests.""" def setUp(self): - super(LDAPSyncTestCase, self).setUp() + super().setUp() self.set_global_parameters({ "ldap_enable_sync": True, "ldap_server_port": 3389, @@ -99,3 +101,31 @@ class LDAPSyncTestCase(ModoTestCase): "ldap_sync_delete_remote_account", True, app="core") user.delete() self.assertFalse(lib.check_if_dn_exists(self.conn, self.dn)) + + +@skipIf(NO_LDAP, "No ldap module installed") +class LDAPImportTestCase(ModoTestCase): + """Import related tests.""" + + def setUp(self): + super().setUp() + self.set_global_parameters({ + "ldap_enable_import": True, + "ldap_server_port": 3389, + "ldap_sync_bind_dn": "cn=admin,dc=example,dc=com", + "ldap_sync_bind_password": "test", + "ldap_import_search_base": "ou=users,dc=example,dc=com", + "ldap_import_search_filter": "(objectClass=person)", + "ldap_groups_search_base": "ou=groups,dc=example,dc=com", + "ldap_admin_groups": "admins", + }, app="core") + self.config = dict(param_tools.get_global_parameters("core")) + self.conn = lib.get_connection(self.config) + + def test_import_from_ldap(self): + """Check management command.""" + call_command("import_from_ldap_directory") + self.assertTrue(core_models.User.objects.filter( + username="testuser@example.com").exists()) + admin = core_models.User.objects.get(username="mailadmin") + self.assertEqual(admin.role, "DomainAdmins") diff --git a/test_data/base.ldif b/test_data/base.ldif index 28bfb14c6..42cb3c7cf 100644 --- a/test_data/base.ldif +++ b/test_data/base.ldif @@ -14,7 +14,7 @@ ou: users dn: cn=admins,ou=groups,dc=example,dc=com cn: admins gidnumber: 10001 -memberuid: mailadmin +memberuid: mailadmin objectclass: posixGroup objectclass: top @@ -36,6 +36,7 @@ objectClass: organizationalPerson objectClass: person objectClass: top cn: testuser +uid: testuser givenName: Test sn: User mail: testuser@example.com