Files
WatchedTogether/Jellyfin.Plugin.WatchedTogether/Services/ProvisioningService.cs
T
dtourolle 5c8430f207
🏗️ Build Plugin / build (push) Successful in 38s
🧪 Test Plugin / test (push) Successful in 34s
Treat member order as insignificant when resolving a group
"jane+john" and "john+jane" name the same group, but they did not behave
that way. Jellyfin only routes a login here when no account matches the
typed name, so logging in with the reversed spelling of an existing group
found nothing and quietly created a second shared account for the same
two people - each with its own watched state.

Group identity is now order-independent:

- Member names are sorted alphabetically when building an account name,
  so a given set of members always produces the same name.
- Before creating anything, the login path looks for an existing group
  whose members are exactly the named set, compared as a set rather than
  a sequence, and logs into that account if it finds one.
- Stored member lists are kept in the same canonical order on create and
  update, so a group's stored order does not depend on the order an
  admin happened to select members in.

Passing no name through to provisioning lets it generate the canonical
name, rather than preserving whatever order was typed.

Members are also now checked in the order they were typed, stopping at
the first match, so whoever puts their own name first is verified first.
Verification is a deliberately slow hash comparison, so the ordering is
worth having; it is only a preference, and any member's password still
unlocks the group.
2026-07-31 09:35:14 +02:00

