diff --git a/LDAP-Auth/Api/Models/LdapUser.cs b/LDAP-Auth/Api/Models/LdapUser.cs index fea64c7..90299e6 100644 --- a/LDAP-Auth/Api/Models/LdapUser.cs +++ b/LDAP-Auth/Api/Models/LdapUser.cs @@ -14,6 +14,7 @@ public LdapUser() { LinkedJellyfinUserId = Guid.Empty; LdapUid = string.Empty; + ProfileImageHash = string.Empty; } /// @@ -25,5 +26,10 @@ public LdapUser() /// Gets or sets the LDAP Uid associated with the user. /// public string LdapUid { get; set; } + + /// + /// Gets or sets the profile image hash. This is used to detect if the profile image provided by LDAP changed. + /// + public string ProfileImageHash { get; set; } } } diff --git a/LDAP-Auth/Config/PluginConfiguration.cs b/LDAP-Auth/Config/PluginConfiguration.cs index 35c344a..74977b8 100644 --- a/LDAP-Auth/Config/PluginConfiguration.cs +++ b/LDAP-Auth/Config/PluginConfiguration.cs @@ -37,6 +37,8 @@ public PluginConfiguration() LdapUidAttribute = "uid"; LdapUsernameAttribute = "cn"; LdapPasswordAttribute = "userPassword"; + EnableLdapProfileImageSync = false; + LdapProfileImageAttribute = "jpegphoto"; EnableAllFolders = false; EnabledFolders = Array.Empty(); @@ -153,6 +155,16 @@ public PluginConfiguration() /// public string LdapPasswordAttribute { get; set; } + /// + /// Gets or sets a value indicating whether profile images are synchronized from LDAP. + /// + public bool EnableLdapProfileImageSync { get; set; } + + /// + /// Gets or sets the ldap profile image attribute. + /// + public string LdapProfileImageAttribute { get; set; } + /// /// Gets or sets a value indicating whether to enable access to all library folders. /// @@ -173,7 +185,8 @@ public PluginConfiguration() /// /// The user Guid. /// The LDAP UID associated with the user. - public void AddUser(Guid userGuid, string ldapUid) + /// The hash of the profile image provided by LDAP. + public void AddUser(Guid userGuid, string ldapUid, string profileImageHash) { // Ensure we do not have more than one entry for a given user // This may happen if a user tries to authenticate after their @@ -185,7 +198,8 @@ public void AddUser(Guid userGuid, string ldapUid) var ldapUser = new LdapUser { LinkedJellyfinUserId = userGuid, - LdapUid = ldapUid + LdapUid = ldapUid, + ProfileImageHash = profileImageHash }; ldapUsers.Add(ldapUser); LdapUsers = ldapUsers.ToArray(); diff --git a/LDAP-Auth/Config/configPage.html b/LDAP-Auth/Config/configPage.html index df31d89..1cdbbea 100644 --- a/LDAP-Auth/Config/configPage.html +++ b/LDAP-Auth/Config/configPage.html @@ -126,6 +126,19 @@

Users

The LDAP attribute for the user password; only required if Allow Password Change is enabled.
+
+ +
+ Note that this is a one way sync. If a user changes their profile image in Jellyfin it won't be updated unless the profile image changes in LDAP or the user deletes it in Jellyfin. +
+
+
+ +
The LDAP attribute for synchronizing profile images.
+

Administrators

@@ -230,6 +243,8 @@

${HeaderLibraryAccess}

txtLdapUidAttribute: document.querySelector("#txtLdapUidAttribute"), txtLdapUsernameAttribute: document.querySelector("#txtLdapUsernameAttribute"), txtLdapPasswordAttribute: document.querySelector("#txtLdapPasswordAttribute"), + chkEnableProfileImageSync: document.querySelector("#chkEnableProfileImageSync"), + txtLdapProfileImageAttribute: document.querySelector("#txtLdapProfileImageAttribute"), chkEnableAllFolders: document.querySelector('#chkEnableAllFolders'), folderAccessList: document.querySelector('.folderAccess'), txtPasswordResetUrl: document.querySelector("#txtLdapPasswordResetUrl") @@ -264,6 +279,8 @@

${HeaderLibraryAccess}

LdapConfigurationPage.txtLdapUidAttribute.value = config.LdapUidAttribute; LdapConfigurationPage.txtLdapUsernameAttribute.value = config.LdapUsernameAttribute; LdapConfigurationPage.txtLdapPasswordAttribute.value = config.LdapPasswordAttribute; + LdapConfigurationPage.chkEnableProfileImageSync.checked = config.EnableLdapProfileImageSync; + LdapConfigurationPage.txtLdapProfileImageAttribute.value = config.LdapProfileImageAttribute; config.EnableAllFolders = config.EnableAllFolders || false; LdapConfigurationPage.chkEnableAllFolders.checked = config.EnableAllFolders; /* Default to empty array if Enabled Folders is not set */ @@ -351,6 +368,8 @@

${HeaderLibraryAccess}

