New management command to import LDAP accounts. (#1845)

* New management command to import LDAP accounts.

see #1844
This commit is contained in:
Antoine Nguyen
2020-02-24 09:14:03 +01:00
committed by GitHub
parent 7cad5ef629
commit a086a1b81c
8 changed files with 244 additions and 54 deletions
+92 -51
View File
@@ -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",
+4
View File
@@ -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"
}
+94
View File
@@ -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)
+32 -2
View File
@@ -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")
+1
View File
@@ -36,6 +36,7 @@ objectClass: organizationalPerson
objectClass: person
objectClass: top
cn: testuser
uid: testuser
givenName: Test
sn: User
mail: testuser@example.com