Implement Watched Together shared viewing accounts
Replaces the plugin template with a working plugin that lets several users share one viewing account while keeping their individual watched lists accurate. Three pieces: - Auto-creating groups. Logging in as "alice+bob" with any named member's own password provisions the shared account and signs you in. Verified against 10.11.5: AuthenticateUser offers unmatched usernames to every enabled provider and re-queries afterwards, which is the hook this relies on. Gated on a real member password so knowing two usernames is not enough to create an account. - Multi-password authentication. IRequiresResolvedUser hands us the resolved shared account; each member's live stored hash is checked via ICryptoProvider.Verify. Deliberately avoids re-entering UserManager.AuthenticateUser, which would trip every member's failed-attempt counter whenever a different member's password matched. - One-way played-state sync. Shared account to members only, filtered to PlaybackFinished/TogglePlayed/Import so playback progress ticks are ignored. No loop guard needed: member writes carry a non-shared id. Membership is stored as user IDs rather than re-parsed from the username, so shared accounts can be renamed freely. The +/name collision resolves itself because Jellyfin only consults the plugin when no local user matches the typed name. Targets Jellyfin 10.11.x / net9.0. Adds Gitea CI (test, build, release), a builder image, and 34 tests covering the auth and sync rules.
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a shared account against the passwords of each of its members.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Jellyfin selects a provider per user via <c>User.AuthenticationProviderId</c>, so this provider
|
||||
/// only ever sees shared accounts that provisioning assigned to it. Implementing
|
||||
/// <see cref="IRequiresResolvedUser"/> means Jellyfin hands us the already-resolved shared account
|
||||
/// rather than us having to look it up by name.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Verification reads each member's stored hash directly instead of calling
|
||||
/// <c>IUserManager.AuthenticateUser</c>. Going through the normal flow would trip every member's
|
||||
/// failed-attempt counter each time a <em>different</em> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IRequiresResolvedUser
|
||||
{
|
||||
private readonly ICryptoProvider _cryptoProvider;
|
||||
private readonly Services.IGroupService _groupService;
|
||||
private readonly Services.IDynamicGroupService _dynamicGroupService;
|
||||
private readonly ILogger<SharedAccountAuthenticationProvider> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SharedAccountAuthenticationProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="cryptoProvider">The crypto provider used to verify stored password hashes.</param>
|
||||
/// <param name="groupService">The group service.</param>
|
||||
/// <param name="dynamicGroupService">The on-demand group creation service.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public SharedAccountAuthenticationProvider(
|
||||
ICryptoProvider cryptoProvider,
|
||||
Services.IGroupService groupService,
|
||||
Services.IDynamicGroupService dynamicGroupService,
|
||||
ILogger<SharedAccountAuthenticationProvider> logger)
|
||||
{
|
||||
_cryptoProvider = cryptoProvider;
|
||||
_groupService = groupService;
|
||||
_dynamicGroupService = dynamicGroupService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Watched Together Shared Account";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabled => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Jellyfin calls the <see cref="IRequiresResolvedUser"/> overload instead, so this exists only
|
||||
/// to satisfy the interface.
|
||||
/// </remarks>
|
||||
public Task<ProviderAuthenticationResult> Authenticate(string username, string password)
|
||||
=> Authenticate(username, password, null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProviderAuthenticationResult> 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.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A shared account always has a password in the sense that matters to Jellyfin: some member
|
||||
/// credential is required. Returning <c>false</c> would let clients offer a passwordless login.
|
||||
/// </remarks>
|
||||
public bool HasPassword(User user) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a submitted password against a member's live stored hash.
|
||||
/// </summary>
|
||||
/// <param name="member">The member whose stored credential to check.</param>
|
||||
/// <param name="password">The submitted password.</param>
|
||||
/// <returns><c>true</c> if the password matches.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user