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
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))