config.LdapUidAttribute = LdapConfigurationPage.txtLdapUidAttribute.value; config.LdapUsernameAttribute = LdapConfigurationPage.txtLdapUsernameAttribute.value; config.LdapPasswordAttribute = LdapConfigurationPage.txtLdapPasswordAttribute.value; + config.EnableLdapProfileImageSync = LdapConfigurationPage.chkEnableProfileImageSync.checked; + config.LdapProfileImageAttribute = LdapConfigurationPage.txtLdapProfileImageAttribute.value; /* Map the set of checked input items to an array of library Id's */ config.EnableAllFolders = LdapConfigurationPage.chkEnableAllFolders.checked || false; let folders = document.querySelectorAll('#folderList input'); diff --git a/LDAP-Auth/Helpers/ProfileImageUpdater.cs b/LDAP-Auth/Helpers/ProfileImageUpdater.cs new file mode 100644 index 0000000..36ad96f --- /dev/null +++ b/LDAP-Auth/Helpers/ProfileImageUpdater.cs @@ -0,0 +1,38 @@ +using System.IO; +using System.Net.Mime; +using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Providers; + +namespace Jellyfin.Plugin.LDAP_Auth.Helpers +{ + /// + /// Provides utility methods to update the profile image of a user. + /// + public static class ProfileImageUpdater + { + /// + /// Sets the profile image of a user to the provided image. + /// + /// The user to update. + /// The data representing the profile image to set. + /// Instance of the interface, used to retrieve the path to save the profile picture. + /// Instance of the interface, used to save the profile picture. + /// A representing the asynchronous operation. + public static async Task SetProfileImage( + User user, + byte[] ldapProfileImage, + IServerConfigurationManager serverConfigurationManager, + IProviderManager providerManager) + { + var userDataPath = Path.Combine(serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); + user.ProfileImage = new ImageInfo(Path.Combine(userDataPath, "profile.jpg")); + + using var profileImageMemoryStream = new MemoryStream(ldapProfileImage); + await providerManager + .SaveImage(profileImageMemoryStream, MediaTypeNames.Image.Jpeg, user.ProfileImage.Path) + .ConfigureAwait(false); + } + } +} diff --git a/LDAP-Auth/LDAPAuthenticationProviderPlugin.cs b/LDAP-Auth/LDAPAuthenticationProviderPlugin.cs index 319331c..2d87b1c 100644 --- a/LDAP-Auth/LDAPAuthenticationProviderPlugin.cs +++ b/LDAP-Auth/LDAPAuthenticationProviderPlugin.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Net.Security; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Threading.Tasks; @@ -12,7 +13,9 @@ using Jellyfin.Plugin.LDAP_Auth.Helpers; using MediaBrowser.Common; using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Users; using Microsoft.Extensions.Logging; using Novell.Directory.Ldap; @@ -44,6 +47,10 @@ public LdapAuthenticationProviderPlugin(IApplicationHost applicationHost, ILogge private string UsernameAttr => LdapPlugin.Instance.Configuration.LdapUsernameAttribute; + private bool EnableProfileImageSync => LdapPlugin.Instance.Configuration.EnableLdapProfileImageSync; + + private string ProfileImageAttr => LdapPlugin.Instance.Configuration.LdapProfileImageAttribute; + private string SearchFilter => LdapPlugin.Instance.Configuration.LdapSearchFilter; private string AdminFilter => LdapPlugin.Instance.Configuration.LdapAdminFilter; @@ -105,7 +112,7 @@ public async Task Authenticate(string username, st else { // Add the user to our Ldap users - LdapPlugin.Instance.Configuration.AddUser(user.Id, ldapUid); + LdapPlugin.Instance.Configuration.AddUser(user.Id, ldapUid, string.Empty); LdapPlugin.Instance.SaveConfiguration(); } } @@ -192,10 +199,21 @@ public async Task Authenticate(string username, st user.SetPreference(PreferenceKind.EnabledFolders, LdapPlugin.Instance.Configuration.EnabledFolders); } + var providerManager = _applicationHost.Resolve(); + var serverConfigurationManager = _applicationHost.Resolve(); + var ldapProfileImage = GetAttribute(ldapUser, ProfileImageAttr)?.ByteValue; + var ldapProfileImageHash = string.Empty; + if (ldapProfileImage is not null && EnableProfileImageSync) + { + ldapProfileImageHash = Convert.ToBase64String(MD5.HashData(ldapProfileImage)); + + await ProfileImageUpdater.SetProfileImage(user, ldapProfileImage, serverConfigurationManager, providerManager).ConfigureAwait(false); + } + await userManager.UpdateUserAsync(user).ConfigureAwait(false); // Add the user to our Ldap users - LdapPlugin.Instance.Configuration.AddUser(user.Id, ldapUid); + LdapPlugin.Instance.Configuration.AddUser(user.Id, ldapUid, ldapProfileImageHash); LdapPlugin.Instance.SaveConfiguration(); } else @@ -455,7 +473,7 @@ public LdapEntry LocateLdapUser(string username) LdapPlugin.Instance.Configuration.LdapBaseDn, LdapConnection.ScopeSub, realSearchFilter, - new[] { UsernameAttr, UidAttr }, + new[] { UsernameAttr, UidAttr, ProfileImageAttr }, false); } catch (LdapException e) @@ -512,7 +530,13 @@ public Task RedeemPasswordResetPin(string pin) throw new NotImplementedException(); } - private LdapAttribute GetAttribute(LdapEntry userEntry, string attr) + /// + /// Retrieves the requested attribute from a . + /// + /// The to retrieve the attribute from. + /// The attribute to retrieve from the . + /// The value of the or null if it does not exist. + public LdapAttribute GetAttribute(LdapEntry userEntry, string attr) { var attributeSet = userEntry.GetAttributeSet(); if (attributeSet.ContainsKey(attr)) diff --git a/LDAP-Auth/LdapProfileImageSyncTask.cs b/LDAP-Auth/LdapProfileImageSyncTask.cs new file mode 100644 index 0000000..c055f6f --- /dev/null +++ b/LDAP-Auth/LdapProfileImageSyncTask.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.LDAP_Auth.Helpers; +using MediaBrowser.Common; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; +using Novell.Directory.Ldap; + +namespace Jellyfin.Plugin.LDAP_Auth +{ + /// + /// Ldap Authentication Provider Plugin. + /// + public class LdapProfileImageSyncTask : IScheduledTask + { + private readonly ILocalizationManager _localization; + private readonly IApplicationHost _applicationHost; + private readonly ILogger _logger; + private readonly IUserManager _userManager; + private readonly IProviderManager _providerManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public LdapProfileImageSyncTask( + IApplicationHost applicationHost, + IUserManager userManager, + IProviderManager providerManager, + IServerConfigurationManager serverConfigurationManager, + ILogger logger, + ILocalizationManager localization) + { + _logger = logger; + _localization = localization; + _applicationHost = applicationHost; + _userManager = userManager; + _providerManager = providerManager; + _serverConfigurationManager = serverConfigurationManager; + } + + private bool EnableProfileImageSync => LdapPlugin.Instance.Configuration.EnableLdapProfileImageSync; + + private string ProfileImageAttr => LdapPlugin.Instance.Configuration.LdapProfileImageAttribute; + + /// + public string Name => "LDAP - Synchronize profile images"; + + /// + public string Key => "LdapProfileImageSync"; + + /// + public string Description => "Synchronizes user profile images from LDAP."; + + /// + public string Category => _localization.GetLocalizedString("TasksApplicationCategory"); + + /// + public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + if (!EnableProfileImageSync) + { + _logger.LogDebug("Synchronizing profile images is deactivated"); + return; + } + + var ldapAuthProvider = _applicationHost.GetExports(false).First(); + var updatePluginConfig = false; + + foreach (var configUser in LdapPlugin.Instance.Configuration.GetAllLdapUsers()) + { + var user = _userManager.GetUserById(configUser.LinkedJellyfinUserId); + LdapEntry ldapUser; + try + { + ldapUser = ldapAuthProvider.LocateLdapUser(configUser.LdapUid); + } + catch (AuthenticationException) + { + _logger.LogWarning("User '{configUser}' is not found in LDAP. Cannot synchronize profile image.", configUser.LdapUid); + continue; + } + + var ldapProfileImage = ldapAuthProvider.GetAttribute(ldapUser, ProfileImageAttr)?.ByteValue; + + if (ldapProfileImage is not null) + { + // Found a profile image in LDAP data. Check if image changed since last synchronization and update if so + var ldapProfileImageHash = Convert.ToBase64String(MD5.HashData(ldapProfileImage)); + + if (user.ProfileImage is null || + !string.Equals(ldapProfileImageHash, configUser.ProfileImageHash, StringComparison.Ordinal)) + { + _logger.LogDebug("Updating profile image for user {Username}", configUser.LdapUid); + + if (user.ProfileImage is not null) + { + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); + } + + await ProfileImageUpdater.SetProfileImage(user, ldapProfileImage, _serverConfigurationManager, _providerManager).ConfigureAwait(false); + configUser.ProfileImageHash = ldapProfileImageHash; + updatePluginConfig = true; + + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + } + } + else if (user.ProfileImage is not null) + { + // Did not find a profile image in LDAP data but user still has a profile image set. Reset it. + _logger.LogDebug("Removing profile image for user {Username}", configUser.LdapUid); + + try + { + File.Delete(user.ProfileImage.Path); + } + catch (IOException e) + { + _logger.LogError(e, "Error deleting user profile image during LDAP user profile image update"); + } + + configUser.ProfileImageHash = string.Empty; + updatePluginConfig = true; + + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); + } + } + + if (updatePluginConfig) + { + LdapPlugin.Instance.SaveConfiguration(); + } + } + + /// + public IEnumerable GetDefaultTriggers() + { + return new[] + { + new TaskTriggerInfo + { + Type = TaskTriggerInfo.TriggerInterval, + IntervalTicks = TimeSpan.FromHours(24).Ticks + } + }; + } + } +} \ No newline at end of file