diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index ec41848a..632c477a 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -987,7 +987,8 @@ Default: `Radicale - Password Required` _(>= 3.3.0)_ -The URI to the ldap server +URI to the LDAP server. +Mandatory for auth type `ldap`. Default: `ldap://localhost` @@ -995,39 +996,44 @@ Default: `ldap://localhost` _(>= 3.3.0)_ -LDAP base DN of the ldap server. This parameter must be provided if auth type is ldap. +Base DN of the LDAP server. +Mandatory for auth type `ldap`. -Default: +Default: (unset) ##### ldap_reader_dn _(>= 3.3.0)_ -The DN of a ldap user with read access to get the user accounts. This parameter must be provided if auth type is ldap. +DN of a LDAP user with read access users and - if defined - groups. +Mandatory for auth type `ldap`. -Default: +Default: (unset) ##### ldap_secret _(>= 3.3.0)_ -The password of the ldap_reader_dn. Either this parameter or `ldap_secret_file` must be provided if auth type is ldap. +Password of `ldap_reader_dn`. +Mandatory for auth type `ldap` unless `ldap_secret_file` is given. -Default: +Default: (unset) ##### ldap_secret_file _(>= 3.3.0)_ -Path of the file containing the password of the ldap_reader_dn. Either this parameter or `ldap_secret` must be provided if auth type is ldap. +Path to the file containing the password of `ldap_reader_dn`. +Mandatory for auth type `ldap` unless `ldap_secret` is given. -Default: +Default: (unset) ##### ldap_filter _(>= 3.3.0)_ -The search filter to find the user DN to authenticate by the username. User '{0}' as placeholder for the user name. +Filter to search for the LDAP entry of the user to authenticate. +It must contain '{0}' as placeholder for the login name. Default: `(cn={0})` @@ -1035,66 +1041,117 @@ Default: `(cn={0})` _(>= 3.4.0)_ -The LDAP attribute whose value shall be used as the user name after successful authentication +LDAP attribute whose value shall be used as the username after successful authentication. -Default: not set, i.e. the login name given is used directly. +If set, you can use flexible logins in `ldap_filter` and still have consolidated usernames, +e.g. to allow login in using mail addresses as an alternative to cn, simply set +``` +ldap_filter = (&(objectclass=inetOrgPerson)(|(cn={0})(mail={0}))) +ldap_user_attribute = cn +``` +Even for simple filter setups, it is recommended to set it in order to get usernames exactly +as they are stored in LDAP and to avoid inconsistencies in the upper-/lower-case spelling of the +login names. -##### ldap_groups_attribute - -_(>= 3.4.0)_ - -The LDAP attribute to read the group memberships from in the authenticated user's LDAP entry. - -If set, load the LDAP group memberships from the attribute given -These memberships can be used later on to define rights. -This also gives you access to the group calendars, if they exist. -* The group calendar will be placed under collection_root_folder/GROUPS -* The name of the calendar directory is the base64 encoded group name. -* The group calendar folders will not be created automatically. This must be done manually. In the [LDAP-authentication section of Radicale's wiki](https://github.com/Kozea/Radicale/wiki/LDAP-authentication) you can find a script to create a group calendar. - -Use 'memberOf' if you want to load groups on Active Directory and alikes, 'groupMembership' on Novell eDirectory, ... - -Default: (unset) +Default: (unset, in which case the login name is directly used as the username) ##### ldap_use_ssl _(>= 3.3.0)_ -Use ssl on the ldap connection (soon to be deprecated, use ldap_security instead) +Use ssl on the LDAP connection. **Deprecated**, use `ldap_security` instead**!** ##### ldap_security _(>= 3.5.2)_ -Use encryption on the ldap connection. none, tls, starttls +Use encryption on the LDAP connection. One of `none`, `tls`, `starttls`. -Default: none +Default: `none` ##### ldap_ssl_verify_mode _(>= 3.3.0)_ -The certificate verification mode. Works for tls and starttls. NONE, OPTIONAL or REQUIRED +Certificate verification mode for tls and starttls. One of `NONE`, `OPTIONAL`, `REQUIRED`. -Default: REQUIRED +Default: `REQUIRED` ##### ldap_ssl_ca_file _(>= 3.3.0)_ -The path to the CA file in pem format which is used to certificate the server certificate +Path to the CA file in PEM format which is used to certify the server certificate -Default: +Default: (unset) + +##### ldap_groups_attribute + +_(>= 3.4.0)_ + +LDAP attribute in the authenticated user's LDAP entry to read the group memberships from. + +E.g. `memberOf` to get groups on Active Directory and alikes, `groupMembership` on Novell eDirectory, ... + +If set, get the user's LDAP groups from the attribute given. + +For DN-valued attributes, the value of the RDN is used to determine the group names. +The implementation also supports non-DN-valued attributes: their values are taken directly. + +The user's group names can be used later on to define rights. +They also give you access to the group calendars, if those exist. +* Group calendars are placed directly under *collection_root_folder*`/GROUPS/` + with the base64-encoded group name as the calendar folder name. +* Group calendar folders are not created automatically. + This must be done manually. In the [LDAP-authentication section of Radicale's wiki](https://github.com/Kozea/Radicale/wiki/LDAP-authentication) you can find a script to create a group calendar. + +Default: (unset) + +##### ldap_group_members_attribute + +_(>= 3.5.6)_ + +Attribute in the group entries to read the group's members from. + +E.g. `member` for groups with objectclass `groupOfNames`. + +Using `ldap_group_members_attribute`, `ldap_group_base` and `ldap_group_filter` is an alternative +approach to getting the user's groups. Instead of reading them from `ldap_groups_attribute` +in the user's entry, an additional query is performed to seach for those groups beneath `ldap_group_base`, +that have the user's DN in their `ldap_group_members_attribute` and additionally fulfil `ldap_group_filter`. + +As with DN-valued `ldap_groups_attribute`, the value of the RDN is used to determine the group names. + +Default: (unset) + +##### ldap_group_base + +_(>= 3.5.6)_ + +Base DN to search for groups. +Only necessary if `ldap_group_members_attribute` is set, and if the base DN for groups differs from `ldap_base`. + +Default: (unset, in which case `ldap_base` is used as fallback) + +##### ldap_group_filter + +_(>= 3.5.6)_ + +Search filter to search for groups having the user DN found as member. +Only necessary `ldap_group_members_attribute` is set, and you want the groups returned to be restricted +instead of all groups the user's DN is in. + +Default: (unset) ##### ldap_ignore_attribute_create_modify_timestamp _(>= 3.5.1)_ -Add modifyTimestamp and createTimestamp to the exclusion list of internal ldap3 client -so that these schema attributes are not checked. This is needed at least for Authentik -LDAP server as not providing these both attributes. +Quirks for Authentik LDAP server, which violates the LDAP RFCs: +add modifyTimestamp and createTimestamp to the exclusion list of internal ldap3 client +so that these schema attributes are not checked. -Default: false +Default: `false` ##### dovecot_connection_type = AF_UNIX @@ -1177,7 +1234,9 @@ providers like ldap, kerberos Default: `False` -Note: cannot be enabled together with `uc_username` +Notes: +* `lc_username` and `uc_username` are mutually exclusive +* for auth type `ldap` the use of `ldap_user_attribute` is preferred ##### uc_username @@ -1188,7 +1247,9 @@ providers like ldap, kerberos Default: `False` -Note: cannot be enabled together with `lc_username` +Notes: +* `uc_username` and `lc_username` are mutually exclusive +* for auth type `ldap` the use of `ldap_user_attribute` is preferred ##### strip_domain diff --git a/config b/config index 0e08659b..b51c5dfc 100644 --- a/config +++ b/config @@ -75,46 +75,54 @@ ## Expiration time of caching failed logins in seconds #cache_failed_logins_expiry = 90 -# Ignore modifyTimestamp and createTimestamp attributes. Required e.g. for Authentik LDAP server -#ldap_ignore_attribute_create_modify_timestamp = false - # URI to the LDAP server #ldap_uri = ldap://localhost -# The base DN where the user accounts have to be searched +# Base DN of the LDAP server to search for user accounts #ldap_base = ##BASE_DN## -# The reader DN of the LDAP server +# Reader DN of the LDAP server; (needs read access to users and - if defined - groups) #ldap_reader_dn = CN=ldapreader,CN=Users,##BASE_DN## -# Password of the reader DN +# Password of the reader DN (better: use 'ldap_secret_file'!) #ldap_secret = ldapreader-secret -# Path of the file containing password of the reader DN +# Path to the file containing the password of the reader DN #ldap_secret_file = /run/secrets/ldap_password -# the attribute to read the group memberships from in the user's LDAP entry (default: not set) -#ldap_groups_attribute = memberOf - -# The filter to find the DN of the user. This filter must contain a python-style placeholder for the login +# Filter to search for the LDAP entry of the user to authenticate. It must contain '{0}' as placeholder for the login name. #ldap_filter = (&(objectClass=person)(uid={0})) -# the attribute holding the value to be used as username after authentication +# Attribute holding the value to be used as username after authentication #ldap_user_attribute = cn -# Use ssl on the ldap connection -# Soon to be deprecated, use ldap_security instead +# Use ssl on the LDAP connection (DEPRECATED - use 'ldap_security'!) #ldap_use_ssl = False -# the encryption mode to be used: tls, starttls, default is none +# Encryption mode to be used. Default: none; one of: none, tls, starttls #ldap_security = none -# The certificate verification mode. Works for ssl and starttls. NONE, OPTIONAL, default is REQUIRED +# Certificate verification mode for tls & starttls. Default: REQUIRED; one of NONE, OPTIONAL, REQUIRED #ldap_ssl_verify_mode = REQUIRED -# The path to the CA file in pem format which is used to certificate the server certificate +# Path to the CA file in PEM format to certify the server certificate #ldap_ssl_ca_file = +# Attribute in the user's LDAP entry to read the group memberships from; default: not set +#ldap_groups_attribute = memberOf + +# Attribute in the group entries to read the group's members from, e.g. member; default: not set +#ldap_group_members_attribute = member + +# Base DN to search for groups; only if it differs from 'ldap_base' and if 'ldap_group_members_attribute' is set +#ldap_group_base = ##GROUP_BASE_DN## + +# Search filter to search for groups having the user DN found as member; only if 'ldap_group_members_attribute' is set +#ldap_group_filter = (objectclass=groupOfNames) + +# Quirks for Authentik LDAP server: ignore modifyTimestamp and createTimestamp attributes +#ldap_ignore_attribute_create_modify_timestamp = false + # Connection type for dovecot authentication (AF_UNIX|AF_INET|AF_INET6) # Note: credentials are transmitted in cleartext #dovecot_connection_type = AF_UNIX diff --git a/radicale/auth/ldap.py b/radicale/auth/ldap.py index 2c4d63c3..84dcee0b 100644 --- a/radicale/auth/ldap.py +++ b/radicale/auth/ldap.py @@ -16,20 +16,36 @@ # along with Radicale. If not, see . """ Authentication backend that checks credentials with a LDAP server. -Following parameters are needed in the configuration: - ldap_uri The LDAP URL to the server like ldap://localhost - ldap_base The baseDN of the LDAP server - ldap_reader_dn The DN of a LDAP user with read access to get the user accounts - ldap_secret The password of the ldap_reader_dn - ldap_secret_file The path of the file containing the password of the ldap_reader_dn - ldap_filter The search filter to find the user to authenticate by the username - ldap_user_attribute The attribute to be used as username after authentication - ldap_groups_attribute The attribute containing group memberships in the LDAP user entry -Following parameters controls SSL connections: - ldap_use_ssl If ssl encryption should be used (to be deprecated) - ldap_security The encryption mode to be used: *none*|tls|starttls - ldap_ssl_verify_mode The certificate verification mode. Works for tls and starttls. NONE, OPTIONAL, default is REQUIRED - ldap_ssl_ca_file + The following parameters are needed in the configuration: + ldap_uri URI to the LDAP server + ldap_base Base DN of the LDAP server + ldap_reader_dn DN of an LDAP user with read access to get the user accounts + ldap_secret Password of the 'ldap_reader_dn' + Better: use 'ldap_secret_file'! + ldap_secret_file Path of the file containing the password of the 'ldap_reader_dn' + ldap_filter Search filter to find the user DN to authenticate + The following parameters control TLS connections: + ldap_use_ssl Use ssl on the ldap connection. + Deprecated, use 'ldap_security' instead! + ldap_security Encryption mode to be used, + one of: *none* | tls | starttls + ldap_ssl_verify_mode Certificate verification mode for tls and starttls; + one of: *REQUIRED* | OPTIONAL | NONE + ldap_ssl_ca_file Path to the CA file in PEM format to certify the server certificate + The following parameters are optional: + ldap_user_attribute Attribute to be used as username after authentication, e.g. cn; + if not given, the name used to logon is used. + ldap_groups_attribute Attribute in the user entry to read the user's group memberships from, + e.g. memberof, groupMememberShip. This may even be a non-DN attribute! + ldap_group_base Base DN to search for groups; + only if it differs from 'ldap_base' and if 'ldap_group_members_attribute' is set + ldap_group_filter Search filter to search for groups having the user DN found as member; + only if 'ldap_group_members_attribute' is set + ldap_group_members_attribute Attribute in the group entries to read the group's members from, + e.g. member. + The following parameters are for LDAP servers with oddities + ldap_ignore_attribute_create_modify_timestamp + Ignore modifyTimestamp and createTimestamp attributes. Needed for Authentik LDAP server """ import ssl @@ -47,7 +63,11 @@ class Auth(auth.BaseAuth): _ldap_attributes: list[str] = [] _ldap_user_attr: str _ldap_groups_attr: str + _ldap_group_base: str + _ldap_group_filter: str + _ldap_group_members_attr: str _ldap_module_version: int = 3 + _use_encryption: bool = False _ldap_use_ssl: bool = False _ldap_security: str = "none" _ldap_ssl_verify_mode: int = ssl.CERT_REQUIRED @@ -61,6 +81,7 @@ class Auth(auth.BaseAuth): except ImportError: try: import ldap + import ldap.filter self._ldap_module_version = 2 self.ldap = ldap except ImportError as e: @@ -78,6 +99,9 @@ class Auth(auth.BaseAuth): self._ldap_filter = configuration.get("auth", "ldap_filter") self._ldap_user_attr = configuration.get("auth", "ldap_user_attribute") self._ldap_groups_attr = configuration.get("auth", "ldap_groups_attribute") + self._ldap_group_base = configuration.get("auth", "ldap_group_base") + self._ldap_group_filter = configuration.get("auth", "ldap_group_filter") + self._ldap_group_members_attr = configuration.get("auth", "ldap_group_members_attribute") ldap_secret_file_path = configuration.get("auth", "ldap_secret_file") if ldap_secret_file_path: with open(ldap_secret_file_path, 'r') as file: @@ -110,6 +134,19 @@ class Auth(auth.BaseAuth): logger.info("auth.ldap_groups_attribute: %r" % self._ldap_groups_attr) else: logger.info("auth.ldap_groups_attribute: (not provided)") + if self._ldap_group_base: + logger.info("auth.ldap_group_base : %r" % self._ldap_group_base) + else: + logger.info("auth.ldap_group_base : (not provided, using ldap_base)") + self._ldap_group_base = self._ldap_base + if self._ldap_group_filter: + logger.info("auth.ldap_group_filter: %r" % self._ldap_group_filter) + else: + logger.info("auth.ldap_group_filter: (not provided)") + if self._ldap_group_members_attr: + logger.info("auth.ldap_group_members_attr: %r" % self._ldap_group_members_attr) + else: + logger.info("auth.ldap_group_members_attr: (not provided)") if ldap_secret_file_path: logger.info("auth.ldap_secret_file_path: %r" % ldap_secret_file_path) if self._ldap_secret: @@ -160,6 +197,30 @@ class Auth(auth.BaseAuth): user_entry = res[0] user_dn = user_entry[0] logger.debug(f"_login2 found LDAP user DN {user_dn}") + + """Let's collect the groups of the user.""" + groupDNs = [] + if self._ldap_groups_attr: + groupDNs = user_entry[1][self._ldap_groups_attr] + + """Search for all groups having the user_dn found as member.""" + if self._ldap_group_members_attr: + groupDNs = [] + res = conn.search_s( + self._ldap_group_base, + self.ldap.SCOPE_SUBTREE, + filterstr="(&{0}({1}={2}))".format( + self._ldap_group_filter, + self._ldap_group_members_attr, + self.ldap.filter.escape_filter_chars(user_dn)), + attrlist=['1.1'] + ) + """Fill groupDNs with DNs of groups found""" + if len(res) > 0: + groupDNs = [] + for dn, entry in res: + groupDNs.append(dn) + """Close LDAP connection""" conn.unbind() except Exception as e: @@ -171,23 +232,26 @@ class Auth(auth.BaseAuth): conn.protocol_version = 3 conn.set_option(self.ldap.OPT_REFERRALS, 0) conn.simple_bind_s(user_dn, password) - tmp: list[str] = [] - if self._ldap_groups_attr: - tmp = [] - for g in user_entry[1][self._ldap_groups_attr]: - """Get group g's RDN's attribute value""" - try: - rdns = self.ldap.dn.explode_dn(g, notypes=True) - tmp.append(rdns[0]) - except Exception: - tmp.append(g.decode('utf8')) - self._ldap_groups = set(tmp) - logger.debug("_login2 LDAP groups of user: %s", ",".join(self._ldap_groups)) if self._ldap_user_attr: if user_entry[1][self._ldap_user_attr]: - tmplogin = user_entry[1][self._ldap_user_attr][0] - login = tmplogin.decode('utf-8') + login = user_entry[1][self._ldap_user_attr][0] + if isinstance(login, bytes): + login = login.decode('utf-8') logger.debug(f"_login2 user set to: '{login}'") + + """Get RDNs of groups' DNs""" + tmp = [] + for g in groupDNs: + try: + rdns = self.ldap.dn.explode_dn(g, notypes=True) + tmp.append(rdns[0]) + except Exception: + if isinstance(g, bytes): + g = g.decode('utf-8') + tmp.append(g) + self._ldap_groups = set(tmp) + logger.debug("_login2 LDAP groups of user: %s", ",".join(self._ldap_groups)) + conn.unbind() logger.debug(f"_login2 {login} successfully authenticated") return login @@ -249,9 +313,42 @@ class Auth(auth.BaseAuth): return "" user_entry = conn.response[0] - conn.unbind() user_dn = user_entry['dn'] logger.debug(f"_login3 found LDAP user DN {user_dn}") + + """Let's collect the groups of the user.""" + groupDNs = [] + if self._ldap_groups_attr: + if user_entry['attributes'][self._ldap_groups_attr]: + if isinstance(user_entry['attributes'][self._ldap_groups_attr], list): + groupDNs = user_entry['attributes'][self._ldap_groups_attr] + else: + groupDNs.append(user_entry['attributes'][self._ldap_groups_attr]) + + """Search for all groups having the user_dn found as member.""" + if self._ldap_group_members_attr: + try: + conn.search( + search_base=self._ldap_group_base, + search_filter="(&{0}({1}={2}))".format( + self._ldap_group_filter, + self._ldap_group_members_attr, + self.ldap3.utils.conv.escape_filter_chars(user_dn)), + search_scope=self.ldap3.SUBTREE, + attributes=['1.1'] + ) + except Exception as e: + """LDAP search failed: consider it as non-fatal - only groups missing""" + logger.debug(f"_ldap3: LDAP group search failed: {e}") + else: + """Fill groupDNs with DNs of groups found""" + groupDNs = [] + for group in conn.response: + groupDNs.append(group['dn']) + + """Close LDAP connection""" + conn.unbind() + try: """Try to bind as the user itself""" try: @@ -264,18 +361,18 @@ class Auth(auth.BaseAuth): if not conn.bind(read_server_info=False): logger.debug(f"_login3 user '{login}' cannot be found") return "" - tmp: list[str] = [] - if self._ldap_groups_attr: - tmp = [] - for g in user_entry['attributes'][self._ldap_groups_attr]: - """Get group g's RDN's attribute value""" - try: - rdns = self.ldap3.utils.dn.parse_dn(g) - tmp.append(rdns[0][1]) - except Exception: - tmp.append(g) - self._ldap_groups = set(tmp) - logger.debug("_login3 LDAP groups of user: %s", ",".join(self._ldap_groups)) + + """Get RDNs of groups' DNs""" + tmp = [] + for g in groupDNs: + try: + rdns = self.ldap3.utils.dn.parse_dn(g) + tmp.append(rdns[0][1]) + except Exception: + tmp.append(g) + self._ldap_groups = set(tmp) + logger.debug("_login3 LDAP groups of user: %s", ",".join(self._ldap_groups)) + if self._ldap_user_attr: if user_entry['attributes'][self._ldap_user_attr]: if isinstance(user_entry['attributes'][self._ldap_user_attr], list): diff --git a/radicale/config.py b/radicale/config.py index c6d93f41..adab9567 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -261,58 +261,70 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "1", "help": "incorrect authentication delay", "type": positive_float}), - ("ldap_ignore_attribute_create_modify_timestamp", { - "value": "false", - "help": "Ignore modifyTimestamp and createTimestamp attributes. Need if Authentik LDAP server is used.", - "type": bool}), ("ldap_uri", { "value": "ldap://localhost", - "help": "URI to the ldap server", + "help": "URI to the LDAP server", "type": str}), ("ldap_base", { "value": "", - "help": "LDAP base DN of the ldap server", + "help": "Base DN of the LDAP server", "type": str}), ("ldap_reader_dn", { "value": "", - "help": "the DN of a ldap user with read access to get the user accounts", + "help": "DN of an LDAP user with read access to users anmd - if defined - groups", "type": str}), ("ldap_secret", { "value": "", - "help": "the password of the ldap_reader_dn", + "help": "Password of ldap_reader_dn (better: use ldap_secret_file)", "type": str}), ("ldap_secret_file", { "value": "", - "help": "path of the file containing the password of the ldap_reader_dn", + "help": "Path to the file containing the password of ldap_reader_dn", "type": str}), ("ldap_filter", { "value": "(cn={0})", - "help": "the search filter to find the user DN to authenticate by the username", + "help": "Filter to search for the LDAP entry of the user to authenticate", "type": str}), ("ldap_user_attribute", { "value": "", - "help": "the attribute to be used as username after authentication", - "type": str}), - ("ldap_groups_attribute", { - "value": "", - "help": "attribute to read the group memberships from", + "help": "Attribute to be used as username after authentication", "type": str}), ("ldap_use_ssl", { "value": "False", - "help": "Use ssl on the ldap connection. Soon to be deprecated, use ldap_security instead", + "help": "Use ssl on the LDAP connection. Deprecated, use ldap_security instead!", "type": bool}), ("ldap_security", { "value": "none", - "help": "the encryption mode to be used: *none*|tls|starttls", + "help": "Encryption mode to be used: *none*|tls|starttls", "type": str}), ("ldap_ssl_verify_mode", { "value": "REQUIRED", - "help": "The certificate verification mode. Works for tls and starttls. NONE, OPTIONAL, default is REQUIRED", + "help": "Certificate verification mode for tls and starttls. NONE, OPTIONAL, default is REQUIRED", "type": str}), ("ldap_ssl_ca_file", { "value": "", - "help": "The path to the CA file in pem format which is used to certificate the server certificate", + "help": "Path to the CA file in PEM format which is used to certify the server certificate", "type": str}), + ("ldap_groups_attribute", { + "value": "", + "help": "Attribute in the user's LDAP entry to read the group memberships from", + "type": str}), + ("ldap_group_members_attribute", { + "value": "", + "help": "Attribute in the group entries to read the group's members from", + "type": str}), + ("ldap_group_base", { + "value": "", + "help": "Base DN to search for groups. Only if it differs from ldap_base and if ldap_group_members_attribute is set", + "type": str}), + ("ldap_group_filter", { + "value": "", + "help": "Search filter to search for groups having the user as member. Only if ldap_group_members_attribute is set", + "type": str}), + ("ldap_ignore_attribute_create_modify_timestamp", { + "value": "false", + "help": "Quirk for Authentik LDAP server: ignore modifyTimestamp and createTimestamp attributes.", + "type": bool}), ("imap_host", { "value": "localhost", "help": "IMAP server hostname: address|address:port|[address]:port|*localhost*",