From 17182d15079d601eeb9c521454d77253098caafc Mon Sep 17 00:00:00 2001 From: "Joshua M. Boniface" Date: Tue, 28 Feb 2023 14:24:39 -0500 Subject: [PATCH] Rework search flow to minimize LDAP search results The previous implementation would search for all users that potentially matched the (mandatory) configured search filter, then loop through the results to find a user matching one of the attributes. This method worked fine for small instances, but became unwieldy for larger LDAP instances, and could even result in a Size Limit Exceeded error for extremely large databases. This commit introduces a better method by integrating the user search component into the initial LDAP search by way of the search filter. This will ideally result in only a single LDAP search result for a given user rather than potentially dozens or hundreds, reducing both the runtime complexity of the plugin as well as the load on the LDAP server. This new method has two potential ways of generating the limited search filter: First, the existing search filter option may now accept a "{username}" variable within it, which will be interpolated at runtime based on what the user entered. This method allows for very complex queries to be crafted at will, providing better administrator flexibility and options. Second, if no "{username}" variable is found within the search filter, the filter will be modified at runtime to include all of the search attributes combined with the username to generate a search query which will return any matches between the username and those attributes. For example, given attributes "uid" and "mail", a (base) search filter of "(objectclass=mailUser)", and the entered username "joshua", the following "real" search query would be produced: (&(objectclass=mailUser)(|(uid=joshua)(mail=joshua))) These two methods are, functionally, mutually exclusive: if the search filter contains the "{username}" variable, the search attributes will be ignored and may be blank/empty; on the other side, with valid search attributes, it is no longer necessary to specify a search filter at all, since there will still be a filter on the attributes and username. Thus, the plugin now accepts blank input for both fields in the configuration, though leaving both blank would not work properly. In both cases with this change, searches are case-insensitive due to the case-insensitivity of LDAP search queries,, so the option for case-insensitive username searches has been removed as obsolete. The new configuration is also backwards-compatible: if no changes are made to the search filter, the second method above will be used and this should continue to function exactly as expected. Some debug messages have also been updated to provide a clearer picture of what the plugin is doing at various steps and aid in troubleshooting. An explanation of the two methods above is included in the plugin configuration page, along with some rearranging of the options, to assist users in configuring the two fields the way they want. Closes #139 #34 Obsoletes PR #71 --- LDAP-Auth/Api/LdapController.cs | 1 - LDAP-Auth/Api/Models/UserFilterInfo.cs | 1 - LDAP-Auth/Api/Models/UserSearchAttributes.cs | 6 -- LDAP-Auth/Config/PluginConfiguration.cs | 6 -- LDAP-Auth/Config/configPage.html | 46 +++++++------ LDAP-Auth/LDAPAuthenticationProviderPlugin.cs | 64 ++++++++++--------- 6 files changed, 60 insertions(+), 64 deletions(-) diff --git a/LDAP-Auth/Api/LdapController.cs b/LDAP-Auth/Api/LdapController.cs index b78711e..36f0508 100644 --- a/LDAP-Auth/Api/LdapController.cs +++ b/LDAP-Auth/Api/LdapController.cs @@ -158,7 +158,6 @@ public IActionResult LdapUserSearch([FromBody] UserSearchAttributes body) { var configuration = LdapPlugin.Instance.Configuration; configuration.LdapSearchAttributes = body.LdapSearchAttributes; - configuration.EnableCaseInsensitiveUsername = body.EnableCaseInsensitiveUsername; LdapPlugin.Instance.UpdateConfiguration(configuration); var response = new UserSearchResponse(); diff --git a/LDAP-Auth/Api/Models/UserFilterInfo.cs b/LDAP-Auth/Api/Models/UserFilterInfo.cs index 31d4513..23ffc3b 100644 --- a/LDAP-Auth/Api/Models/UserFilterInfo.cs +++ b/LDAP-Auth/Api/Models/UserFilterInfo.cs @@ -11,7 +11,6 @@ public class UserFilterInfo /// /// Gets or sets the ldap user search filter. /// - [Required] public string LdapSearchFilter { get; set; } /// diff --git a/LDAP-Auth/Api/Models/UserSearchAttributes.cs b/LDAP-Auth/Api/Models/UserSearchAttributes.cs index a830d57..683173c 100644 --- a/LDAP-Auth/Api/Models/UserSearchAttributes.cs +++ b/LDAP-Auth/Api/Models/UserSearchAttributes.cs @@ -12,14 +12,8 @@ public class UserSearchAttributes /// /// Gets or sets the ldap search attributes. /// - [Required] public string LdapSearchAttributes { get; set; } - /// - /// Gets or sets a value indicating whether to use case insensitive username comparison. - /// - public bool EnableCaseInsensitiveUsername { get; set; } - /// /// Gets or sets the username to search for as a test. /// diff --git a/LDAP-Auth/Config/PluginConfiguration.cs b/LDAP-Auth/Config/PluginConfiguration.cs index 379a1a4..69c6ba9 100644 --- a/LDAP-Auth/Config/PluginConfiguration.cs +++ b/LDAP-Auth/Config/PluginConfiguration.cs @@ -29,7 +29,6 @@ public PluginConfiguration() LdapClientCertPath = string.Empty; LdapClientKeyPath = string.Empty; LdapRootCaPath = string.Empty; - EnableCaseInsensitiveUsername = false; CreateUsersFromLdap = true; LdapUsernameAttribute = "uid"; LdapPasswordAttribute = "userPassword"; @@ -102,11 +101,6 @@ public PluginConfiguration() /// public string LdapSearchAttributes { get; set; } - /// - /// Gets or sets a value indicating whether to use case insensitive username comparison. - /// - public bool EnableCaseInsensitiveUsername { get; set; } - /// /// Gets or sets the ldap client cert path. /// diff --git a/LDAP-Auth/Config/configPage.html b/LDAP-Auth/Config/configPage.html index f59bf73..630b174 100644 --- a/LDAP-Auth/Config/configPage.html +++ b/LDAP-Auth/Config/configPage.html @@ -93,10 +93,30 @@

