using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Jellyfin.Data; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Plugin.WatchedTogether.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Cryptography; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.WatchedTogether.Services; /// /// Creates a shared account the first time someone logs in as "alice+bob". /// /// /// /// Jellyfin only routes a login to the providers with a null resolved user when no local /// user matches the typed name. A real account named "alice+bob" therefore always wins, and this /// code never sees it - the ambiguity between a group and a same-named user resolves in favour of /// the real user automatically. /// /// /// Every named member must exist and none may be a shared account, so an unrelated username /// containing the separator simply fails to resolve and is rejected. /// /// public class DynamicGroupService : IDynamicGroupService { private readonly IUserManager _userManager; private readonly IProvisioningService _provisioningService; private readonly ICryptoProvider _cryptoProvider; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The user manager. /// The provisioning service. /// The crypto provider. /// The logger. public DynamicGroupService( IUserManager userManager, IProvisioningService provisioningService, ICryptoProvider cryptoProvider, ILogger logger) { _userManager = userManager; _provisioningService = provisioningService; _cryptoProvider = cryptoProvider; _logger = logger; } /// public async Task TryCreateFromLoginAsync(string enteredUsername, string password) { var config = Plugin.Instance?.Configuration; if (config is null || !config.EnableDynamicGroups) { return null; } if (string.IsNullOrWhiteSpace(enteredUsername)) { return null; } var separator = string.IsNullOrEmpty(config.NameSeparator) ? "+" : config.NameSeparator; var parts = enteredUsername .Split(separator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) .ToList(); // Needs at least two names to be a group at all. if (parts.Count < 2) { return null; } // Every part must name a real, non-shared, enabled user. Anything else means this is not a // group login, so fall through and let the attempt fail normally. var members = new List(parts.Count); foreach (var part in parts) { var member = _userManager.GetUserByName(part); if (member is null) { _logger.LogDebug("Dynamic group login rejected: no user named {Part}", part); return null; } if (config.Groups.Any(g => g.SharedUserId == member.Id)) { _logger.LogWarning( "Dynamic group login rejected: {Part} is itself a shared account", part); return null; } if (member.HasPermission(PermissionKind.IsDisabled)) { _logger.LogWarning("Dynamic group login rejected: {Part} is disabled", part); return null; } if (members.Any(m => m.Id == member.Id)) { _logger.LogWarning("Dynamic group login rejected: {Part} named more than once", part); return null; } members.Add(member); } // The password must belong to one of the named members. Without this any visitor could // conjure a shared account out of two usernames they happened to know. if (!members.Any(m => VerifyPassword(m, password))) { _logger.LogWarning( "Dynamic group login for {Username} rejected: no named member's password matched", enteredUsername); return null; } // The account is limited to the libraries all named members share, so creating one at the // login screen cannot grant anybody access they did not already have. var group = await _provisioningService.CreateGroupAsync( members.Select(m => m.Id).ToList(), enteredUsername).ConfigureAwait(false); var sharedUser = _userManager.GetUserById(group.SharedUserId); if (sharedUser is null) { return null; } _logger.LogInformation( "Created shared account {Username} on demand for {MemberCount} members", sharedUser.Username, members.Count); return new DynamicGroupResult(group, sharedUser.Username); } /// /// Verifies a submitted password against a member's live stored hash. /// /// The member to check. /// The submitted password. /// true if the password matches. private bool VerifyPassword(User member, string password) { if (string.IsNullOrEmpty(member.Password)) { return false; } try { return _cryptoProvider.Verify(PasswordHash.Parse(member.Password), password); } catch (Exception ex) when (ex is FormatException or ArgumentException) { // Never log the hash or the submitted password. _logger.LogError(ex, "Could not parse the stored password hash for member {MemberId}", member.Id); return false; } } }