Parcourir la source

Merge pull request #1861 from marschap/ldap-group-search

LDAP auth: group memberships based on separate search
Peter Bieringer il y a 5 mois
Parent
commit
126b1a13d2
4 fichiers modifiés avec 296 ajouts et 118 suppressions
  1. 102 41
      DOCUMENTATION.md
  2. 25 17
      config
  3. 138 41
      radicale/auth/ldap.py
  4. 31 19
      radicale/config.py

+ 102 - 41
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
-
-Default: not set, i.e. the login name given is used directly.
-
-##### ldap_groups_attribute
-
-_(>= 3.4.0)_
-
-The LDAP attribute to read the group memberships from in the authenticated user's LDAP entry.
+LDAP attribute whose value shall be used as the username after successful authentication.
 
-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, ...
+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.
 
-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
 

+ 25 - 17
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

+ 138 - 41
radicale/auth/ldap.py

@@ -16,20 +16,36 @@
 # along with Radicale.  If not, see <http://www.gnu.org/licenses/>.
 """
 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):

+ 31 - 19
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*",