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.
180 lines
7.2 KiB
C#
180 lines
7.2 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|