Files
WatchedTogether/Jellyfin.Plugin.WatchedTogether/Auth/SharedAccountAuthenticationProvider.cs
T
dtourolle 7be07d16a2
🏗️ Build Plugin / build (push) Has been cancelled
🧪 Test Plugin / test (push) Has been cancelled
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.
2026-07-29 00:00:13 +02:00

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;
}
}
}