282 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.WatchedTogether.Configuration;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.WatchedTogether.Services;
/// <summary>
/// Creates and maintains shared accounts and the groups that describe them.
/// </summary>
public class ProvisioningService : IProvisioningService
{
/// <summary>
/// The database column limit on usernames. A generated name is shortened to fit.
/// </summary>
private const int MaxUsernameLength = 255;
/// <summary>
/// The provider key Jellyfin stores on a shared account to route its logins to us. Jellyfin
/// resolves providers by <c>GetType().FullName</c>, so this must match the provider type's
/// full name exactly.
/// </summary>
private static readonly string AuthProviderId =
typeof(Auth.SharedAccountAuthenticationProvider).FullName!;
private readonly IUserManager _userManager;
private readonly ILibraryAccessService _libraryAccessService;
private readonly ILogger<ProvisioningService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ProvisioningService"/> class.
/// </summary>
/// <param name="userManager">The user manager.</param>
/// <param name="libraryAccessService">The library access service.</param>
/// <param name="logger">The logger.</param>
public ProvisioningService(
IUserManager userManager,
ILibraryAccessService libraryAccessService,
ILogger<ProvisioningService> logger)
{
_userManager = userManager;
_libraryAccessService = libraryAccessService;
_logger = logger;
}
/// <inheritdoc />
public async Task<SharedGroup> CreateGroupAsync(IReadOnlyList<Guid> memberIds, string? name)
{
ArgumentNullException.ThrowIfNull(memberIds);
var plugin = Plugin.Instance
?? throw new InvalidOperationException("The plugin is not initialised.");
var config = plugin.Configuration;
var distinctIds = memberIds.Distinct().ToList();
if (distinctIds.Count < 2)
{
throw new ArgumentException("A group needs at least two distinct members.", nameof(memberIds));
}
var members = new List<Jellyfin.Database.Implementations.Entities.User>(distinctIds.Count);
foreach (var id in distinctIds)
{
var member = _userManager.GetUserById(id)
?? throw new ArgumentException(
string.Format(CultureInfo.InvariantCulture, "No user exists with id {0}.", id),
nameof(memberIds));
// A shared account must not become a member of another group: its own login is already
// a union of other people's credentials, and nesting would compound that invisibly.
if (config.Groups.Any(g => g.SharedUserId == id))
{
throw new ArgumentException(
string.Format(
CultureInfo.InvariantCulture,
"'{0}' is itself a shared account and cannot be a member of a group.",
member.Username),
nameof(memberIds));
}
members.Add(member);
}
// Store members in the same alphabetical order the generated name uses, so that the stored
// order is canonical however the members were supplied. Password checks then always run in
// a predictable order too.
members = members.OrderBy(m => m.Username, StringComparer.OrdinalIgnoreCase).ToList();
distinctIds = members.Select(m => m.Id).ToList();
var accountName = string.IsNullOrWhiteSpace(name)
? BuildDefaultName(members.Select(m => m.Username), config.NameSeparator)
: name.Trim();
var sharedUser = await _userManager.CreateUserAsync(accountName).ConfigureAwait(false);
// Route this account's logins through our provider. Jellyfin matches providers by
// GetType().FullName, the same key the SSO plugin uses, and the assignment only sticks
// once the user is updated.
sharedUser.AuthenticationProviderId = AuthProviderId;
// The shared account never authenticates against its own password - our provider checks
// member hashes instead. Setting a random one avoids leaving a passwordless account behind
// if the provider is ever unassigned.
await _userManager.ChangePassword(sharedUser, GenerateUnusedPassword()).ConfigureAwait(false);
await _userManager.UpdateUserAsync(sharedUser).ConfigureAwait(false);
await ApplyLibraryAccessAsync(sharedUser.Id, distinctIds).ConfigureAwait(false);
var group = new SharedGroup
{
SharedUserId = sharedUser.Id,
MemberUserIds = distinctIds
};
config.Groups.Add(group);
plugin.UpdateConfiguration(config);
_logger.LogInformation(
"Created shared account {Username} ({SharedUserId}) with {MemberCount} members",
sharedUser.Username,
sharedUser.Id,
distinctIds.Count);
return group;
}
/// <inheritdoc />
public async Task<SharedGroup> UpdateGroupAsync(
Guid sharedUserId,
IReadOnlyList<Guid> memberIds,
bool syncUnwatched,
bool syncPlayCount,
bool isDisabled)
{
ArgumentNullException.ThrowIfNull(memberIds);
var plugin = Plugin.Instance
?? throw new InvalidOperationException("The plugin is not initialised.");
var config = plugin.Configuration;
var group = config.Groups.FirstOrDefault(g => g.SharedUserId == sharedUserId)
?? throw new ArgumentException("No group exists for that shared account.", nameof(sharedUserId));
var distinctIds = memberIds.Distinct().ToList();
if (distinctIds.Count < 2)
{
throw new ArgumentException("A group needs at least two distinct members.", nameof(memberIds));
}
foreach (var id in distinctIds)
{
if (_userManager.GetUserById(id) is null)
{
throw new ArgumentException(
string.Format(CultureInfo.InvariantCulture, "No user exists with id {0}.", id),
nameof(memberIds));
}
if (id == sharedUserId || config.Groups.Any(g => g.SharedUserId == id))
{
throw new ArgumentException(
"A shared account cannot be a member of a group.",
nameof(memberIds));
}
}
// Keep the stored order canonical, matching how groups are created.
distinctIds = distinctIds
.OrderBy(id => _userManager.GetUserById(id)?.Username, StringComparer.OrdinalIgnoreCase)
.ToList();
group.MemberUserIds = distinctIds;
group.SyncUnwatched = syncUnwatched;
group.SyncPlayCount = syncPlayCount;
group.IsDisabled = isDisabled;
plugin.UpdateConfiguration(config);
// Membership drives library access, so recompute it: adding a member can only narrow the
// intersection, and removing one may widen it.
await ApplyLibraryAccessAsync(sharedUserId, distinctIds).ConfigureAwait(false);
_logger.LogInformation(
"Updated group {SharedUserId}: {MemberCount} members, disabled={IsDisabled}",
sharedUserId,
distinctIds.Count,
isDisabled);
return group;
}
/// <inheritdoc />
public async Task DeleteGroupAsync(Guid sharedUserId, bool deleteSharedUser)
{
var plugin = Plugin.Instance
?? throw new InvalidOperationException("The plugin is not initialised.");
var config = plugin.Configuration;
var group = config.Groups.FirstOrDefault(g => g.SharedUserId == sharedUserId)
?? throw new ArgumentException("No group exists for that shared account.", nameof(sharedUserId));
// Drop the group first: if account deletion fails we are left with an orphaned account
// rather than a group pointing at a user that may be half-deleted.
config.Groups.Remove(group);
plugin.UpdateConfiguration(config);
if (deleteSharedUser && _userManager.GetUserById(sharedUserId) is not null)
{
await _userManager.DeleteUserAsync(sharedUserId).ConfigureAwait(false);
_logger.LogInformation("Deleted shared account {SharedUserId}", sharedUserId);
}
_logger.LogInformation("Removed group {SharedUserId}", sharedUserId);
}
/// <summary>
/// Joins member names into a display name, falling back to a generic name if the result would
/// exceed the username column limit.
/// </summary>
/// <remarks>
/// Names are sorted alphabetically so that a given set of members always produces the same
/// account name. Without this, "jane+john" and "john+jane" would be two different names for the
/// same group and would end up as two separate accounts.
/// </remarks>
/// <param name="usernames">The member usernames.</param>
/// <param name="separator">The configured separator.</param>
/// <returns>A name that fits within the username length limit.</returns>
private static string BuildDefaultName(IEnumerable<string> usernames, string separator)
{
var sep = string.IsNullOrEmpty(separator) ? "+" : separator;
var joined = string.Join(sep, usernames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase));
if (joined.Length <= MaxUsernameLength)
{
return joined;
}
// Truncating mid-name would produce something misleading, so switch to a neutral label
// with a short unique suffix instead.
return string.Format(
CultureInfo.InvariantCulture,
"Shared-{0}",
Guid.NewGuid().ToString("N")[..8]);
}
/// <summary>
/// Generates a random password that is never used for authentication.
/// </summary>
/// <returns>A random password string.</returns>
private static string GenerateUnusedPassword()
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
/// <summary>
/// Sets library access on the shared account.
/// </summary>
/// <param name="sharedUserId">The shared account.</param>
/// <param name="memberIds">The members whose access is intersected.</param>
/// <returns>A task representing the update.</returns>
private async Task ApplyLibraryAccessAsync(Guid sharedUserId, IReadOnlyList<Guid> memberIds)
{
var user = _userManager.GetUserById(sharedUserId);
if (user is null)
{
return;
}
// Never "all folders": the shared account gets an explicit list of the libraries every
// member can already reach, so joining a group can never grant access to anything.
user.SetPermission(PermissionKind.EnableAllFolders, false);
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
await _libraryAccessService.ApplyIntersectionAsync(sharedUserId, memberIds).ConfigureAwait(false);
}
}