Files
WatchedTogether/Jellyfin.Plugin.WatchedTogether/Services/DynamicGroupService.cs
T
dtourolle b4134dd744 Grant shared accounts the intersection of member library access
Previously the shared account's libraries were chosen independently of
its members, so a group could see a library that one of its members was
blocked from - joining a group became a way to gain access. That was
especially sharp with auto-created groups, where no admin is in the loop.

A shared account is now granted exactly the libraries every member can
already reach. If one member is blocked from a library, no group
containing them can see it. The account is therefore always a subset of
what each member could reach alone, which is what makes creating groups
at the login screen safe to leave on by default.

Details:

- "Enable all folders" is expanded to concrete library ids before
  intersecting, since it cannot otherwise be compared with an explicit
  list. Shared accounts are always given an explicit list, never the
  all-folders permission, so newly added libraries do not silently widen
  an existing group.
- Explicitly blocked folders are subtracted even for members who
  otherwise have access to everything.
- Fails closed: an unresolvable member contributes no access rather than
  being treated as unrestricted.
- Recomputed when membership changes, and re-applied to every group at
  startup so narrowing a member's own access narrows their groups.

Drops the now-meaningless EnableAllFolders/EnabledFolders provisioning
inputs and the DynamicGroupsEnableAllFolders setting. Adds 8 tests
covering the intersection rules.
2026-07-29 00:15:32 +02:00

172 lines
6.1 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.WatchedTogether.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Cryptography;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.WatchedTogether.Services;
/// <summary>
/// Creates a shared account the first time someone logs in as "alice+bob".
/// </summary>
/// <remarks>
/// <para>
/// Jellyfin only routes a login to the providers with a null resolved user when <em>no</em> local
/// user matches the typed name. A real account named "alice+bob" therefore always wins, and this
/// code never sees it - the ambiguity between a group and a same-named user resolves in favour of
/// the real user automatically.
/// </para>
/// <para>
/// Every named member must exist and none may be a shared account, so an unrelated username
/// containing the separator simply fails to resolve and is rejected.
/// </para>
/// </remarks>
public class DynamicGroupService : IDynamicGroupService
{
private readonly IUserManager _userManager;
private readonly IProvisioningService _provisioningService;
private readonly ICryptoProvider _cryptoProvider;
private readonly ILogger<DynamicGroupService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DynamicGroupService"/> class.
/// </summary>
/// <param name="userManager">The user manager.</param>
/// <param name="provisioningService">The provisioning service.</param>
/// <param name="cryptoProvider">The crypto provider.</param>
/// <param name="logger">The logger.</param>
public DynamicGroupService(
IUserManager userManager,
IProvisioningService provisioningService,
ICryptoProvider cryptoProvider,
ILogger<DynamicGroupService> logger)
{
_userManager = userManager;
_provisioningService = provisioningService;
_cryptoProvider = cryptoProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task<DynamicGroupResult?> TryCreateFromLoginAsync(string enteredUsername, string password)
{
var config = Plugin.Instance?.Configuration;
if (config is null || !config.EnableDynamicGroups)
{
return null;
}
if (string.IsNullOrWhiteSpace(enteredUsername))
{
return null;
}
var separator = string.IsNullOrEmpty(config.NameSeparator) ? "+" : config.NameSeparator;
var parts = enteredUsername
.Split(separator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.ToList();
// Needs at least two names to be a group at all.
if (parts.Count < 2)
{
return null;
}
// Every part must name a real, non-shared, enabled user. Anything else means this is not a
// group login, so fall through and let the attempt fail normally.
var members = new List<User>(parts.Count);
foreach (var part in parts)
{
var member = _userManager.GetUserByName(part);
if (member is null)
{
_logger.LogDebug("Dynamic group login rejected: no user named {Part}", part);
return null;
}
if (config.Groups.Any(g => g.SharedUserId == member.Id))
{
_logger.LogWarning(
"Dynamic group login rejected: {Part} is itself a shared account",
part);
return null;
}
if (member.HasPermission(PermissionKind.IsDisabled))
{
_logger.LogWarning("Dynamic group login rejected: {Part} is disabled", part);
return null;
}
if (members.Any(m => m.Id == member.Id))
{
_logger.LogWarning("Dynamic group login rejected: {Part} named more than once", part);
return null;
}
members.Add(member);
}
// The password must belong to one of the named members. Without this any visitor could
// conjure a shared account out of two usernames they happened to know.
if (!members.Any(m => VerifyPassword(m, password)))
{
_logger.LogWarning(
"Dynamic group login for {Username} rejected: no named member's password matched",
enteredUsername);
return null;
}
// The account is limited to the libraries all named members share, so creating one at the
// login screen cannot grant anybody access they did not already have.
var group = await _provisioningService.CreateGroupAsync(
members.Select(m => m.Id).ToList(),
enteredUsername).ConfigureAwait(false);
var sharedUser = _userManager.GetUserById(group.SharedUserId);
if (sharedUser is null)
{
return null;
}
_logger.LogInformation(
"Created shared account {Username} on demand for {MemberCount} members",
sharedUser.Username,
members.Count);
return new DynamicGroupResult(group, sharedUser.Username);
}
/// <summary>
/// Verifies a submitted password against a member's live stored hash.
/// </summary>
/// <param name="member">The member 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))
{
return false;
}
try
{
return _cryptoProvider.Verify(PasswordHash.Parse(member.Password), 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}", member.Id);
return false;
}
}
}