From 4f5f8ba3faf1f8ce3da998f860e59864417e79e3 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Mon, 17 Feb 2020 17:27:25 +0100 Subject: [PATCH 1/4] Added support for a secondary LDAP server. see #1842 --- doc/configuration.rst | 1 + modoboa/core/app_settings.py | 162 +++++++++++++++++++++-------------- modoboa/ldapsync/lib.py | 25 +++++- modoboa/lib/authbackends.py | 23 ++++- 4 files changed, 140 insertions(+), 71 deletions(-) diff --git a/doc/configuration.rst b/doc/configuration.rst index 4b1d763d8..df9bb4b5c 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -297,6 +297,7 @@ like this:: AUTHENTICATION_BACKENDS = ( 'modoboa.lib.authbackends.LDAPBackend', + # 'modoboa.lib.authbackends.LDAPSecondaryBackend', # Useful for a fallback mechanism 'django.contrib.auth.backends.ModelBackend', ) diff --git a/modoboa/core/app_settings.py b/modoboa/core/app_settings.py index 6cdc3bc86..23e795c3c 100644 --- a/modoboa/core/app_settings.py +++ b/modoboa/core/app_settings.py @@ -48,7 +48,6 @@ class GeneralParametersForm(param_forms.AdminParametersForm): if hasher().scheme in get_dovecot_schemes()], initial="sha512crypt", help_text=ugettext_lazy("Scheme used to crypt mailbox passwords"), - widget=forms.Select(attrs={"class": "form-control"}) ) rounds_number = forms.IntegerField( @@ -58,7 +57,6 @@ class GeneralParametersForm(param_forms.AdminParametersForm): "Number of rounds to use (only used by sha256crypt and " "sha512crypt). Must be between 1000 and 999999999, inclusive." ), - widget=forms.TextInput(attrs={"class": "form-control"}) ) update_scheme = YesNoField( @@ -92,14 +90,35 @@ class GeneralParametersForm(param_forms.AdminParametersForm): initial="localhost", help_text=ugettext_lazy( "The IP address or the DNS name of the LDAP server"), - widget=forms.TextInput(attrs={"class": "form-control"}) ) ldap_server_port = forms.IntegerField( label=ugettext_lazy("Server port"), initial=389, help_text=ugettext_lazy("The TCP port number used by the LDAP server"), - widget=forms.TextInput(attrs={"class": "form-control"}) + ) + + ldap_enable_secondary_server = YesNoField( + label=ugettext_lazy("Enable secondary server (fallback)"), + initial=False, + help_text=ugettext_lazy( + "Enable a secondary LDAP server which will be used " + "if the primary one fails" + ) + ) + + ldap_secondary_server_address = forms.CharField( + label=ugettext_lazy("Secondary server address"), + initial="localhost", + help_text=ugettext_lazy( + "The IP address or the DNS name of the seondary LDAP server"), + ) + + ldap_secondary_server_port = forms.IntegerField( + label=ugettext_lazy("Secondary server port"), + initial=389, + help_text=ugettext_lazy( + "The TCP port number used by the LDAP secondary server"), ) ldap_secured = forms.ChoiceField( @@ -127,7 +146,6 @@ class GeneralParametersForm(param_forms.AdminParametersForm): ("directbind", ugettext_lazy("Direct bind"))], initial="searchbind", help_text=ugettext_lazy("Choose the authentication method to use"), - widget=forms.Select(attrs={"class": "form-control"}) ) ldap_bind_dn = forms.CharField( @@ -138,7 +156,6 @@ class GeneralParametersForm(param_forms.AdminParametersForm): "Leave empty for an anonymous bind" ), required=False, - widget=forms.TextInput(attrs={"class": "form-control"}) ) ldap_bind_password = forms.CharField( @@ -148,8 +165,7 @@ class GeneralParametersForm(param_forms.AdminParametersForm): "The password to use when binding to the LDAP server " "(with 'Bind DN')" ), - widget=forms.PasswordInput( - attrs={"class": "form-control"}, render_value=True), + widget=forms.PasswordInput(render_value=True), required=False ) @@ -160,7 +176,6 @@ class GeneralParametersForm(param_forms.AdminParametersForm): "The distinguished name of the search base used to find users" ), required=False, - widget=forms.TextInput(attrs={"class": "form-control"}) ) ldap_search_filter = forms.CharField( @@ -171,7 +186,6 @@ class GeneralParametersForm(param_forms.AdminParametersForm): "In order to be valid, it must be enclosed in parentheses." ), required=False, - widget=forms.TextInput(attrs={"class": "form-control"}) ) ldap_user_dn_template = forms.CharField( @@ -182,7 +196,6 @@ class GeneralParametersForm(param_forms.AdminParametersForm): "one placeholder (ie. %(user)s)" ), required=False, - widget=forms.TextInput(attrs={"class": "form-control"}) ) ldap_admin_groups = forms.CharField( @@ -271,7 +284,6 @@ class GeneralParametersForm(param_forms.AdminParametersForm): 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")) @@ -350,7 +362,6 @@ class GeneralParametersForm(param_forms.AdminParametersForm): "An account with a last login date greater than this threshold " "(in days) will be considered as inactive" ), - widget=forms.TextInput(attrs={"class": "form-control"}) ) top_notifications_check_interval = forms.IntegerField( @@ -359,21 +370,18 @@ class GeneralParametersForm(param_forms.AdminParametersForm): help_text=_( "Interval between two top notification checks (in seconds)" ), - widget=forms.TextInput(attrs={"class": "form-control"}) ) log_maximum_age = forms.IntegerField( label=ugettext_lazy("Maximum log record age"), initial=365, help_text=ugettext_lazy("The maximum age in days of a log record"), - widget=forms.TextInput(attrs={"class": "form-control"}) ) items_per_page = forms.IntegerField( label=ugettext_lazy("Items per page"), initial=30, help_text=ugettext_lazy("Number of displayed items per page"), - widget=forms.TextInput(attrs={"class": "form-control"}) ) default_top_redirection = forms.ChoiceField( @@ -383,11 +391,12 @@ class GeneralParametersForm(param_forms.AdminParametersForm): help_text=ugettext_lazy( "The default redirection used when no application is specified" ), - widget=forms.Select(attrs={"class": "form-control"}) ) # Visibility rules visibility_rules = { + "ldap_secondary_server_address": "ldap_enable_secondary_server=True", + "ldap_secondary_server_port": "ldap_enable_secondary_server=True", "ldap_auth_sep": "authentication_type=ldap", "ldap_auth_method": "authentication_type=ldap", "ldap_bind_dn": "ldap_auth_method=searchbind", @@ -458,6 +467,68 @@ class GeneralParametersForm(param_forms.AdminParametersForm): return cleaned_data + def _apply_ldap_settings(self, values, backend): + """Apply configuration for given backend.""" + import ldap + from django_auth_ldap.config import ( + LDAPSearch, PosixGroupType, GroupOfNamesType) + + if not hasattr(settings, backend.setting_fullname("USER_ATTR_MAP")): + setattr(settings, backend.setting_fullname("USER_ATTR_MAP"), { + "first_name": "givenName", + "email": "mail", + "last_name": "sn" + }) + ldap_uri = "ldaps://" if values["ldap_secured"] == "ssl" else "ldap://" + ldap_uri += "%s:%s" % ( + values[backend.srv_address_setting_name], + values[backend.srv_port_setting_name] + ) + setattr(settings, backend.setting_fullname("SERVER_URI"), ldap_uri) + if values["ldap_secured"] == "starttls": + setattr(settings, backend.setting_fullname("START_TLS"), True) + + if values["ldap_group_type"] == "groupofnames": + setattr(settings, backend.setting_fullname("GROUP_TYPE"), + GroupOfNamesType()) + searchfilter = "(objectClass=groupOfNames)" + else: + setattr(settings, backend.setting_fullname("GROUP_TYPE"), + PosixGroupType()) + searchfilter = "(objectClass=posixGroup)" + setattr(settings, backend.setting_fullname("GROUP_SEARCH"), LDAPSearch( + values["ldap_groups_search_base"], ldap.SCOPE_SUBTREE, + searchfilter + )) + if values["ldap_auth_method"] == "searchbind": + setattr(settings, backend.setting_fullname("BIND_DN"), + values["ldap_bind_dn"]) + setattr( + settings, backend.setting_fullname("BIND_PASSWORD"), + values["ldap_bind_password"] + ) + search = LDAPSearch( + values["ldap_search_base"], ldap.SCOPE_SUBTREE, + values["ldap_search_filter"] + ) + setattr(settings, backend.setting_fullname("USER_SEARCH"), search) + else: + setattr( + settings, backend.setting_fullname("USER_DN_TEMPLATE"), + values["ldap_user_dn_template"] + ) + setattr( + settings, + backend.setting_fullname("BIND_AS_AUTHENTICATING_USER"), True) + if values["ldap_is_active_directory"]: + setting = backend.setting_fullname("GLOBAL_OPTIONS") + if not hasattr(settings, setting): + setattr(settings, setting, { + ldap.OPT_REFERRALS: False + }) + else: + getattr(settings, setting)[ldap.OPT_REFERRALS] = False + def to_django_settings(self): """Apply LDAP related parameters to Django settings. @@ -465,8 +536,6 @@ class GeneralParametersForm(param_forms.AdminParametersForm): """ try: import ldap - from django_auth_ldap.config import ( - LDAPSearch, PosixGroupType, GroupOfNamesType) ldap_available = True except ImportError: ldap_available = False @@ -474,51 +543,12 @@ class GeneralParametersForm(param_forms.AdminParametersForm): values = dict(param_tools.get_global_parameters("core")) if not ldap_available or values["authentication_type"] != "ldap": return - if not hasattr(settings, "AUTH_LDAP_USER_ATTR_MAP"): - setattr(settings, "AUTH_LDAP_USER_ATTR_MAP", { - "first_name": "givenName", - "email": "mail", - "last_name": "sn" - }) - ldap_uri = "ldaps://" if values["ldap_secured"] == "ssl" else "ldap://" - ldap_uri += "%s:%s" % ( - values["ldap_server_address"], values["ldap_server_port"]) - setattr(settings, "AUTH_LDAP_SERVER_URI", ldap_uri) - if values["ldap_secured"] == "starttls": - setattr(settings, "AUTH_LDAP_START_TLS", True) - if values["ldap_group_type"] == "groupofnames": - setattr(settings, "AUTH_LDAP_GROUP_TYPE", GroupOfNamesType()) - searchfilter = "(objectClass=groupOfNames)" - else: - setattr(settings, "AUTH_LDAP_GROUP_TYPE", PosixGroupType()) - searchfilter = "(objectClass=posixGroup)" - setattr(settings, "AUTH_LDAP_GROUP_SEARCH", LDAPSearch( - values["ldap_groups_search_base"], ldap.SCOPE_SUBTREE, - searchfilter - )) - if values["ldap_auth_method"] == "searchbind": - setattr(settings, "AUTH_LDAP_BIND_DN", values["ldap_bind_dn"]) - setattr( - settings, "AUTH_LDAP_BIND_PASSWORD", - values["ldap_bind_password"] - ) - search = LDAPSearch( - values["ldap_search_base"], ldap.SCOPE_SUBTREE, - values["ldap_search_filter"] - ) - setattr(settings, "AUTH_LDAP_USER_SEARCH", search) - else: - setattr( - settings, "AUTH_LDAP_USER_DN_TEMPLATE", - values["ldap_user_dn_template"] - ) - setattr( - settings, "AUTH_LDAP_BIND_AS_AUTHENTICATING_USER", True) - if values["ldap_is_active_directory"]: - if not hasattr(settings, "AUTH_LDAP_GLOBAL_OPTIONS"): - setattr(settings, "AUTH_LDAP_GLOBAL_OPTIONS", { - ldap.OPT_REFERRALS: False - }) - else: - settings.AUTH_LDAP_GLOBAL_OPTIONS[ldap.OPT_REFERRALS] = False + from modoboa.lib.authbackends import LDAPBackend + self._apply_ldap_settings(values, LDAPBackend) + + if not values["ldap_enable_secondary_server"]: + return + + from modoboa.lib.authbackends import LDAPSecondaryBackend + self._apply_ldap_settings(values, LDAPSecondaryBackend) diff --git a/modoboa/ldapsync/lib.py b/modoboa/ldapsync/lib.py index fd8a345c1..d29879e8a 100644 --- a/modoboa/ldapsync/lib.py +++ b/modoboa/ldapsync/lib.py @@ -9,10 +9,9 @@ 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"]) +def create_connection(srv_address, srv_port, config, username, password): + """Create a new connection with given server.""" + uri = "{}:{}".format(srv_address, srv_port) uri = "{}://{}".format( "ldaps" if config["ldap_secured"] == "ssl" else "ldap", uri) conn = ldap.initialize(uri) @@ -25,6 +24,24 @@ def get_connection(config, username=None, password=None): return conn +def get_connection(config, username=None, password=None): + """Get a new connection to the LDAP directory.""" + try: + conn = create_connection( + config["ldap_server_address"], config["ldap_server_port"], + config, username, password + ) + except ldap.LDAPError: + if not config["ldap_enable_secondary_server"]: + raise + conn = create_connection( + config["ldap_secondary_server_address"], + config["ldap_secondary_server_port"], + config, username, password + ) + return conn + + def get_user_password(user, disable=False): """Return ready-to-use password from user instance.""" scheme, password = user.password.split("}") diff --git a/modoboa/lib/authbackends.py b/modoboa/lib/authbackends.py index 5d01859d2..7925e11e4 100644 --- a/modoboa/lib/authbackends.py +++ b/modoboa/lib/authbackends.py @@ -62,7 +62,7 @@ try: LDAPBackend as orig_LDAPBackend, _LDAPUser ) - class LDAPBackend(orig_LDAPBackend): + class LDAPBackendBase(orig_LDAPBackend): def __init__(self, *args, **kwargs): """Load LDAP settings.""" @@ -122,5 +122,26 @@ try: return super(LDAPBackend, self).authenticate(*args, **kwargs) return None + def setting_fullname(self, setting): + """Return fullname for given setting.""" + return "{}{}".format(self.setting, self.settings_prefix) + + + class LDAPBackend(LDAPBackendBase): + """Primary LDAP backend.""" + + settings_prefix = "AUTH_LDAP_" + srv_address_setting_name = "ldap_server_address" + srv_port_setting_name = "ldap_server_port" + + + class LDAPSecondaryBackend(LDAPBackendBase): + """Secondary LDAP backend.""" + + settings_prefix = "AUTH_LDAP_2_" + srv_address_setting_name = "ldap_secondary_server_address" + srv_port_setting_name = "ldap_secondary_server_port" + + except ImportError: pass From cfa3c0bee54aeb64269ad6ab86fb868477fd47fe Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Mon, 17 Feb 2020 17:42:54 +0100 Subject: [PATCH 2/4] Fixed loop. --- modoboa/lib/authbackends.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modoboa/lib/authbackends.py b/modoboa/lib/authbackends.py index 7925e11e4..04d649be9 100644 --- a/modoboa/lib/authbackends.py +++ b/modoboa/lib/authbackends.py @@ -67,7 +67,7 @@ try: def __init__(self, *args, **kwargs): """Load LDAP settings.""" param_tools.apply_to_django_settings() - super(LDAPBackend, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.global_params = dict( param_tools.get_global_parameters("core")) @@ -119,7 +119,7 @@ try: def authenticate(self, *args, **kwargs): if self.global_params["authentication_type"] == "ldap": - return super(LDAPBackend, self).authenticate(*args, **kwargs) + return super().authenticate(*args, **kwargs) return None def setting_fullname(self, setting): From 59dfecd801280661cb50a619a53cc78979ee0db3 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Mon, 17 Feb 2020 17:52:10 +0100 Subject: [PATCH 3/4] Use a classmethod here. --- modoboa/lib/authbackends.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modoboa/lib/authbackends.py b/modoboa/lib/authbackends.py index 04d649be9..bbe5f0308 100644 --- a/modoboa/lib/authbackends.py +++ b/modoboa/lib/authbackends.py @@ -122,9 +122,10 @@ try: return super().authenticate(*args, **kwargs) return None - def setting_fullname(self, setting): + @classmethod + def setting_fullname(cls, setting): """Return fullname for given setting.""" - return "{}{}".format(self.setting, self.settings_prefix) + return "{}{}".format(cls.settings_prefix, setting) class LDAPBackend(LDAPBackendBase): From a9b56dde07dbe3c293c8ed8439662a1b901d6097 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Mon, 17 Feb 2020 18:03:27 +0100 Subject: [PATCH 4/4] Fixed tests. --- modoboa/core/tests/test_views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modoboa/core/tests/test_views.py b/modoboa/core/tests/test_views.py index f011ef876..45905d3b6 100644 --- a/modoboa/core/tests/test_views.py +++ b/modoboa/core/tests/test_views.py @@ -76,7 +76,10 @@ SETTINGS_SAMPLE = { "core-ldap_sync_account_dn_template": "", "core-ldap_sync_delete_remote_account": False, "core-send_new_versions_email": False, - "core-new_versions_email_rcpt": "postmaster@domain.test" + "core-new_versions_email_rcpt": "postmaster@domain.test", + "core-ldap_enable_secondary_server": False, + "core-ldap_secondary_server_address": "localhost", + "core-ldap_secondary_server_port": 389, }