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;
///
/// Creates a shared account the first time someone logs in as "alice+bob".
///
///
///
/// Jellyfin only routes a login to the providers with a null resolved user when no 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.
///
///
/// 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.
///
///
public class DynamicGroupService : IDynamicGroupService
{
private readonly IUserManager _userManager;
private readonly IProvisioningService _provisioningService;
private readonly ICryptoProvider _cryptoProvider;
private readonly ILogger _logger;
///
/// Initializes a new instance of the class.
///
/// The user manager.
/// The provisioning service.
/// The crypto provider.
/// The logger.
public DynamicGroupService(
IUserManager userManager,
IProvisioningService provisioningService,
ICryptoProvider cryptoProvider,
ILogger logger)
{
_userManager = userManager;
_provisioningService = provisioningService;
_cryptoProvider = cryptoProvider;
_logger = logger;
}
///
public async Task 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(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);
}
///
/// Verifies a submitted password against a member's live stored hash.
///
/// The member to check.
/// The submitted password.
/// true if the password matches.
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;
}
}
}