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 Services.IGroupService _groupService;
private readonly Services.IDynamicGroupService _dynamicGroupService;
private readonly ILogger _logger;
///
/// Initializes a new instance of the class.
///
/// The crypto provider used to verify stored password hashes.
/// The group service.
/// The on-demand group creation service.
/// The logger.
public SharedAccountAuthenticationProvider(
ICryptoProvider cryptoProvider,
Services.IGroupService groupService,
Services.IDynamicGroupService 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
.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.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.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;
}
}
}