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.
This commit is contained in:
Antoine Nguyen
2019-05-07 10:48:00 +02:00
committed by GitHub
parent 76109337aa
commit ab592c3d02
11 changed files with 420 additions and 23 deletions
+1 -1
View File
@@ -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
+54 -2
View File
@@ -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 <ldap_auth>`
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
********************
+81 -20
View File
@@ -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:
@@ -83,6 +83,7 @@ MODOBOA_APPS = (
'modoboa.limits',
'modoboa.parameters',
'modoboa.dnstools',
'modoboa.ldapsync',
# Modoboa extensions here.
{% for extension in extensions %} '{{ extension }}',
{% endfor %}
+5
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
default_app_config = "modoboa.ldapsync.apps.LdapsyncConfig"
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class LdapsyncConfig(AppConfig):
name = 'modoboa.ldapsync'
def ready(self):
from . import handlers # noqa
+36
View File
@@ -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)
+127
View File
@@ -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))
+105
View File
@@ -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))
+1
View File
@@ -70,6 +70,7 @@ MODOBOA_APPS = (
"modoboa.limits",
"modoboa.parameters",
"modoboa.dnstools",
"modoboa.ldapsync",
# Modoboa extensions here.
)