mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
New management command to import LDAP accounts. (#1845)
* New management command to import LDAP accounts. see #1844
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user