LDAP Settings:

+

Users

+

There are two methods possible to search for users.

+
    +
  • The {username} variable can be placed directly into the LDAP Search Filter. This variable will be replaced during the search with the entered username. If you use this method, the LDAP Search Attributes value will be ignored, and you must implement the attribute comparison(s) manually in your filter.
  • +
  • The LDAP Search Filter can be left as a subcomponent of a larger search filter constructed at runtime. For each LDAP Search Attributes entry, the entered username will be used with the attribute as an 'or' condition search filter. If you use this method, the LDAP Search Filter is optional and may be empty. This will be the default if you do not adjust your LDAP Search Filter to include the {username} variable at least once.
  • +
+

At least one method must be chosen and configured below. If upgrading from plugin version 16 or newer, the second option will be used by default.

+

Note: Usernames are treated case-insensitive regardless of the method, as an LDAP search is not case-sensitive.

+
- -
The LDAP search filter to find users for Jellyfin, e.g. (objectClass=inetOrgPerson).
+ +
+ LDAP search filter to limit user searches.
+
+
+ +
+ A comma-separated list of attributes to filter the username by.
+
+
+
+ +

Administrators

The LDAP search base dn to find administrative users for Jellyfin, e.g. (cn=admins,dc=contoso,dc=com). Defaults to user dn if empty
@@ -115,22 +135,14 @@

LDAP Settings:

Enable Admin Filter 'memberUid' mode
+
+ +

Testing

-
-
- -
A comma-separated list of LDAP attributes to compare with entered usernames.
-
-
- -
Enable case insensitive username comparison
-
+
A user login to search the LDAP for to test attribute configuration.
@@ -152,7 +164,7 @@

LDAP Settings:

-
The LDAP attribute to create Jellyfin user names from.
+
The LDAP attribute to create Jellyfin user names from.
For example, 'uid' means we will use the LDAP 'uid' attribute as the Jellyfin username.
@@ -206,7 +218,6 @@

${HeaderLibraryAccess}

chkEnableLdapAdminFilterMemberUid: document.querySelector("#chkEnableLdapAdminFilterMemberUid"), divFilterTestResults: document.querySelector("#divFilterTestResults"), txtLdapSearchAttributes: document.querySelector("#txtLdapSearchAttributes"), - chkEnableCaseInsensitiveUsername: document.querySelector("#chkEnableCaseInsensitiveUsername"), txtLdapSearchTest: document.querySelector("#txtLdapSearchTest"), divSearchTestResults: document.querySelector("#divSearchTestResults"), txtLdapClientCertPath: document.querySelector("#txtLdapClientCertPath"), @@ -245,7 +256,6 @@

${HeaderLibraryAccess}

LdapConfigurationPage.txtLdapClientCertPath.value = config.LdapClientCertPath || ""; LdapConfigurationPage.txtLdapClientKeyPath.value = config.LdapClientKeyPath || ""; LdapConfigurationPage.txtLdapRootCaPath.value = config.LdapRootCaPath || ""; - LdapConfigurationPage.chkEnableCaseInsensitiveUsername.checked = config.EnableCaseInsensitiveUsername; LdapConfigurationPage.chkEnableUserCreation.checked = config.CreateUsersFromLdap; LdapConfigurationPage.txtLdapUsernameAttribute.value = config.LdapUsernameAttribute; LdapConfigurationPage.txtLdapPasswordAttribute.value = config.LdapPasswordAttribute; @@ -332,7 +342,6 @@

${HeaderLibraryAccess}

