From ab592c3d02002391450c19a0df9e6ea9a1b916a8 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Tue, 7 May 2019 10:48:00 +0200 Subject: [PATCH] LDAP synchronization process (#1732) * Initial commit for LDAP sync process. * Fixed unit tests. * Added doc and run ldapsync tests on travis. * Removed server address. * Force bytes when calling ldap methods. * Fixed tests compat with python2. * Do not force bytes here. * Force bytes. * Force bytes for attribute names too. * Disable bytes mode in PY2. * Handle account removals. * Fixed unit tests. * Limit sync to simple users. * Fixed unit test. * Fixed unit test. --- .travis.yml | 2 +- doc/configuration.rst | 56 +++++++- modoboa/core/app_settings.py | 101 +++++++++++--- .../core/commands/templates/settings.py.tpl | 1 + modoboa/core/tests/test_views.py | 5 + modoboa/ldapsync/__init__.py | 1 + modoboa/ldapsync/apps.py | 8 ++ modoboa/ldapsync/handlers.py | 36 +++++ modoboa/ldapsync/lib.py | 127 ++++++++++++++++++ modoboa/ldapsync/tests.py | 105 +++++++++++++++ test_project/test_project/settings.py | 1 + 11 files changed, 420 insertions(+), 23 deletions(-) create mode 100644 modoboa/ldapsync/__init__.py create mode 100644 modoboa/ldapsync/apps.py create mode 100644 modoboa/ldapsync/handlers.py create mode 100644 modoboa/ldapsync/lib.py create mode 100644 modoboa/ldapsync/tests.py diff --git a/.travis.yml b/.travis.yml index 2464a8b36..f3ef06c2a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -50,7 +50,7 @@ before_script: script: - python ./tests.py - cd test_project - - coverage run --source ../modoboa manage.py test modoboa.core modoboa.lib modoboa.admin modoboa.limits modoboa.transport modoboa.relaydomains modoboa.dnstools + - coverage run --source ../modoboa manage.py test modoboa.core modoboa.lib modoboa.admin modoboa.limits modoboa.transport modoboa.relaydomains modoboa.dnstools modoboa.ldapsync after_success: - coverage xml diff --git a/doc/configuration.rst b/doc/configuration.rst index 996c52559..07fd75767 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -14,7 +14,7 @@ are two available levels: * User level: per user customization. Available at *User > Settings > Preferences* - + Regardless level, parameters are displayed using tabs, each tab corresponding to one application. @@ -405,7 +405,7 @@ group. In this case, the username is not necessarily an email address. Users will also be able to update their LDAP password directly from Modoboa. -.. note:: +.. note:: Modoboa doesn't provide any synchronization mechanism once a user is registered into the database. Any modification done from the @@ -438,6 +438,58 @@ SMTP server location can be customized using the following settings: AUTH_SMTP_SERVER_PORT = 25 AUTH_SMTP_SECURED_MODE = None # 'ssl' or 'starttls' are accepted +******************** +LDAP synchronization +******************** + +Modoboa can synchronize accounts with an LDAP directory (tested with +OpenLDAP) but this feature is not enabled by default. To activate it, +add ``modoboa.ldapsync`` to ``MODOBOA_APPS`` in the +:file:`settings.py` file:: + + MODOBOA_APPS = ( + 'modoboa', + 'modoboa.core', + 'modoboa.lib', + 'modoboa.admin', + 'modoboa.transport', + 'modoboa.relaydomains', + 'modoboa.limits', + 'modoboa.parameters', + 'modoboa.dnstools', + 'modoboa.ldapsync', + ) + +and enable it from the admin panel. + +.. warning:: + + Make sure to install additional :ref:`requirements ` + otherwise it won't work. + +The following parameters are available and must be filled:: + ++--------------------+---------------------------------+--------------------+ +|Name |Description |Default value | ++====================+=================================+====================+ +|Enable LDAP |Control LDAP synchronization |no | +|synchronization |state | | +| | | | ++--------------------+---------------------------------+--------------------+ +|Bind DN |The DN of a user with write | | +| |permission to create/update | | +| |accounts | | ++--------------------+---------------------------------+--------------------+ +|Bind password |The associated password | | +| | | | +| | | | ++--------------------+---------------------------------+--------------------+ +|Account DN template |The template used to build | | +| |account DNs (must contain a | | +| |%(user)s placeholder | | ++--------------------+---------------------------------+--------------------+ + + ******************** Database maintenance ******************** diff --git a/modoboa/core/app_settings.py b/modoboa/core/app_settings.py index 0cf69528a..f5967de83 100644 --- a/modoboa/core/app_settings.py +++ b/modoboa/core/app_settings.py @@ -114,6 +114,17 @@ class GeneralParametersForm(param_forms.AdminParametersForm): "Use an SSL/STARTTLS connection to access the LDAP server") ) + ldap_is_active_directory = YesNoField( + label=ugettext_lazy("Active Directory"), + initial=False, + help_text=ugettext_lazy( + "Tell if the LDAP server is an Active Directory one") + ) + + # LDAP authentication settings + ldap_auth_sep = SeparatorField( + label=ugettext_lazy("LDAP authentication settings")) + ldap_auth_method = forms.ChoiceField( label=ugettext_lazy("Authentication method"), choices=[("searchbind", ugettext_lazy("Search and bind")), @@ -178,20 +189,6 @@ class GeneralParametersForm(param_forms.AdminParametersForm): widget=forms.TextInput(attrs={"class": "form-control"}) ) - ldap_password_attribute = forms.CharField( - label=ugettext_lazy("Password attribute"), - initial="userPassword", - help_text=ugettext_lazy("The attribute used to store user passwords"), - widget=forms.TextInput(attrs={"class": "form-control"}) - ) - - ldap_is_active_directory = YesNoField( - label=ugettext_lazy("Active Directory"), - initial=False, - help_text=ugettext_lazy( - "Tell if the LDAP server is an Active Directory one") - ) - ldap_admin_groups = forms.CharField( label=ugettext_lazy("Administrator groups"), initial="", @@ -220,6 +217,67 @@ class GeneralParametersForm(param_forms.AdminParametersForm): 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="", + help_text=ugettext_lazy( + "The distinguished name to use when binding to the LDAP server. " + "Leave empty for an anonymous bind" + ), + required=False, + ) + + ldap_sync_bind_password = forms.CharField( + label=ugettext_lazy("Bind password"), + initial="", + help_text=ugettext_lazy( + "The password to use when binding to the LDAP server " + "(with 'Bind DN')" + ), + widget=forms.PasswordInput(render_value=True), + required=False + ) + + ldap_sync_account_dn_template = forms.CharField( + label=ugettext_lazy("Account DN template"), + initial="", + help_text=ugettext_lazy( + "The template used to construct an account's DN. It should contain " + "one placeholder (ie. %(user)s)" + ), + 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"), + widget=forms.TextInput(attrs={"class": "form-control"}) + ) + dash_sep = SeparatorField(label=ugettext_lazy("Dashboard")) rss_feed_url = forms.URLField( @@ -319,18 +377,13 @@ class GeneralParametersForm(param_forms.AdminParametersForm): # Visibility rules visibility_rules = { - "ldap_sep": "authentication_type=ldap", - "ldap_server_address": "authentication_type=ldap", - "ldap_server_port": "authentication_type=ldap", - "ldap_secured": "authentication_type=ldap", + "ldap_auth_sep": "authentication_type=ldap", "ldap_auth_method": "authentication_type=ldap", "ldap_bind_dn": "ldap_auth_method=searchbind", "ldap_bind_password": "ldap_auth_method=searchbind", "ldap_search_base": "ldap_auth_method=searchbind", "ldap_search_filter": "ldap_auth_method=searchbind", "ldap_user_dn_template": "ldap_auth_method=directbind", - "ldap_password_attribute": "authentication_type=ldap", - "ldap_is_active_directory": "authentication_type=ldap", "ldap_admin_groups": "authentication_type=ldap", "ldap_group_type": "authentication_type=ldap", "ldap_groups_search_base": "authentication_type=ldap", @@ -350,6 +403,14 @@ class GeneralParametersForm(param_forms.AdminParametersForm): raise forms.ValidationError(_("Invalid syntax")) return tpl + def clean_ldap_sync_account_dn_template(self): + tpl = self.cleaned_data["ldap_sync_account_dn_template"] + try: + tpl % {"user": "toto"} + except (KeyError, ValueError): + raise forms.ValidationError(_("Invalid syntax")) + return tpl + def clean_rounds_number(self): value = self.cleaned_data["rounds_number"] if value < 1000 or value > 999999999: diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index cfd0fc5b7..d422d20e2 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -83,6 +83,7 @@ MODOBOA_APPS = ( 'modoboa.limits', 'modoboa.parameters', 'modoboa.dnstools', + 'modoboa.ldapsync', # Modoboa extensions here. {% for extension in extensions %} '{{ extension }}', {% endfor %} diff --git a/modoboa/core/tests/test_views.py b/modoboa/core/tests/test_views.py index 07c9606c3..498892c99 100644 --- a/modoboa/core/tests/test_views.py +++ b/modoboa/core/tests/test_views.py @@ -74,6 +74,11 @@ SETTINGS_SAMPLE = { "admin-enable_dkim_checks": True, "admin-enable_dmarc_checks": True, "admin-enable_autoconfig_checks": True, + "core-ldap_enable_sync": False, + "core-ldap_sync_bind_dn": "", + "core-ldap_sync_bind_password": "", + "core-ldap_sync_account_dn_template": "", + "core-ldap_sync_delete_remote_account": False } diff --git a/modoboa/ldapsync/__init__.py b/modoboa/ldapsync/__init__.py new file mode 100644 index 000000000..e0abb2254 --- /dev/null +++ b/modoboa/ldapsync/__init__.py @@ -0,0 +1 @@ +default_app_config = "modoboa.ldapsync.apps.LdapsyncConfig" diff --git a/modoboa/ldapsync/apps.py b/modoboa/ldapsync/apps.py new file mode 100644 index 000000000..4ccad8839 --- /dev/null +++ b/modoboa/ldapsync/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class LdapsyncConfig(AppConfig): + name = 'modoboa.ldapsync' + + def ready(self): + from . import handlers # noqa diff --git a/modoboa/ldapsync/handlers.py b/modoboa/ldapsync/handlers.py new file mode 100644 index 000000000..6cebc3844 --- /dev/null +++ b/modoboa/ldapsync/handlers.py @@ -0,0 +1,36 @@ +"""LDAP sync related handlers.""" + +from django.db.models import signals +from django.dispatch import receiver + +from modoboa.core import models as core_models +from modoboa.parameters import tools as param_tools + +from . import lib + + +@receiver(signals.post_save, sender=core_models.User) +def sync_ldap_account(sender, instance, created, **kwargs): + """Create/modify a new LDAP account if needed.""" + config = dict(param_tools.get_global_parameters("core")) + if not config["ldap_enable_sync"]: + return + if created: + return + if instance.role != "SimpleUsers": + return + update_fields = kwargs.get("update_fields") + if update_fields and "last_login" in update_fields: + return + lib.update_ldap_account(instance, config) + + +@receiver(signals.pre_delete, sender=core_models.User) +def delete_ldap_account(sender, instance, **kwargs): + """Delete LDAP account if needed.""" + config = dict(param_tools.get_global_parameters("core")) + if not config["ldap_enable_sync"]: + return + if instance.role != "SimpleUsers": + return + lib.delete_ldap_account(instance, config) diff --git a/modoboa/ldapsync/lib.py b/modoboa/ldapsync/lib.py new file mode 100644 index 000000000..099b7de29 --- /dev/null +++ b/modoboa/ldapsync/lib.py @@ -0,0 +1,127 @@ +"""LDAP related functions.""" + +from __future__ import unicode_literals + +import ldap +import ldap.modlist as modlist + +from django.utils.encoding import force_bytes, force_str +from django.utils.translation import ugettext as _ + +from modoboa.lib.exceptions import InternalError + + +def get_connection(config, username=None, password=None): + """Get a new connection to the LDAP directory.""" + uri = "{}:{}".format( + config["ldap_server_address"], config["ldap_server_port"]) + uri = "{}://{}".format( + "ldaps" if config["ldap_secured"] == "ssl" else "ldap", uri) + conn = ldap.initialize(uri) + conn.set_option(ldap.OPT_X_TLS_DEMAND, True) + conn.set_option(ldap.OPT_DEBUG_LEVEL, 255) + 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"]) + ) + return conn + + +def get_user_password(user, disable=False): + """Return ready-to-use password from user instance.""" + scheme, password = user.password.split("}") + return ( + force_bytes(scheme) + + b"}" + + b"#" if disable else b"" + + force_bytes(password) + ) + + +def create_ldap_account(user, dn, conn): + """Create new account.""" + scheme, password = user.password.split("}") + attrs = { + "objectClass": [ + force_bytes("inetOrgPerson"), force_bytes("organizationalPerson") + ], + "uid": [force_bytes(user.username)], + "sn": [force_bytes(user.last_name)], + "givenName": [force_bytes(user.first_name)], + "cn": [force_bytes(user.username)], + "displayName": [force_bytes(user.fullname)], + "mail": [ + force_bytes(user.email), force_bytes(user.secondary_email)], + "homePhone": [force_bytes(user.phone_number)], + "userPassword": [get_user_password(user)] + } + ldif = modlist.addModlist(attrs) + try: + conn.add_s(dn, ldif) + except ldap.LDAPError as e: + raise InternalError( + _("Failed to create LDAP account: {}").format(e) + ) + + +def check_if_dn_exists(conn, dn): + """Check if DN already exists in directory.""" + try: + res = conn.search_s( + force_str(dn), ldap.SCOPE_SUBTREE, + force_str("(&(objectClass=inetOrgPerson))") + ) + res = res[0][0] + except ldap.LDAPError: + return False + return True + + +def update_ldap_account(user, config): + """Update existing account.""" + dn = config["ldap_sync_account_dn_template"] % {"user": user.username} + conn = get_connection(config) + if not check_if_dn_exists(conn, dn): + create_ldap_account(user, dn, conn) + return + password = get_user_password(user, not user.is_active) + ldif = [ + (ldap.MOD_REPLACE, "uid", force_bytes(user.username)), + (ldap.MOD_REPLACE, "sn", force_bytes(user.last_name)), + (ldap.MOD_REPLACE, "givenName", force_bytes(user.first_name)), + (ldap.MOD_REPLACE, "cn", force_bytes(user.username)), + (ldap.MOD_REPLACE, "displayName", force_bytes(user.fullname)), + (ldap.MOD_REPLACE, "mail", force_bytes(user.email)), + (ldap.MOD_REPLACE, "homePhone", force_bytes(user.phone_number)), + (ldap.MOD_REPLACE, "userPassword", password) + ] + try: + conn.modify_s(dn, ldif) + except ldap.LDAPError as e: + raise InternalError( + _("Failed to update LDAP account: {}").format(e)) + + +def delete_ldap_account(user, config): + """Delete remote LDAP account.""" + dn = config["ldap_sync_account_dn_template"] % {"user": user.username} + conn = get_connection(config) + if not check_if_dn_exists(conn, dn): + return + if config["ldap_sync_delete_remote_account"]: + try: + conn.delete_s(dn) + except ldap.LDAPError as e: + raise InternalError( + _("Failed to delete LDAP account: {}").format(e) + ) + else: + password = get_user_password(user, True) + ldif = [ + (ldap.MOD_REPLACE, "userPassword", password) + ] + try: + conn.modify_s(dn, ldif) + except ldap.LDAPError as e: + raise InternalError( + _("Failed to disable LDAP account: {}").format(e)) diff --git a/modoboa/ldapsync/tests.py b/modoboa/ldapsync/tests.py new file mode 100644 index 000000000..4d42c983b --- /dev/null +++ b/modoboa/ldapsync/tests.py @@ -0,0 +1,105 @@ +"""LDAP sync. related tests.""" + +from __future__ import unicode_literals + +from unittest import skipIf + +import ldap + +from django.utils import six +from django.utils.encoding import force_bytes, force_str + +from modoboa.core import factories as core_factories +from modoboa.lib.tests import NO_LDAP, ModoTestCase +from modoboa.parameters import tools as param_tools + +from . import lib + + +@skipIf(NO_LDAP, "No ldap module installed") +class LDAPSyncTestCase(ModoTestCase): + """Synchronization related tests.""" + + def setUp(self): + super(LDAPSyncTestCase, self).setUp() + self.set_global_parameters({ + "ldap_enable_sync": True, + "ldap_server_port": 3389, + "ldap_sync_bind_dn": "cn=admin,dc=example,dc=com", + "ldap_sync_bind_password": "test", + "ldap_sync_account_dn_template": ( + "cn=%(user)s,ou=users,dc=example,dc=com"), + }, app="core") + self.config = dict(param_tools.get_global_parameters("core")) + self.conn = lib.get_connection(self.config) + self.username = "testldap@test.com" + self.dn = self.config["ldap_sync_account_dn_template"] % { + "user": self.username} + + def reset_ldap_directory(self): + try: + tmp_dn = force_bytes(self.dn) if six.PY2 else self.dn + self.conn.delete_s(tmp_dn) + except ldap.NO_SUCH_OBJECT: + pass + + def test_sync_user(self): + self.reset_ldap_directory() + user = core_factories.UserFactory( + username=self.username, + first_name="Test", + last_name="LDAP", + groups=("SimpleUsers",) + ) + self.assertTrue(lib.check_if_dn_exists(self.conn, self.dn)) + + lib.get_connection(self.config, self.dn, "toto") + + user.last_name = "LDAP Modif" + user.save() + lib.get_connection(self.config, self.dn, "toto") + + res = self.conn.search_s( + force_str(self.dn), ldap.SCOPE_SUBTREE, + force_str("(&(objectClass=inetOrgPerson))") + ) + self.assertIn(force_bytes(user.last_name), res[0][1]["sn"]) + + def test_sync_domainadmin(self): + self.reset_ldap_directory() + core_factories.UserFactory( + username=self.username, + first_name="Test", + last_name="LDAP", + groups=("DomainAdmins", ) + ) + self.assertFalse(lib.check_if_dn_exists(self.conn, self.dn)) + + def test_delete_user(self): + self.reset_ldap_directory() + user = core_factories.UserFactory( + username=self.username, + first_name="Test", + last_name="LDAP", + groups=("SimpleUsers",) + ) + user.delete() + ldap_record = self.conn.search_s( + force_str(self.dn), ldap.SCOPE_SUBTREE, + force_str("(&(objectClass=inetOrgPerson))") + ) + password = ldap_record[0][1]["userPassword"][0].split(b"}")[1] + self.assertTrue(password.startswith(b"#")) + with self.assertRaises(ldap.INVALID_CREDENTIALS): + lib.get_connection(self.config, self.dn, "toto") + + user = core_factories.UserFactory( + username=self.username, + first_name="Test", + last_name="LDAP", + groups=("SimpleUsers",) + ) + self.set_global_parameter( + "ldap_sync_delete_remote_account", True, app="core") + user.delete() + self.assertFalse(lib.check_if_dn_exists(self.conn, self.dn)) diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 3c20ea65a..7298d4b7b 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -70,6 +70,7 @@ MODOBOA_APPS = ( "modoboa.limits", "modoboa.parameters", "modoboa.dnstools", + "modoboa.ldapsync", # Modoboa extensions here. )