mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
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:
+1
-1
@@ -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
@@ -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
|
||||
********************
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
default_app_config = "modoboa.ldapsync.apps.LdapsyncConfig"
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LdapsyncConfig(AppConfig):
|
||||
name = 'modoboa.ldapsync'
|
||||
|
||||
def ready(self):
|
||||
from . import handlers # noqa
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -70,6 +70,7 @@ MODOBOA_APPS = (
|
||||
"modoboa.limits",
|
||||
"modoboa.parameters",
|
||||
"modoboa.dnstools",
|
||||
"modoboa.ldapsync",
|
||||
# Modoboa extensions here.
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user