using System; using System.Threading.Tasks; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Authentication; using MediaBrowser.Model.Cryptography; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.WatchedTogether.Auth; /// /// Authenticates a shared account against the passwords of each of its members. /// /// /// /// Jellyfin selects a provider per user via User.AuthenticationProviderId, so this provider /// only ever sees shared accounts that provisioning assigned to it. Implementing /// means Jellyfin hands us the already-resolved shared account /// rather than us having to look it up by name. /// /// /// Verification reads each member's stored hash directly instead of calling /// IUserManager.AuthenticateUser. Going through the normal flow would trip every member's /// failed-attempt counter each time a different member's password was the one that /// matched, eventually locking out members who did nothing wrong. Reading the live hash also means /// member password changes take effect immediately, with no second copy of any credential stored. /// /// public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IRequiresResolvedUser { private readonly ICryptoProvider _cryptoProvider; private readonly Lazy _groupService; private readonly Lazy _dynamicGroupService; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The crypto provider used to verify stored password hashes. /// A deferred handle to the group service. /// A deferred handle to the on-demand group creation service. /// The logger. /// /// The group services are taken as to break a container-level cycle. /// Jellyfin's UserManager constructor-injects every , /// so resolving those services eagerly here would require IUserManager while it is still /// being built and the host would refuse to start. Deferring the lookup to the first /// authentication is safe: nobody can log in until the host is fully up. /// public SharedAccountAuthenticationProvider( ICryptoProvider cryptoProvider, Lazy groupService, Lazy dynamicGroupService, ILogger logger) { _cryptoProvider = cryptoProvider; _groupService = groupService; _dynamicGroupService = dynamicGroupService; _logger = logger; } /// public string Name => "Watched Together Shared Account"; /// public bool IsEnabled => true; /// /// /// Jellyfin calls the overload instead, so this exists only /// to satisfy the interface. /// public Task Authenticate(string username, string password) => Authenticate(username, password, null); /// public async Task Authenticate(string username, string password, User? resolvedUser) { // Jellyfin passes a null user when no account matches the typed name, and offers the login // to every enabled provider. That is the hook for "type alice+bob and the account appears": // a real user with that exact name always resolves first and never reaches this branch. if (resolvedUser is null) { var created = await _dynamicGroupService.Value .TryCreateFromLoginAsync(username, password) .ConfigureAwait(false); if (created is null) { throw new AuthenticationException("Invalid username or password."); } return new ProviderAuthenticationResult { Username = created.SharedUsername }; } var group = _groupService.Value.GetGroupForSharedUser(resolvedUser.Id); if (group is null) { // Either not one of ours, or the group is disabled. Either way this account has no // member passwords to check, so it cannot be unlocked. _logger.LogWarning( "Rejected login for {Username}: no enabled Watched Together group owns this account", resolvedUser.Username); throw new AuthenticationException("Invalid username or password."); } var members = _groupService.Value.GetEligibleMembers(group); if (members.Count == 0) { _logger.LogWarning( "Rejected login for {Username}: group has no eligible members", resolvedUser.Username); throw new AuthenticationException("Invalid username or password."); } foreach (var member in members) { if (!VerifyPassword(member, password)) { continue; } _logger.LogInformation( "Shared account {SharedUsername} unlocked by member {MemberUsername}", resolvedUser.Username, member.Username); return new ProviderAuthenticationResult { Username = resolvedUser.Username }; } _logger.LogWarning( "Rejected login for {Username}: no member password matched", resolvedUser.Username); throw new AuthenticationException("Invalid username or password."); } /// /// /// A shared account always has a password in the sense that matters to Jellyfin: some member /// credential is required. Returning false would let clients offer a passwordless login. /// public bool HasPassword(User user) => true; /// /// /// Shared accounts have no password of their own to change - members change their own passwords /// in the normal way and the effect is picked up on the next login. /// public Task ChangePassword(User user, string newPassword) { throw new NotSupportedException( "A Watched Together shared account has no password of its own. Members change their own passwords instead."); } /// /// Verifies a submitted password against a member's live stored hash. /// /// The member whose stored credential to check. /// The submitted password. /// true if the password matches. private bool VerifyPassword(User member, string password) { if (string.IsNullOrEmpty(member.Password)) { // A member with no password set cannot contribute a credential to the group. return false; } try { var hash = PasswordHash.Parse(member.Password); return _cryptoProvider.Verify(hash, 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}; skipping", member.Id); return false; } } }