Provisioning assigned AuthenticationProviderId and only then called IUserManager.ChangePassword. Jellyfin dispatches that call to the provider the user is currently assigned to, so it reached this plugin's own ChangePassword, which refuses by design. Creating a group by typing "alice+bob" at the login screen therefore died with NotSupportedException. Set the placeholder password first, while the freshly created account is still on Jellyfin's default provider, then claim it. The new end-to-end tests wire the real provisioning, group and authentication services together rather than mocking IProvisioningService, and cover a group created on demand being unlocked afterwards by either member's password. With the old ordering restored, six of them fail with the original exception. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
287 lines
12 KiB
C#
287 lines
12 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);
|
|
|
|
// 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.
|
|
//
|
|
// This must happen before the account is claimed below. IUserManager.ChangePassword
|
|
// dispatches to the provider the user is currently assigned to, and ours refuses the call
|
|
// by design, so claiming first would make provisioning throw NotSupportedException. A
|
|
// freshly created user is still on Jellyfin's default provider, which stores the hash.
|
|
await _userManager.ChangePassword(sharedUser, GenerateUnusedPassword()).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;
|
|
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);
|
|
}
|
|
}
|