Skip to content

Commit

Permalink
Rework search flow to minimize LDAP search results
Browse files Browse the repository at this point in the history
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 jellyfin#139 jellyfin#34
Obsoletes PR jellyfin#71
  • Loading branch information
joshuaboniface committed Feb 28, 2023
1 parent 3a5b07b commit 17182d1
Show file tree
Hide file tree
Showing 6 changed files with 60 additions and 64 deletions.
1 change: 0 additions & 1 deletion LDAP-Auth/Api/LdapController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 0 additions & 1 deletion LDAP-Auth/Api/Models/UserFilterInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ public class UserFilterInfo
/// <summary>
/// Gets or sets the ldap user search filter.
/// </summary>
[Required]
public string LdapSearchFilter { get; set; }

/// <summary>
Expand Down
6 changes: 0 additions & 6 deletions LDAP-Auth/Api/Models/UserSearchAttributes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,8 @@ public class UserSearchAttributes
/// <summary>
/// Gets or sets the ldap search attributes.
/// </summary>
[Required]
public string LdapSearchAttributes { get; set; }

/// <summary>
/// Gets or sets a value indicating whether to use case insensitive username comparison.
/// </summary>
public bool EnableCaseInsensitiveUsername { get; set; }

/// <summary>
/// Gets or sets the username to search for as a test.
/// </summary>
Expand Down
6 changes: 0 additions & 6 deletions LDAP-Auth/Config/PluginConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ public PluginConfiguration()
LdapClientCertPath = string.Empty;
LdapClientKeyPath = string.Empty;
LdapRootCaPath = string.Empty;
EnableCaseInsensitiveUsername = false;
CreateUsersFromLdap = true;
LdapUsernameAttribute = "uid";
LdapPasswordAttribute = "userPassword";
Expand Down Expand Up @@ -102,11 +101,6 @@ public PluginConfiguration()
/// </summary>
public string LdapSearchAttributes { get; set; }

/// <summary>
/// Gets or sets a value indicating whether to use case insensitive username comparison.
/// </summary>
public bool EnableCaseInsensitiveUsername { get; set; }

/// <summary>
/// Gets or sets the ldap client cert path.
/// </summary>
Expand Down
46 changes: 27 additions & 19 deletions LDAP-Auth/Config/configPage.html
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,30 @@ <h2 class="sectionTitle">LDAP Settings:</h2>
</div>
<div class="verticalSection" is="emby-collapse" title="LDAP User Settings">
<div class="collapseContent">
<h4 style="margin-top:-0.3em">Users</h4>
<p>There are two methods possible to search for users.</p>
<ul>
<li>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.</li>
<li>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.</li>
</ul>
<p>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.</p>
<p>Note: Usernames are treated case-insensitive regardless of the method, as an LDAP search is not case-sensitive.</p>
<hr>
<div class="inputContainer fldExternalAddressFilter">
<input is="emby-input" type="text" id="txtLdapSearchFilter" required placeholder="(memberOf=CN=JellyfinUsers,DC=contoso,DC=com)" label="LDAP User Filter:" />
<div class="fieldDescription">The LDAP search filter to find users for Jellyfin, e.g. (objectClass=inetOrgPerson).</div>
<input is="emby-input" type="text" id="txtLdapSearchFilter" placeholder="(memberOf=CN=JellyfinUsers,DC=contoso,DC=com)" label="LDAP Search Filter:" />
<div class="fieldDescription">
LDAP search filter to limit user searches.<br/>
</div>
</div>
<div class="inputContainer fldExternalAddressFilter">
<input is="emby-input" type="text" id="txtLdapSearchAttributes" placeholder="uid, cn, mail, displayName" label="LDAP Search Attributes:" />
<div class="fieldDescription">
A comma-separated list of attributes to filter the username by.<br/>
</div>
</div>
<hr>

<h4>Administrators</h4>
<div class="inputContainer fldExternalAddressFilter">
<input is="emby-input" type="text" id="txtLdapAdminBaseDn" label="LDAP Admin Base DN:" />
<div class="fieldDescription">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</div>
Expand All @@ -115,22 +135,14 @@ <h2 class="sectionTitle">LDAP Settings:</h2>
<span>Enable Admin Filter 'memberUid' mode</span>
</label>
</div>
<hr>

<h4>Testing</h4>
<button id="btnTestFilters" is="emby-button" type="button" class="raised button block">
<span>Save and Test LDAP Filter Settings</span>
</button>
<div id="divFilterTestResults"></div>
<hr>
<div class="inputContainer fldExternalAddressFilter">
<input is="emby-input" type="text" id="txtLdapSearchAttributes" required placeholder="uid, cn, mail, displayName" label="LDAP Attributes:" />
<div class="fieldDescription">A comma-separated list of LDAP attributes to compare with entered usernames.</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkEnableCaseInsensitiveUsername" />
<span>Enable Case Insensitive Username</span>
</label>
<div class="fieldDescription checkboxFieldDescription">Enable case insensitive username comparison</div>
</div>
<br/>
<div class="inputContainer fldExternalAddressFilter">
<input is="emby-input" type="text" id="txtLdapSearchTest" label="Test Login Name:" />
<div class="fieldDescription">A user login to search the LDAP for to test attribute configuration.</div>
Expand All @@ -152,7 +164,7 @@ <h2 class="sectionTitle">LDAP Settings:</h2>
</div>
<div class="inputContainer fldExternalAddressFilter">
<input is="emby-input" type="text" id="txtLdapUsernameAttribute" label="LDAP Name Attribute:" />
<div class="fieldDescription">The LDAP attribute to create Jellyfin user names from.</div>
<div class="fieldDescription">The LDAP attribute to create Jellyfin user names from.<br/><i>For example, 'uid' means we will use the LDAP 'uid' attribute as the Jellyfin username.</i></div>
</div>
<div class="inputContainer fldExternalAddressFilter">
<input is="emby-input" type="text" id="txtLdapPasswordAttribute" label="LDAP Password Attribute:" />
Expand Down Expand Up @@ -206,7 +218,6 @@ <h2>${HeaderLibraryAccess}</h2>
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"),
Expand Down Expand Up @@ -245,7 +256,6 @@ <h2>${HeaderLibraryAccess}</h2>
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;
Expand Down Expand Up @@ -332,7 +342,6 @@ <h2>${HeaderLibraryAccess}</h2>
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;
Expand Down Expand Up @@ -473,7 +482,6 @@ <h2>${HeaderLibraryAccess}</h2>

const configUpdate = {
LdapSearchAttributes: LdapConfigurationPage.txtLdapSearchAttributes.value,
EnableCaseInsensitiveUsername: LdapConfigurationPage.chkEnableCaseInsensitiveUsername.checked,
TestSearchUsername: LdapConfigurationPage.txtLdapSearchTest.value,
}

Expand Down
64 changes: 33 additions & 31 deletions LDAP-Auth/LDAPAuthenticationProviderPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ public IEnumerable<string> 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
Expand All @@ -344,8 +344,6 @@ public IEnumerable<string> GetFilteredUsers(string filter)
/// <exception cref="AuthenticationException">Thrown on failure to connect or bind to LDAP server.</exception>
public LdapEntry LocateLdapUser(string username)
{
var foundUser = false;
LdapEntry ldapUser = null;
using var ldapClient = ConnectToLdap();

if (!ldapClient.Connected)
Expand All @@ -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;
}

/// <inheritdoc />
Expand Down

0 comments on commit 17182d1

Please sign in to comment.