config.LdapClientCertPath = LdapConfigurationPage.txtLdapClientCertPath.value; config.LdapClientKeyPath = LdapConfigurationPage.txtLdapClientKeyPath.value; config.LdapRootCaPath = LdapConfigurationPage.txtLdapRootCaPath.value; - config.EnableCaseInsensitiveUsername = LdapConfigurationPage.chkEnableCaseInsensitiveUsername.checked; config.CreateUsersFromLdap = LdapConfigurationPage.chkEnableUserCreation.checked; config.LdapUsernameAttribute = LdapConfigurationPage.txtLdapUsernameAttribute.value; config.LdapPasswordAttribute = LdapConfigurationPage.txtLdapPasswordAttribute.value; @@ -473,7 +482,6 @@

${HeaderLibraryAccess}

const configUpdate = { LdapSearchAttributes: LdapConfigurationPage.txtLdapSearchAttributes.value, - EnableCaseInsensitiveUsername: LdapConfigurationPage.chkEnableCaseInsensitiveUsername.checked, TestSearchUsername: LdapConfigurationPage.txtLdapSearchTest.value, } diff --git a/LDAP-Auth/LDAPAuthenticationProviderPlugin.cs b/LDAP-Auth/LDAPAuthenticationProviderPlugin.cs index 517b85d..4e58235 100644 --- a/LDAP-Auth/LDAPAuthenticationProviderPlugin.cs +++ b/LDAP-Auth/LDAPAuthenticationProviderPlugin.cs @@ -323,7 +323,7 @@ public IEnumerable GetFilteredUsers(string filter) LdapPlugin.Instance.Configuration.LdapBaseDn, LdapConnection.ScopeSub, filter, - LdapUsernameAttributes, + new string[] { UsernameAttr }, false); // ToList to ensure enumeration is complete before the connection is closed @@ -344,8 +344,6 @@ public IEnumerable GetFilteredUsers(string filter) /// Thrown on failure to connect or bind to LDAP server. public LdapEntry LocateLdapUser(string username) { - var foundUser = false; - LdapEntry ldapUser = null; using var ldapClient = ConnectToLdap(); if (!ldapClient.Connected) @@ -358,55 +356,59 @@ public LdapEntry LocateLdapUser(string username) LdapPlugin.Instance.Configuration.LdapBindUser, LdapPlugin.Instance.Configuration.LdapBindPassword); + string realSearchFilter; + + if (SearchFilter.Contains("{username}")) + { + realSearchFilter = SearchFilter.Replace("{username}", username); + } + else + { + realSearchFilter = "(&" + SearchFilter + "(|"; + foreach (var attr in LdapUsernameAttributes) + { + realSearchFilter += "(" + attr + "=" + username + ")"; + } + + realSearchFilter += "))"; + } + + _logger.LogDebug("LDAP Search: {BaseDn} {realSearchFilter} @ {LdapServer}", LdapPlugin.Instance.Configuration.LdapBaseDn, realSearchFilter, LdapPlugin.Instance.Configuration.LdapServer); + ILdapSearchResults ldapUsers; try { ldapUsers = ldapClient.Search( LdapPlugin.Instance.Configuration.LdapBaseDn, LdapConnection.ScopeSub, - SearchFilter, - LdapUsernameAttributes, + realSearchFilter, + new string[] { UsernameAttr }, false); } catch (LdapException e) { - _logger.LogError(e, "Failed to filter users with: {Filter}", SearchFilter); + _logger.LogError(e, "Failed to filter users with: {Filter}", realSearchFilter); throw new AuthenticationException("Error completing LDAP login while applying user filter."); } - _logger.LogDebug("Search: {BaseDn} {SearchFilter} @ {LdapServer}", LdapPlugin.Instance.Configuration.LdapBaseDn, SearchFilter, LdapPlugin.Instance.Configuration.LdapServer); - - var usernameComparison = LdapPlugin.Instance.Configuration.EnableCaseInsensitiveUsername - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - while (ldapUsers.HasMore() && foundUser == false) + if (ldapUsers.HasMore()) { - var currentUser = ldapUsers.Next(); - foreach (var attr in LdapUsernameAttributes) + LdapEntry ldapUser = ldapUsers.Next(); + + if (ldapUsers.HasMore()) { - var toCheck = GetAttribute(currentUser, attr); - if (toCheck?.StringValueArray != null) - { - foreach (var name in toCheck.StringValueArray) - { - if (string.Equals(username, name, usernameComparison)) - { - ldapUser = currentUser; - foundUser = true; - break; - } - } - } + _logger.LogWarning("More than one LDAP result matched; using first result only."); } - } - if (ldapUser == null) + _logger.LogDebug("LDAP User: {ldapUser}", ldapUser); + + return ldapUser; + } + else { _logger.LogError("Found no users matching {Username} in LDAP search", username); throw new AuthenticationException("Found no LDAP users matching provided username."); } - - return ldapUser; } ///