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.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
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;
|
||||
}
|
||||
|
||||
var group = await _provisioningService.CreateGroupAsync(
|
||||
members.Select(m => m.Id).ToList(),
|
||||
enteredUsername,
|
||||
enableAllFolders: config.DynamicGroupsEnableAllFolders,
|
||||
enabledFolders: null).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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user