From 36af7fa7bf5272e19ad85daf9e3c463d2272bbed Mon Sep 17 00:00:00 2001 From: JPVenson Date: Tue, 26 May 2026 19:46:01 +0000 Subject: [PATCH 1/6] Add lockhelper for UserManager --- .../Users/UserManager.cs | 1878 +++++++++-------- 1 file changed, 971 insertions(+), 907 deletions(-) diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index faa68b9578..d6f90ee25e 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -31,957 +31,1021 @@ using MediaBrowser.Model.Users; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -namespace Jellyfin.Server.Implementations.Users +namespace Jellyfin.Server.Implementations.Users; + +/// +/// Manages the creation and retrieval of instances. +/// +public partial class UserManager : IUserManager, IDisposable { + private readonly IDbContextFactory _dbProvider; + private readonly IEventManager _eventManager; + private readonly INetworkManager _networkManager; + private readonly IApplicationHost _appHost; + private readonly IImageProcessor _imageProcessor; + private readonly ILogger _logger; + private readonly IReadOnlyCollection _passwordResetProviders; + private readonly IReadOnlyCollection _authenticationProviders; + private readonly InvalidAuthProvider _invalidAuthProvider; + private readonly DefaultAuthenticationProvider _defaultAuthenticationProvider; + private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; + private readonly IServerConfigurationManager _serverConfigurationManager; + + private readonly LockHelper _userLock = new(); + /// - /// Manages the creation and retrieval of instances. + /// Initializes a new instance of the class. /// - public partial class UserManager : IUserManager, IDisposable + /// The database provider. + /// The event manager. + /// The network manager. + /// The application host. + /// The image processor. + /// The logger. + /// The system config manager. + /// The password reset providers. + /// The authentication providers. + public UserManager( + IDbContextFactory dbProvider, + IEventManager eventManager, + INetworkManager networkManager, + IApplicationHost appHost, + IImageProcessor imageProcessor, + ILogger logger, + IServerConfigurationManager serverConfigurationManager, + IEnumerable passwordResetProviders, + IEnumerable authenticationProviders) { - private readonly IDbContextFactory _dbProvider; - private readonly IEventManager _eventManager; - private readonly INetworkManager _networkManager; - private readonly IApplicationHost _appHost; - private readonly IImageProcessor _imageProcessor; - private readonly ILogger _logger; - private readonly IReadOnlyCollection _passwordResetProviders; - private readonly IReadOnlyCollection _authenticationProviders; - private readonly InvalidAuthProvider _invalidAuthProvider; - private readonly DefaultAuthenticationProvider _defaultAuthenticationProvider; - private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; - private readonly IServerConfigurationManager _serverConfigurationManager; + _dbProvider = dbProvider; + _eventManager = eventManager; + _networkManager = networkManager; + _appHost = appHost; + _imageProcessor = imageProcessor; + _logger = logger; + _serverConfigurationManager = serverConfigurationManager; - private readonly AsyncKeyedLocker _userLock = new(); + _passwordResetProviders = passwordResetProviders.ToList(); + _authenticationProviders = authenticationProviders.ToList(); - /// - /// Initializes a new instance of the class. - /// - /// The database provider. - /// The event manager. - /// The network manager. - /// The application host. - /// The image processor. - /// The logger. - /// The system config manager. - /// The password reset providers. - /// The authentication providers. - public UserManager( - IDbContextFactory dbProvider, - IEventManager eventManager, - INetworkManager networkManager, - IApplicationHost appHost, - IImageProcessor imageProcessor, - ILogger logger, - IServerConfigurationManager serverConfigurationManager, - IEnumerable passwordResetProviders, - IEnumerable authenticationProviders) + _invalidAuthProvider = _authenticationProviders.OfType().First(); + _defaultAuthenticationProvider = _authenticationProviders.OfType().First(); + _defaultPasswordResetProvider = _passwordResetProviders.OfType().First(); + } + + /// + public event EventHandler>? OnUserUpdated; + + /// + public IEnumerable GetUsers() + { + using var dbContext = _dbProvider.CreateDbContext(); + return UserQuery(dbContext) + .ToArray(); + } + + /// + public IEnumerable GetUsersIds() + { + using var dbContext = _dbProvider.CreateDbContext(); + return dbContext.Users + .AsNoTracking() + .Select(user => user.Id) + .ToArray(); + } + + // This is some regex that matches only on unicode "word" characters, as well as -, _ and @ + // In theory this will cut out most if not all 'control' characters which should help minimize any weirdness + // Usernames can contain letters (a-z + whatever else unicode is cool with), numbers (0-9), at-signs (@), dashes (-), underscores (_), apostrophes ('), periods (.) and spaces ( ) + [GeneratedRegex(@"^(?!\s)[\w\ \-'._@+]+(? + public User? GetUserById(Guid id) + { + if (id.IsEmpty()) { - _dbProvider = dbProvider; - _eventManager = eventManager; - _networkManager = networkManager; - _appHost = appHost; - _imageProcessor = imageProcessor; - _logger = logger; - _serverConfigurationManager = serverConfigurationManager; - - _passwordResetProviders = passwordResetProviders.ToList(); - _authenticationProviders = authenticationProviders.ToList(); - - _invalidAuthProvider = _authenticationProviders.OfType().First(); - _defaultAuthenticationProvider = _authenticationProviders.OfType().First(); - _defaultPasswordResetProvider = _passwordResetProviders.OfType().First(); + throw new ArgumentException("Guid can't be empty", nameof(id)); } - /// - public event EventHandler>? OnUserUpdated; + using var dbContext = _dbProvider.CreateDbContext(); + return UserQuery(dbContext) + .FirstOrDefault(user => user.Id == id); + } - /// - public IEnumerable GetUsers() + private static IQueryable UserQuery(JellyfinDbContext dbContext) + { + return dbContext.Users + .AsSingleQuery() + .Include(user => user.Permissions) + .Include(user => user.Preferences) + .Include(user => user.AccessSchedules) + .Include(user => user.ProfileImage) + .AsNoTracking(); + } + + /// + public User? GetFirstUser() + { + using var dbContext = _dbProvider.CreateDbContext(); + return UserQuery(dbContext) + .FirstOrDefault(); + } + + /// + public User? GetUserByName(string name) + { + if (string.IsNullOrWhiteSpace(name)) { - using var dbContext = _dbProvider.CreateDbContext(); - return UserQuery(dbContext) - .ToArray(); + throw new ArgumentException("Invalid username", nameof(name)); } - /// - public IEnumerable GetUsersIds() - { - using var dbContext = _dbProvider.CreateDbContext(); - return dbContext.Users - .AsNoTracking() - .Select(user => user.Id) - .ToArray(); - } - - // This is some regex that matches only on unicode "word" characters, as well as -, _ and @ - // In theory this will cut out most if not all 'control' characters which should help minimize any weirdness - // Usernames can contain letters (a-z + whatever else unicode is cool with), numbers (0-9), at-signs (@), dashes (-), underscores (_), apostrophes ('), periods (.) and spaces ( ) - [GeneratedRegex(@"^(?!\s)[\w\ \-'._@+]+(? - public User? GetUserById(Guid id) - { - if (id.IsEmpty()) - { - throw new ArgumentException("Guid can't be empty", nameof(id)); - } - - using var dbContext = _dbProvider.CreateDbContext(); - return UserQuery(dbContext) - .FirstOrDefault(user => user.Id == id); - } - - private static IQueryable UserQuery(JellyfinDbContext dbContext) - { - return dbContext.Users - .AsSingleQuery() - .Include(user => user.Permissions) - .Include(user => user.Preferences) - .Include(user => user.AccessSchedules) - .Include(user => user.ProfileImage) - .AsNoTracking(); - } - - /// - public User? GetFirstUser() - { - using var dbContext = _dbProvider.CreateDbContext(); - return UserQuery(dbContext) - .FirstOrDefault(); - } - - /// - public User? GetUserByName(string name) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Invalid username", nameof(name)); - } - - using var dbContext = _dbProvider.CreateDbContext(); + using var dbContext = _dbProvider.CreateDbContext(); #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons - return UserQuery(dbContext) - .FirstOrDefault(u => u.NormalizedUsername == name.ToUpperInvariant()); + return UserQuery(dbContext) + .FirstOrDefault(u => u.NormalizedUsername == name.ToUpperInvariant()); #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + } + + /// + public async Task RenameUser(Guid userId, string oldName, string newName) + { + ThrowIfInvalidUsername(newName); + + if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("The new and old names must be different."); } - /// - public async Task RenameUser(Guid userId, string oldName, string newName) + User user = null!; // user is never actually null where its used afterwards so we can just ignore. + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { - ThrowIfInvalidUsername(newName); - - if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException("The new and old names must be different."); - } - - User user = null!; // user is never actually null where its used afterwards so we can just ignore. - using (await _userLock.LockAsync(userId).ConfigureAwait(false)) - { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { -#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons - if (await dbContext.Users - .AnyAsync(u => u.NormalizedUsername == newName.ToUpperInvariant() && u.Id != userId) - .ConfigureAwait(false)) - { - throw new ArgumentException(string.Format( - CultureInfo.InvariantCulture, - "A user with the name '{0}' already exists.", - newName)); - } -#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons - - user = await UserQuery(dbContext) - .AsTracking() - .FirstOrDefaultAsync(u => u.Id == userId) - .ConfigureAwait(false) - ?? throw new ResourceNotFoundException(nameof(userId)); - - user.Username = newName; - user.NormalizedUsername = newName.ToUpperInvariant(); - await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); - } - } - - var eventArgs = new UserUpdatedEventArgs(user); - await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false); - OnUserUpdated?.Invoke(this, eventArgs); - } - - /// - public async Task UpdateUserAsync(User user) - { - using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) - { - await UpdateUserInternalAsync(user).ConfigureAwait(false); - } - } - - internal async Task CreateUserInternalAsync(string name, JellyfinDbContext dbContext) - { - // TODO: Remove after user item data is migrated. - var max = await dbContext.Users.AsQueryable().AnyAsync().ConfigureAwait(false) - ? await dbContext.Users.AsQueryable().Select(u => u.InternalId).MaxAsync().ConfigureAwait(false) - : 0; - - var user = new User( - name, - _defaultAuthenticationProvider.GetType().FullName!, - _defaultPasswordResetProvider.GetType().FullName!) - { - InternalId = max + 1 - }; - - user.AddDefaultPermissions(); - user.AddDefaultPreferences(); - - return user; - } - - /// - public async Task CreateUserAsync(string name) - { - ThrowIfInvalidUsername(name); - - User newUser; var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons if (await dbContext.Users - .AnyAsync(u => u.NormalizedUsername == name.ToUpperInvariant()) + .AnyAsync(u => u.NormalizedUsername == newName.ToUpperInvariant() && u.Id != userId) .ConfigureAwait(false)) { throw new ArgumentException(string.Format( CultureInfo.InvariantCulture, "A user with the name '{0}' already exists.", - name)); + newName)); } #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons - newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false); - - dbContext.Users.Add(newUser); - await dbContext.SaveChangesAsync().ConfigureAwait(false); - } - - await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false); - - return newUser; - } - - /// - public async Task DeleteUserAsync(Guid userId) - { - User? user; - using (await _userLock.LockAsync(userId).ConfigureAwait(false)) - { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - user = await dbContext.Users - .Include(u => u.Permissions) - .FirstOrDefaultAsync(u => u.Id.Equals(userId)) - .ConfigureAwait(false); - - if (user is null) - { - throw new ResourceNotFoundException(nameof(userId)); - } - - var userCount = await dbContext.Users.CountAsync().ConfigureAwait(false); - if (userCount == 1) - { - throw new InvalidOperationException(string.Format( - CultureInfo.InvariantCulture, - "The user '{0}' cannot be deleted because there must be at least one user in the system.", - user.Username)); - } - - if (user.HasPermission(PermissionKind.IsAdministrator) - && await dbContext.Users - .CountAsync(i => i.Permissions.Any(p => p.Kind == PermissionKind.IsAdministrator && p.Value)) - .ConfigureAwait(false) == 1) - { - throw new ArgumentException( - string.Format( - CultureInfo.InvariantCulture, - "The user '{0}' cannot be deleted because there must be at least one admin user in the system.", - user.Username), - nameof(userId)); - } - - dbContext.Users.Remove(user); - await dbContext.SaveChangesAsync().ConfigureAwait(false); - } - } - - await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false); - } - - /// - public Task ResetPassword(Guid userId) - { - return ChangePassword(userId, string.Empty); - } - - /// - public async Task ChangePassword(Guid userId, string newPassword) - { - User dbUser = null!; - using (await _userLock.LockAsync(userId).ConfigureAwait(false)) - { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - dbUser = await UserQuery(dbContext) - .AsTracking() - .FirstOrDefaultAsync(u => u.Id == userId) - .ConfigureAwait(false) - ?? throw new ResourceNotFoundException(nameof(userId)); - - if (dbUser.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword)) - { - throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword)); - } - - await GetAuthenticationProvider(dbUser).ChangePassword(dbUser, newPassword).ConfigureAwait(false); - await dbContext.SaveChangesAsync().ConfigureAwait(false); - } - } - - await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(dbUser)).ConfigureAwait(false); - } - - /// - public UserDto GetUserDto(User user, string? remoteEndPoint = null) - { - var hasPassword = GetAuthenticationProvider(user).HasPassword(user); - var castReceiverApplications = _serverConfigurationManager.Configuration.CastReceiverApplications; - return new UserDto - { - Name = user.Username, - Id = user.Id, - ServerId = _appHost.SystemId, - HasPassword = hasPassword, - HasConfiguredPassword = hasPassword, - EnableAutoLogin = user.EnableAutoLogin, - LastLoginDate = user.LastLoginDate, - LastActivityDate = user.LastActivityDate, - PrimaryImageTag = user.ProfileImage is not null ? _imageProcessor.GetImageCacheTag(user) : null, - Configuration = new UserConfiguration - { - SubtitleMode = user.SubtitleMode, - HidePlayedInLatest = user.HidePlayedInLatest, - EnableLocalPassword = user.EnableLocalPassword, - PlayDefaultAudioTrack = user.PlayDefaultAudioTrack, - DisplayCollectionsView = user.DisplayCollectionsView, - DisplayMissingEpisodes = user.DisplayMissingEpisodes, - AudioLanguagePreference = user.AudioLanguagePreference, - RememberAudioSelections = user.RememberAudioSelections, - EnableNextEpisodeAutoPlay = user.EnableNextEpisodeAutoPlay, - RememberSubtitleSelections = user.RememberSubtitleSelections, - SubtitleLanguagePreference = user.SubtitleLanguagePreference ?? string.Empty, - OrderedViews = user.GetPreferenceValues(PreferenceKind.OrderedViews), - GroupedFolders = user.GetPreferenceValues(PreferenceKind.GroupedFolders), - MyMediaExcludes = user.GetPreferenceValues(PreferenceKind.MyMediaExcludes), - LatestItemsExcludes = user.GetPreferenceValues(PreferenceKind.LatestItemExcludes), - CastReceiverId = string.IsNullOrEmpty(user.CastReceiverId) - ? castReceiverApplications.FirstOrDefault()?.Id - : castReceiverApplications.FirstOrDefault(c => string.Equals(c.Id, user.CastReceiverId, StringComparison.Ordinal))?.Id - ?? castReceiverApplications.FirstOrDefault()?.Id - }, - Policy = new UserPolicy - { - MaxParentalRating = user.MaxParentalRatingScore, - MaxParentalSubRating = user.MaxParentalRatingSubScore, - EnableUserPreferenceAccess = user.EnableUserPreferenceAccess, - RemoteClientBitrateLimit = user.RemoteClientBitrateLimit ?? 0, - AuthenticationProviderId = user.AuthenticationProviderId, - PasswordResetProviderId = user.PasswordResetProviderId, - InvalidLoginAttemptCount = user.InvalidLoginAttemptCount, - LoginAttemptsBeforeLockout = user.LoginAttemptsBeforeLockout ?? -1, - MaxActiveSessions = user.MaxActiveSessions, - IsAdministrator = user.HasPermission(PermissionKind.IsAdministrator), - IsHidden = user.HasPermission(PermissionKind.IsHidden), - IsDisabled = user.HasPermission(PermissionKind.IsDisabled), - EnableSharedDeviceControl = user.HasPermission(PermissionKind.EnableSharedDeviceControl), - EnableRemoteAccess = user.HasPermission(PermissionKind.EnableRemoteAccess), - EnableLiveTvManagement = user.HasPermission(PermissionKind.EnableLiveTvManagement), - EnableLiveTvAccess = user.HasPermission(PermissionKind.EnableLiveTvAccess), - EnableMediaPlayback = user.HasPermission(PermissionKind.EnableMediaPlayback), - EnableAudioPlaybackTranscoding = user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding), - EnableVideoPlaybackTranscoding = user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding), - EnableContentDeletion = user.HasPermission(PermissionKind.EnableContentDeletion), - EnableContentDownloading = user.HasPermission(PermissionKind.EnableContentDownloading), - EnableSyncTranscoding = user.HasPermission(PermissionKind.EnableSyncTranscoding), - EnableMediaConversion = user.HasPermission(PermissionKind.EnableMediaConversion), - EnableAllChannels = user.HasPermission(PermissionKind.EnableAllChannels), - EnableAllDevices = user.HasPermission(PermissionKind.EnableAllDevices), - EnableAllFolders = user.HasPermission(PermissionKind.EnableAllFolders), - EnableRemoteControlOfOtherUsers = user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers), - EnablePlaybackRemuxing = user.HasPermission(PermissionKind.EnablePlaybackRemuxing), - ForceRemoteSourceTranscoding = user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding), - EnablePublicSharing = user.HasPermission(PermissionKind.EnablePublicSharing), - EnableCollectionManagement = user.HasPermission(PermissionKind.EnableCollectionManagement), - EnableSubtitleManagement = user.HasPermission(PermissionKind.EnableSubtitleManagement), - AccessSchedules = user.AccessSchedules.ToArray(), - BlockedTags = user.GetPreference(PreferenceKind.BlockedTags), - AllowedTags = user.GetPreference(PreferenceKind.AllowedTags), - EnabledChannels = user.GetPreferenceValues(PreferenceKind.EnabledChannels), - EnabledDevices = user.GetPreference(PreferenceKind.EnabledDevices), - EnabledFolders = user.GetPreferenceValues(PreferenceKind.EnabledFolders), - EnableContentDeletionFromFolders = user.GetPreference(PreferenceKind.EnableContentDeletionFromFolders), - SyncPlayAccess = user.SyncPlayAccess, - BlockedChannels = user.GetPreferenceValues(PreferenceKind.BlockedChannels), - BlockedMediaFolders = user.GetPreferenceValues(PreferenceKind.BlockedMediaFolders), - BlockUnratedItems = user.GetPreferenceValues(PreferenceKind.BlockUnratedItems) - } - }; - } - - /// - public async Task AuthenticateUser( - string username, - string password, - string remoteEndPoint, - bool isUserSession) - { - if (string.IsNullOrWhiteSpace(username)) - { - _logger.LogInformation("Authentication request without username has been denied (IP: {IP}).", remoteEndPoint); - throw new ArgumentNullException(nameof(username)); - } - - bool success; - var user = GetUserByName(username); - using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false)) - { - // Reload the user now that we hold the lock so the RowVersion is current. - // GetUserByName uses AsNoTracking and the snapshot may be stale if another - // write (e.g. a concurrent login) incremented RowVersion after our initial load. - if (user is not null) - { - user = GetUserById(user.Id) ?? user; - } - - var authResult = await AuthenticateLocalUser(username, password, user) - .ConfigureAwait(false); - var authenticationProvider = authResult.AuthenticationProvider; - success = authResult.Success; - - if (user is null) - { - string updatedUsername = authResult.Username; - - if (success - && authenticationProvider is not null - && authenticationProvider is not DefaultAuthenticationProvider) - { - // Trust the username returned by the authentication provider - username = updatedUsername; - - // Search the database for the user again - // the authentication provider might have created it - user = GetUserByName(username); - - if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null) - { - await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false); - } - } - } - - if (success && user is not null && authenticationProvider is not null) - { - var providerId = authenticationProvider.GetType().FullName; - - if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) - { - user.AuthenticationProviderId = providerId; - await UpdateUserInternalAsync(user).ConfigureAwait(false); - } - } - - if (user is null) - { - _logger.LogInformation( - "Authentication request for {UserName} has been denied (IP: {IP}).", - username, - remoteEndPoint); - throw new AuthenticationException("Invalid username or password entered."); - } - - if (user.HasPermission(PermissionKind.IsDisabled)) - { - _logger.LogInformation( - "Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).", - username, - remoteEndPoint); - throw new SecurityException( - $"The {user.Username} account is currently disabled. Please consult with your administrator."); - } - - if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && - !_networkManager.IsInLocalNetwork(remoteEndPoint)) - { - _logger.LogInformation( - "Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).", - username, - remoteEndPoint); - throw new SecurityException("Forbidden."); - } - - if (!user.IsParentalScheduleAllowed()) - { - _logger.LogInformation( - "Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).", - username, - remoteEndPoint); - throw new SecurityException("User is not allowed access at this time."); - } - - // Update LastActivityDate and LastLoginDate, then save - if (success) - { - if (isUserSession) - { - user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow; - } - - user.InvalidLoginAttemptCount = 0; - await UpdateUserInternalAsync(user).ConfigureAwait(false); - _logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username); - } - else - { - await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false); - _logger.LogInformation( - "Authentication request for {UserName} has been denied (IP: {IP}).", - user.Username, - remoteEndPoint); - } - } - - return success ? user : null; - } - - /// - public async Task StartForgotPasswordProcess(string enteredUsername, bool isInNetwork) - { - var user = string.IsNullOrWhiteSpace(enteredUsername) ? null : GetUserByName(enteredUsername); - var passwordResetProvider = GetPasswordResetProvider(user); - - var result = await passwordResetProvider - .StartForgotPasswordProcess(user, enteredUsername, isInNetwork) - .ConfigureAwait(false); - - if (user is not null && isInNetwork) - { - await UpdateUserAsync(user).ConfigureAwait(false); - } - - return result; - } - - /// - public async Task RedeemPasswordResetPin(string pin) - { - foreach (var provider in _passwordResetProviders) - { - var result = await provider.RedeemPasswordResetPin(pin).ConfigureAwait(false); - - if (result.Success) - { - return result; - } - } - - return new PinRedeemResult(); - } - - /// - public async Task InitializeAsync() - { - // TODO: Refactor the startup wizard so that it doesn't require a user to already exist. - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - if (await dbContext.Users.AnyAsync().ConfigureAwait(false)) - { - return; - } - - var defaultName = Environment.UserName; - if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName)) - { - defaultName = "MyJellyfinUser"; - } - - _logger.LogWarning("No users, creating one with username {UserName}", defaultName); - - var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false); - newUser.SetPermission(PermissionKind.IsAdministrator, true); - newUser.SetPermission(PermissionKind.EnableContentDeletion, true); - newUser.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, true); - - dbContext.Users.Add(newUser); - await dbContext.SaveChangesAsync().ConfigureAwait(false); - } - } - - /// - public NameIdPair[] GetAuthenticationProviders() - { - return _authenticationProviders - .Where(provider => provider.IsEnabled) - .OrderBy(i => i is DefaultAuthenticationProvider ? 0 : 1) - .ThenBy(i => i.Name) - .Select(i => new NameIdPair - { - Name = i.Name, - Id = i.GetType().FullName - }) - .ToArray(); - } - - /// - public NameIdPair[] GetPasswordResetProviders() - { - return _passwordResetProviders - .Where(provider => provider.IsEnabled) - .OrderBy(i => i is DefaultPasswordResetProvider ? 0 : 1) - .ThenBy(i => i.Name) - .Select(i => new NameIdPair - { - Name = i.Name, - Id = i.GetType().FullName - }) - .ToArray(); - } - - /// - public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config) - { - using (await _userLock.LockAsync(userId).ConfigureAwait(false)) - { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - var user = UserQuery(dbContext) - .AsTracking() - .FirstOrDefault(u => u.Id.Equals(userId)) - ?? throw new ArgumentException("No user exists with given Id!"); - - user.SubtitleMode = config.SubtitleMode; - user.HidePlayedInLatest = config.HidePlayedInLatest; - user.EnableLocalPassword = config.EnableLocalPassword; - user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack; - user.DisplayCollectionsView = config.DisplayCollectionsView; - user.DisplayMissingEpisodes = config.DisplayMissingEpisodes; - user.AudioLanguagePreference = config.AudioLanguagePreference; - user.RememberAudioSelections = config.RememberAudioSelections; - user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay; - user.RememberSubtitleSelections = config.RememberSubtitleSelections; - user.SubtitleLanguagePreference = config.SubtitleLanguagePreference; - - // Only set cast receiver id if it is passed in and it exists in the server config. - if (!string.IsNullOrEmpty(config.CastReceiverId) - && _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal))) - { - user.CastReceiverId = config.CastReceiverId; - } - - user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews); - user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders); - user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes); - user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes); - - dbContext.Update(user); - await dbContext.SaveChangesAsync().ConfigureAwait(false); - } - } - } - - /// - public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy) - { - using (await _userLock.LockAsync(userId).ConfigureAwait(false)) - { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - var user = UserQuery(dbContext) - .AsTracking() - .FirstOrDefault(u => u.Id.Equals(userId)) - ?? throw new ArgumentException("No user exists with given Id!"); - - // The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0" - int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch - { - -1 => null, - 0 => 3, - _ => policy.LoginAttemptsBeforeLockout - }; - - user.MaxParentalRatingScore = policy.MaxParentalRating; - user.MaxParentalRatingSubScore = policy.MaxParentalSubRating; - user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess; - user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit; - user.AuthenticationProviderId = policy.AuthenticationProviderId; - user.PasswordResetProviderId = policy.PasswordResetProviderId; - user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount; - user.LoginAttemptsBeforeLockout = maxLoginAttempts; - user.MaxActiveSessions = policy.MaxActiveSessions; - user.SyncPlayAccess = policy.SyncPlayAccess; - user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator); - user.SetPermission(PermissionKind.IsHidden, policy.IsHidden); - user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled); - user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl); - user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess); - user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement); - user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess); - user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback); - user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding); - user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding); - user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion); - user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading); - user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding); - user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion); - user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels); - user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices); - user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders); - user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers); - user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing); - user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement); - user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement); - user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement); - user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding); - user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing); - - user.AccessSchedules.Clear(); - foreach (var policyAccessSchedule in policy.AccessSchedules) - { - user.AccessSchedules.Add(policyAccessSchedule); - } - - // TODO: fix this at some point - user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty()); - user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags); - user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags); - user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels); - user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices); - user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders); - user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); - - dbContext.Update(user); - await dbContext.SaveChangesAsync().ConfigureAwait(false); - } - } - } - - /// - public async Task ClearProfileImageAsync(User user) - { - if (user.ProfileImage is null) - { - return; - } - - using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) - { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - dbContext.Remove(user.ProfileImage); - await dbContext.SaveChangesAsync().ConfigureAwait(false); - } - - user.ProfileImage = null; - } - } - - internal static void ThrowIfInvalidUsername(string name) - { - if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name)) - { - return; - } - - throw new ArgumentException("Usernames can contain unicode symbols, numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)", nameof(name)); - } - - private IAuthenticationProvider GetAuthenticationProvider(User user) - { - return GetAuthenticationProviders(user)[0]; - } - - private IPasswordResetProvider GetPasswordResetProvider(User? user) - { - if (user is null) - { - return _defaultPasswordResetProvider; - } - - return GetPasswordResetProviders(user)[0]; - } - - private List GetAuthenticationProviders(User? user) - { - var authenticationProviderId = user?.AuthenticationProviderId; - - var providers = _authenticationProviders.Where(i => i.IsEnabled).ToList(); - - if (!string.IsNullOrEmpty(authenticationProviderId)) - { - providers = providers.Where(i => string.Equals(authenticationProviderId, i.GetType().FullName, StringComparison.OrdinalIgnoreCase)).ToList(); - } - - if (providers.Count == 0) - { - // Assign the user to the InvalidAuthProvider since no configured auth provider was valid/found - _logger.LogWarning( - "User {Username} was found with invalid/missing Authentication Provider {AuthenticationProviderId}. Assigning user to InvalidAuthProvider until this is corrected", - user?.Username, - user?.AuthenticationProviderId); - providers = new List - { - _invalidAuthProvider - }; - } - - return providers; - } - - private IPasswordResetProvider[] GetPasswordResetProviders(User user) - { - var passwordResetProviderId = user.PasswordResetProviderId; - var providers = _passwordResetProviders.Where(i => i.IsEnabled).ToArray(); - - if (!string.IsNullOrEmpty(passwordResetProviderId)) - { - providers = providers.Where(i => - string.Equals(passwordResetProviderId, i.GetType().FullName, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - } - - if (providers.Length == 0) - { - providers = new IPasswordResetProvider[] - { - _defaultPasswordResetProvider - }; - } - - return providers; - } - - private async Task<(IAuthenticationProvider? AuthenticationProvider, string Username, bool Success)> AuthenticateLocalUser( - string username, - string password, - User? user) - { - bool success = false; - IAuthenticationProvider? authenticationProvider = null; - - foreach (var provider in GetAuthenticationProviders(user)) - { - var providerAuthResult = - await AuthenticateWithProvider(provider, username, password, user).ConfigureAwait(false); - var updatedUsername = providerAuthResult.Username; - success = providerAuthResult.Success; - - if (success) - { - authenticationProvider = provider; - username = updatedUsername; - break; - } - } - - return (authenticationProvider, username, success); - } - - private async Task<(string Username, bool Success)> AuthenticateWithProvider( - IAuthenticationProvider provider, - string username, - string password, - User? resolvedUser) - { - try - { - var authenticationResult = provider is IRequiresResolvedUser requiresResolvedUser - ? await requiresResolvedUser.Authenticate(username, password, resolvedUser).ConfigureAwait(false) - : await provider.Authenticate(username, password).ConfigureAwait(false); - - if (authenticationResult.Username != username) - { - _logger.LogDebug("Authentication provider provided updated username {1}", authenticationResult.Username); - username = authenticationResult.Username; - } - - return (username, true); - } - catch (AuthenticationException ex) - { - _logger.LogDebug(ex, "Error authenticating with provider {Provider}", provider.Name); - - return (username, false); - } - } - - private async Task IncrementInvalidLoginAttemptCount(User user) - { - user.InvalidLoginAttemptCount++; - int? maxInvalidLogins = user.LoginAttemptsBeforeLockout; - if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins) - { - user.SetPermission(PermissionKind.IsDisabled, true); - await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false); - _logger.LogWarning( - "Disabling user {Username} due to {Attempts} unsuccessful login attempts.", - user.Username, - user.InvalidLoginAttemptCount); - } - - await UpdateUserInternalAsync(user).ConfigureAwait(false); - } - - private async Task UpdateUserInternalAsync(User user) - { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { + user = await UserQuery(dbContext) + .AsTracking() + .FirstOrDefaultAsync(u => u.Id == userId) + .ConfigureAwait(false) + ?? throw new ResourceNotFoundException(nameof(userId)); + + user.Username = newName; + user.NormalizedUsername = newName.ToUpperInvariant(); await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); } } - private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) + var eventArgs = new UserUpdatedEventArgs(user); + await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false); + OnUserUpdated?.Invoke(this, eventArgs); + } + + /// + public async Task UpdateUserAsync(User user) + { + using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) { - dbContext.Users.Attach(user); - dbContext.Entry(user).State = EntityState.Modified; + await UpdateUserInternalAsync(user).ConfigureAwait(false); + } + } + + internal async Task CreateUserInternalAsync(string name, JellyfinDbContext dbContext) + { + // TODO: Remove after user item data is migrated. + var max = await dbContext.Users.AsQueryable().AnyAsync().ConfigureAwait(false) + ? await dbContext.Users.AsQueryable().Select(u => u.InternalId).MaxAsync().ConfigureAwait(false) + : 0; + + var user = new User( + name, + _defaultAuthenticationProvider.GetType().FullName!, + _defaultPasswordResetProvider.GetType().FullName!) + { + InternalId = max + 1 + }; + + user.AddDefaultPermissions(); + user.AddDefaultPreferences(); + + return user; + } + + /// + public async Task CreateUserAsync(string name) + { + ThrowIfInvalidUsername(name); + + User newUser; + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + if (await dbContext.Users + .AnyAsync(u => u.NormalizedUsername == name.ToUpperInvariant()) + .ConfigureAwait(false)) + { + throw new ArgumentException(string.Format( + CultureInfo.InvariantCulture, + "A user with the name '{0}' already exists.", + name)); + } +#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + + newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false); + + dbContext.Users.Add(newUser); await dbContext.SaveChangesAsync().ConfigureAwait(false); } - /// - public void Dispose() + await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false); + + return newUser; + } + + /// + public async Task DeleteUserAsync(Guid userId) + { + User? user; + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { - Dispose(true); - GC.SuppressFinalize(this); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + user = await dbContext.Users + .Include(u => u.Permissions) + .FirstOrDefaultAsync(u => u.Id.Equals(userId)) + .ConfigureAwait(false); + + if (user is null) + { + throw new ResourceNotFoundException(nameof(userId)); + } + + var userCount = await dbContext.Users.CountAsync().ConfigureAwait(false); + if (userCount == 1) + { + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "The user '{0}' cannot be deleted because there must be at least one user in the system.", + user.Username)); + } + + if (user.HasPermission(PermissionKind.IsAdministrator) + && await dbContext.Users + .CountAsync(i => i.Permissions.Any(p => p.Kind == PermissionKind.IsAdministrator && p.Value)) + .ConfigureAwait(false) == 1) + { + throw new ArgumentException( + string.Format( + CultureInfo.InvariantCulture, + "The user '{0}' cannot be deleted because there must be at least one admin user in the system.", + user.Username), + nameof(userId)); + } + + dbContext.Users.Remove(user); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } - /// - /// Disposes all members of this class. - /// - /// Defines if the class has been cleaned up by a dispose or finalizer. - protected virtual void Dispose(bool disposing) + await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false); + } + + /// + public Task ResetPassword(Guid userId) + { + return ChangePassword(userId, string.Empty); + } + + /// + public async Task ChangePassword(Guid userId, string newPassword) + { + User dbUser = null!; + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { - if (disposing) + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - _userLock.Dispose(); + dbUser = await UserQuery(dbContext) + .AsTracking() + .FirstOrDefaultAsync(u => u.Id == userId) + .ConfigureAwait(false) + ?? throw new ResourceNotFoundException(nameof(userId)); + + if (dbUser.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword)) + { + throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword)); + } + + await GetAuthenticationProvider(dbUser).ChangePassword(dbUser, newPassword).ConfigureAwait(false); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + } + + await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(dbUser)).ConfigureAwait(false); + } + + /// + public UserDto GetUserDto(User user, string? remoteEndPoint = null) + { + var hasPassword = GetAuthenticationProvider(user).HasPassword(user); + var castReceiverApplications = _serverConfigurationManager.Configuration.CastReceiverApplications; + return new UserDto + { + Name = user.Username, + Id = user.Id, + ServerId = _appHost.SystemId, + HasPassword = hasPassword, + HasConfiguredPassword = hasPassword, + EnableAutoLogin = user.EnableAutoLogin, + LastLoginDate = user.LastLoginDate, + LastActivityDate = user.LastActivityDate, + PrimaryImageTag = user.ProfileImage is not null ? _imageProcessor.GetImageCacheTag(user) : null, + Configuration = new UserConfiguration + { + SubtitleMode = user.SubtitleMode, + HidePlayedInLatest = user.HidePlayedInLatest, + EnableLocalPassword = user.EnableLocalPassword, + PlayDefaultAudioTrack = user.PlayDefaultAudioTrack, + DisplayCollectionsView = user.DisplayCollectionsView, + DisplayMissingEpisodes = user.DisplayMissingEpisodes, + AudioLanguagePreference = user.AudioLanguagePreference, + RememberAudioSelections = user.RememberAudioSelections, + EnableNextEpisodeAutoPlay = user.EnableNextEpisodeAutoPlay, + RememberSubtitleSelections = user.RememberSubtitleSelections, + SubtitleLanguagePreference = user.SubtitleLanguagePreference ?? string.Empty, + OrderedViews = user.GetPreferenceValues(PreferenceKind.OrderedViews), + GroupedFolders = user.GetPreferenceValues(PreferenceKind.GroupedFolders), + MyMediaExcludes = user.GetPreferenceValues(PreferenceKind.MyMediaExcludes), + LatestItemsExcludes = user.GetPreferenceValues(PreferenceKind.LatestItemExcludes), + CastReceiverId = string.IsNullOrEmpty(user.CastReceiverId) + ? castReceiverApplications.FirstOrDefault()?.Id + : castReceiverApplications.FirstOrDefault(c => string.Equals(c.Id, user.CastReceiverId, StringComparison.Ordinal))?.Id + ?? castReceiverApplications.FirstOrDefault()?.Id + }, + Policy = new UserPolicy + { + MaxParentalRating = user.MaxParentalRatingScore, + MaxParentalSubRating = user.MaxParentalRatingSubScore, + EnableUserPreferenceAccess = user.EnableUserPreferenceAccess, + RemoteClientBitrateLimit = user.RemoteClientBitrateLimit ?? 0, + AuthenticationProviderId = user.AuthenticationProviderId, + PasswordResetProviderId = user.PasswordResetProviderId, + InvalidLoginAttemptCount = user.InvalidLoginAttemptCount, + LoginAttemptsBeforeLockout = user.LoginAttemptsBeforeLockout ?? -1, + MaxActiveSessions = user.MaxActiveSessions, + IsAdministrator = user.HasPermission(PermissionKind.IsAdministrator), + IsHidden = user.HasPermission(PermissionKind.IsHidden), + IsDisabled = user.HasPermission(PermissionKind.IsDisabled), + EnableSharedDeviceControl = user.HasPermission(PermissionKind.EnableSharedDeviceControl), + EnableRemoteAccess = user.HasPermission(PermissionKind.EnableRemoteAccess), + EnableLiveTvManagement = user.HasPermission(PermissionKind.EnableLiveTvManagement), + EnableLiveTvAccess = user.HasPermission(PermissionKind.EnableLiveTvAccess), + EnableMediaPlayback = user.HasPermission(PermissionKind.EnableMediaPlayback), + EnableAudioPlaybackTranscoding = user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding), + EnableVideoPlaybackTranscoding = user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding), + EnableContentDeletion = user.HasPermission(PermissionKind.EnableContentDeletion), + EnableContentDownloading = user.HasPermission(PermissionKind.EnableContentDownloading), + EnableSyncTranscoding = user.HasPermission(PermissionKind.EnableSyncTranscoding), + EnableMediaConversion = user.HasPermission(PermissionKind.EnableMediaConversion), + EnableAllChannels = user.HasPermission(PermissionKind.EnableAllChannels), + EnableAllDevices = user.HasPermission(PermissionKind.EnableAllDevices), + EnableAllFolders = user.HasPermission(PermissionKind.EnableAllFolders), + EnableRemoteControlOfOtherUsers = user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers), + EnablePlaybackRemuxing = user.HasPermission(PermissionKind.EnablePlaybackRemuxing), + ForceRemoteSourceTranscoding = user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding), + EnablePublicSharing = user.HasPermission(PermissionKind.EnablePublicSharing), + EnableCollectionManagement = user.HasPermission(PermissionKind.EnableCollectionManagement), + EnableSubtitleManagement = user.HasPermission(PermissionKind.EnableSubtitleManagement), + AccessSchedules = user.AccessSchedules.ToArray(), + BlockedTags = user.GetPreference(PreferenceKind.BlockedTags), + AllowedTags = user.GetPreference(PreferenceKind.AllowedTags), + EnabledChannels = user.GetPreferenceValues(PreferenceKind.EnabledChannels), + EnabledDevices = user.GetPreference(PreferenceKind.EnabledDevices), + EnabledFolders = user.GetPreferenceValues(PreferenceKind.EnabledFolders), + EnableContentDeletionFromFolders = user.GetPreference(PreferenceKind.EnableContentDeletionFromFolders), + SyncPlayAccess = user.SyncPlayAccess, + BlockedChannels = user.GetPreferenceValues(PreferenceKind.BlockedChannels), + BlockedMediaFolders = user.GetPreferenceValues(PreferenceKind.BlockedMediaFolders), + BlockUnratedItems = user.GetPreferenceValues(PreferenceKind.BlockUnratedItems) + } + }; + } + + /// + public async Task AuthenticateUser( + string username, + string password, + string remoteEndPoint, + bool isUserSession) + { + if (string.IsNullOrWhiteSpace(username)) + { + _logger.LogInformation("Authentication request without username has been denied (IP: {IP}).", remoteEndPoint); + throw new ArgumentNullException(nameof(username)); + } + + bool success; + var user = GetUserByName(username); + using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false)) + { + // Reload the user now that we hold the lock so the RowVersion is current. + // GetUserByName uses AsNoTracking and the snapshot may be stale if another + // write (e.g. a concurrent login) incremented RowVersion after our initial load. + if (user is not null) + { + user = GetUserById(user.Id) ?? user; + } + + var authResult = await AuthenticateLocalUser(username, password, user) + .ConfigureAwait(false); + var authenticationProvider = authResult.AuthenticationProvider; + success = authResult.Success; + + if (user is null) + { + string updatedUsername = authResult.Username; + + if (success + && authenticationProvider is not null + && authenticationProvider is not DefaultAuthenticationProvider) + { + // Trust the username returned by the authentication provider + username = updatedUsername; + + // Search the database for the user again + // the authentication provider might have created it + user = GetUserByName(username); + + if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null) + { + await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false); + } + } + } + + if (success && user is not null && authenticationProvider is not null) + { + var providerId = authenticationProvider.GetType().FullName; + + if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) + { + user.AuthenticationProviderId = providerId; + await UpdateUserInternalAsync(user).ConfigureAwait(false); + } + } + + if (user is null) + { + _logger.LogInformation( + "Authentication request for {UserName} has been denied (IP: {IP}).", + username, + remoteEndPoint); + throw new AuthenticationException("Invalid username or password entered."); + } + + if (user.HasPermission(PermissionKind.IsDisabled)) + { + _logger.LogInformation( + "Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).", + username, + remoteEndPoint); + throw new SecurityException( + $"The {user.Username} account is currently disabled. Please consult with your administrator."); + } + + if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && + !_networkManager.IsInLocalNetwork(remoteEndPoint)) + { + _logger.LogInformation( + "Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).", + username, + remoteEndPoint); + throw new SecurityException("Forbidden."); + } + + if (!user.IsParentalScheduleAllowed()) + { + _logger.LogInformation( + "Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).", + username, + remoteEndPoint); + throw new SecurityException("User is not allowed access at this time."); + } + + // Update LastActivityDate and LastLoginDate, then save + if (success) + { + if (isUserSession) + { + user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow; + } + + user.InvalidLoginAttemptCount = 0; + await UpdateUserInternalAsync(user).ConfigureAwait(false); + _logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username); + } + else + { + await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false); + _logger.LogInformation( + "Authentication request for {UserName} has been denied (IP: {IP}).", + user.Username, + remoteEndPoint); + } + } + + return success ? user : null; + } + + /// + public async Task StartForgotPasswordProcess(string enteredUsername, bool isInNetwork) + { + var user = string.IsNullOrWhiteSpace(enteredUsername) ? null : GetUserByName(enteredUsername); + var passwordResetProvider = GetPasswordResetProvider(user); + + var result = await passwordResetProvider + .StartForgotPasswordProcess(user, enteredUsername, isInNetwork) + .ConfigureAwait(false); + + if (user is not null && isInNetwork) + { + await UpdateUserAsync(user).ConfigureAwait(false); + } + + return result; + } + + /// + public async Task RedeemPasswordResetPin(string pin) + { + foreach (var provider in _passwordResetProviders) + { + var result = await provider.RedeemPasswordResetPin(pin).ConfigureAwait(false); + + if (result.Success) + { + return result; + } + } + + return new PinRedeemResult(); + } + + /// + public async Task InitializeAsync() + { + // TODO: Refactor the startup wizard so that it doesn't require a user to already exist. + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + if (await dbContext.Users.AnyAsync().ConfigureAwait(false)) + { + return; + } + + var defaultName = Environment.UserName; + if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName)) + { + defaultName = "MyJellyfinUser"; + } + + _logger.LogWarning("No users, creating one with username {UserName}", defaultName); + + var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false); + newUser.SetPermission(PermissionKind.IsAdministrator, true); + newUser.SetPermission(PermissionKind.EnableContentDeletion, true); + newUser.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, true); + + dbContext.Users.Add(newUser); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + } + + /// + public NameIdPair[] GetAuthenticationProviders() + { + return _authenticationProviders + .Where(provider => provider.IsEnabled) + .OrderBy(i => i is DefaultAuthenticationProvider ? 0 : 1) + .ThenBy(i => i.Name) + .Select(i => new NameIdPair + { + Name = i.Name, + Id = i.GetType().FullName + }) + .ToArray(); + } + + /// + public NameIdPair[] GetPasswordResetProviders() + { + return _passwordResetProviders + .Where(provider => provider.IsEnabled) + .OrderBy(i => i is DefaultPasswordResetProvider ? 0 : 1) + .ThenBy(i => i.Name) + .Select(i => new NameIdPair + { + Name = i.Name, + Id = i.GetType().FullName + }) + .ToArray(); + } + + /// + public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config) + { + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) + { + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var user = UserQuery(dbContext) + .AsTracking() + .FirstOrDefault(u => u.Id.Equals(userId)) + ?? throw new ArgumentException("No user exists with given Id!"); + + user.SubtitleMode = config.SubtitleMode; + user.HidePlayedInLatest = config.HidePlayedInLatest; + user.EnableLocalPassword = config.EnableLocalPassword; + user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack; + user.DisplayCollectionsView = config.DisplayCollectionsView; + user.DisplayMissingEpisodes = config.DisplayMissingEpisodes; + user.AudioLanguagePreference = config.AudioLanguagePreference; + user.RememberAudioSelections = config.RememberAudioSelections; + user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay; + user.RememberSubtitleSelections = config.RememberSubtitleSelections; + user.SubtitleLanguagePreference = config.SubtitleLanguagePreference; + + // Only set cast receiver id if it is passed in and it exists in the server config. + if (!string.IsNullOrEmpty(config.CastReceiverId) + && _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal))) + { + user.CastReceiverId = config.CastReceiverId; + } + + user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews); + user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders); + user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes); + user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes); + + dbContext.Update(user); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + } + } + + /// + public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy) + { + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) + { + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var user = UserQuery(dbContext) + .AsTracking() + .FirstOrDefault(u => u.Id.Equals(userId)) + ?? throw new ArgumentException("No user exists with given Id!"); + + // The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0" + int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch + { + -1 => null, + 0 => 3, + _ => policy.LoginAttemptsBeforeLockout + }; + + user.MaxParentalRatingScore = policy.MaxParentalRating; + user.MaxParentalRatingSubScore = policy.MaxParentalSubRating; + user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess; + user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit; + user.AuthenticationProviderId = policy.AuthenticationProviderId; + user.PasswordResetProviderId = policy.PasswordResetProviderId; + user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount; + user.LoginAttemptsBeforeLockout = maxLoginAttempts; + user.MaxActiveSessions = policy.MaxActiveSessions; + user.SyncPlayAccess = policy.SyncPlayAccess; + user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator); + user.SetPermission(PermissionKind.IsHidden, policy.IsHidden); + user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled); + user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl); + user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess); + user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement); + user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess); + user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback); + user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding); + user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding); + user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion); + user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading); + user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding); + user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion); + user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels); + user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices); + user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders); + user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers); + user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing); + user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement); + user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement); + user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement); + user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding); + user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing); + + user.AccessSchedules.Clear(); + foreach (var policyAccessSchedule in policy.AccessSchedules) + { + user.AccessSchedules.Add(policyAccessSchedule); + } + + // TODO: fix this at some point + user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty()); + user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags); + user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags); + user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels); + user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices); + user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders); + user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); + + dbContext.Update(user); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + } + } + + /// + public async Task ClearProfileImageAsync(User user) + { + if (user.ProfileImage is null) + { + return; + } + + using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) + { + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + dbContext.Remove(user.ProfileImage); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + + user.ProfileImage = null; + } + } + + internal static void ThrowIfInvalidUsername(string name) + { + if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name)) + { + return; + } + + throw new ArgumentException("Usernames can contain unicode symbols, numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)", nameof(name)); + } + + private IAuthenticationProvider GetAuthenticationProvider(User user) + { + return GetAuthenticationProviders(user)[0]; + } + + private IPasswordResetProvider GetPasswordResetProvider(User? user) + { + if (user is null) + { + return _defaultPasswordResetProvider; + } + + return GetPasswordResetProviders(user)[0]; + } + + private List GetAuthenticationProviders(User? user) + { + var authenticationProviderId = user?.AuthenticationProviderId; + + var providers = _authenticationProviders.Where(i => i.IsEnabled).ToList(); + + if (!string.IsNullOrEmpty(authenticationProviderId)) + { + providers = providers.Where(i => string.Equals(authenticationProviderId, i.GetType().FullName, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + if (providers.Count == 0) + { + // Assign the user to the InvalidAuthProvider since no configured auth provider was valid/found + _logger.LogWarning( + "User {Username} was found with invalid/missing Authentication Provider {AuthenticationProviderId}. Assigning user to InvalidAuthProvider until this is corrected", + user?.Username, + user?.AuthenticationProviderId); + providers = new List + { + _invalidAuthProvider + }; + } + + return providers; + } + + private IPasswordResetProvider[] GetPasswordResetProviders(User user) + { + var passwordResetProviderId = user.PasswordResetProviderId; + var providers = _passwordResetProviders.Where(i => i.IsEnabled).ToArray(); + + if (!string.IsNullOrEmpty(passwordResetProviderId)) + { + providers = providers.Where(i => + string.Equals(passwordResetProviderId, i.GetType().FullName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + + if (providers.Length == 0) + { + providers = new IPasswordResetProvider[] + { + _defaultPasswordResetProvider + }; + } + + return providers; + } + + private async Task<(IAuthenticationProvider? AuthenticationProvider, string Username, bool Success)> AuthenticateLocalUser( + string username, + string password, + User? user) + { + bool success = false; + IAuthenticationProvider? authenticationProvider = null; + + foreach (var provider in GetAuthenticationProviders(user)) + { + var providerAuthResult = + await AuthenticateWithProvider(provider, username, password, user).ConfigureAwait(false); + var updatedUsername = providerAuthResult.Username; + success = providerAuthResult.Success; + + if (success) + { + authenticationProvider = provider; + username = updatedUsername; + break; + } + } + + return (authenticationProvider, username, success); + } + + private async Task<(string Username, bool Success)> AuthenticateWithProvider( + IAuthenticationProvider provider, + string username, + string password, + User? resolvedUser) + { + try + { + var authenticationResult = provider is IRequiresResolvedUser requiresResolvedUser + ? await requiresResolvedUser.Authenticate(username, password, resolvedUser).ConfigureAwait(false) + : await provider.Authenticate(username, password).ConfigureAwait(false); + + if (authenticationResult.Username != username) + { + _logger.LogDebug("Authentication provider provided updated username {1}", authenticationResult.Username); + username = authenticationResult.Username; + } + + return (username, true); + } + catch (AuthenticationException ex) + { + _logger.LogDebug(ex, "Error authenticating with provider {Provider}", provider.Name); + + return (username, false); + } + } + + private async Task IncrementInvalidLoginAttemptCount(User user) + { + user.InvalidLoginAttemptCount++; + int? maxInvalidLogins = user.LoginAttemptsBeforeLockout; + if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins) + { + user.SetPermission(PermissionKind.IsDisabled, true); + await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false); + _logger.LogWarning( + "Disabling user {Username} due to {Attempts} unsuccessful login attempts.", + user.Username, + user.InvalidLoginAttemptCount); + } + + await UpdateUserInternalAsync(user).ConfigureAwait(false); + } + + private async Task UpdateUserInternalAsync(User user) + { + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); + } + } + + private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) + { + dbContext.Users.Attach(user); + dbContext.Entry(user).State = EntityState.Modified; + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes all members of this class. + /// + /// Defines if the class has been cleaned up by a dispose or finalizer. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _userLock.Dispose(); + } + } + + internal sealed class LockHelper : IDisposable + { + private readonly AsyncKeyedLocker _userLock = new(); + + private bool _disposed; + + public static AsyncLocal IsNestedLock { get; set; } = new(); + + public bool ShouldLock() + { + return IsNestedLock.Value == 0; + } + + public ValueTask LockAsync(Guid key) + { + ThrowIfDisposed(); + var isNested = LockHelper.IsNestedLock.Value != 0; + LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value + 1; + if (isNested) + { + return new ValueTask(new LockHandle { Parent = null }); + } + + return AcquireLockAsync(key); + } + + private async ValueTask AcquireLockAsync(Guid key) + { + var lockHandle = await _userLock.LockAsync(key, true).ConfigureAwait(false); + return new LockHandle { Parent = lockHandle }; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _userLock.Dispose(); + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + private sealed class LockHandle : IDisposable + { + public required IDisposable? Parent { get; init; } + + public void Dispose() + { + Parent?.Dispose(); + LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value - 1; + + if (LockHelper.IsNestedLock.Value < 0) + { + throw new InvalidOperationException("Mismatched locking detected. Threads internal NestedLock is less then 0 which should not be possible."); + } } } } From 8d0534195d42ec52f029e39f95af17bcbef31069 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Wed, 27 May 2026 11:18:21 +0000 Subject: [PATCH 2/6] Make auth method more resliant --- .../Users/UserManager.cs | 68 ++++++++++++------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index d6f90ee25e..f62b30dde2 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -460,12 +460,14 @@ public partial class UserManager : IUserManager, IDisposable var user = GetUserByName(username); using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false)) { + using var dbContext = _dbProvider.CreateDbContext(); + // Reload the user now that we hold the lock so the RowVersion is current. // GetUserByName uses AsNoTracking and the snapshot may be stale if another // write (e.g. a concurrent login) incremented RowVersion after our initial load. if (user is not null) { - user = GetUserById(user.Id) ?? user; + user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false) ?? user; } var authResult = await AuthenticateLocalUser(username, password, user) @@ -486,11 +488,16 @@ public partial class UserManager : IUserManager, IDisposable // Search the database for the user again // the authentication provider might have created it - user = GetUserByName(username); +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + user = await UserQuery(dbContext) + .FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false); if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null) { await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false); + user = await UserQuery(dbContext) + .FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false); +#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons } } } @@ -501,8 +508,10 @@ public partial class UserManager : IUserManager, IDisposable if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) { - user.AuthenticationProviderId = providerId; - await UpdateUserInternalAsync(user).ConfigureAwait(false); + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId)) + .ConfigureAwait(false); } } @@ -549,16 +558,42 @@ public partial class UserManager : IUserManager, IDisposable { if (isUserSession) { - user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow; + var date = DateTime.UtcNow; + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e + .SetProperty(f => f.LastActivityDate, date) + .SetProperty(f => f.LastLoginDate, date)) + .ConfigureAwait(false); } - user.InvalidLoginAttemptCount = 0; - await UpdateUserInternalAsync(user).ConfigureAwait(false); + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, 0)) + .ConfigureAwait(false); _logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username); } else { - await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false); + user.InvalidLoginAttemptCount++; + int? maxInvalidLogins = user.LoginAttemptsBeforeLockout; + if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins) + { + user.SetPermission(PermissionKind.IsDisabled, true); + await dbContext.SaveChangesAsync() + .ConfigureAwait(false); + await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false); + _logger.LogWarning( + "Disabling user {Username} due to {Attempts} unsuccessful login attempts.", + user.Username, + user.InvalidLoginAttemptCount); + } + + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, f => f.InvalidLoginAttemptCount + 1)) + .ConfigureAwait(false); + _logger.LogInformation( "Authentication request for {UserName} has been denied (IP: {IP}).", user.Username, @@ -933,23 +968,6 @@ public partial class UserManager : IUserManager, IDisposable } } - private async Task IncrementInvalidLoginAttemptCount(User user) - { - user.InvalidLoginAttemptCount++; - int? maxInvalidLogins = user.LoginAttemptsBeforeLockout; - if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins) - { - user.SetPermission(PermissionKind.IsDisabled, true); - await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false); - _logger.LogWarning( - "Disabling user {Username} due to {Attempts} unsuccessful login attempts.", - user.Username, - user.InvalidLoginAttemptCount); - } - - await UpdateUserInternalAsync(user).ConfigureAwait(false); - } - private async Task UpdateUserInternalAsync(User user) { var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); From 869d8d3abc907604d4dc816f05e37f04a3eb67e3 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Wed, 27 May 2026 16:49:54 +0000 Subject: [PATCH 3/6] revert to block scope --- .../Users/UserManager.cs | 1871 +++++++++-------- 1 file changed, 936 insertions(+), 935 deletions(-) diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index f62b30dde2..a4ea340681 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -31,1038 +31,1039 @@ using MediaBrowser.Model.Users; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -namespace Jellyfin.Server.Implementations.Users; - -/// -/// Manages the creation and retrieval of instances. -/// -public partial class UserManager : IUserManager, IDisposable +namespace Jellyfin.Server.Implementations.Users { - private readonly IDbContextFactory _dbProvider; - private readonly IEventManager _eventManager; - private readonly INetworkManager _networkManager; - private readonly IApplicationHost _appHost; - private readonly IImageProcessor _imageProcessor; - private readonly ILogger _logger; - private readonly IReadOnlyCollection _passwordResetProviders; - private readonly IReadOnlyCollection _authenticationProviders; - private readonly InvalidAuthProvider _invalidAuthProvider; - private readonly DefaultAuthenticationProvider _defaultAuthenticationProvider; - private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; - private readonly IServerConfigurationManager _serverConfigurationManager; - - private readonly LockHelper _userLock = new(); - /// - /// Initializes a new instance of the class. + /// Manages the creation and retrieval of instances. /// - /// The database provider. - /// The event manager. - /// The network manager. - /// The application host. - /// The image processor. - /// The logger. - /// The system config manager. - /// The password reset providers. - /// The authentication providers. - public UserManager( - IDbContextFactory dbProvider, - IEventManager eventManager, - INetworkManager networkManager, - IApplicationHost appHost, - IImageProcessor imageProcessor, - ILogger logger, - IServerConfigurationManager serverConfigurationManager, - IEnumerable passwordResetProviders, - IEnumerable authenticationProviders) + public partial class UserManager : IUserManager, IDisposable { - _dbProvider = dbProvider; - _eventManager = eventManager; - _networkManager = networkManager; - _appHost = appHost; - _imageProcessor = imageProcessor; - _logger = logger; - _serverConfigurationManager = serverConfigurationManager; + private readonly IDbContextFactory _dbProvider; + private readonly IEventManager _eventManager; + private readonly INetworkManager _networkManager; + private readonly IApplicationHost _appHost; + private readonly IImageProcessor _imageProcessor; + private readonly ILogger _logger; + private readonly IReadOnlyCollection _passwordResetProviders; + private readonly IReadOnlyCollection _authenticationProviders; + private readonly InvalidAuthProvider _invalidAuthProvider; + private readonly DefaultAuthenticationProvider _defaultAuthenticationProvider; + private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; + private readonly IServerConfigurationManager _serverConfigurationManager; - _passwordResetProviders = passwordResetProviders.ToList(); - _authenticationProviders = authenticationProviders.ToList(); + private readonly LockHelper _userLock = new(); - _invalidAuthProvider = _authenticationProviders.OfType().First(); - _defaultAuthenticationProvider = _authenticationProviders.OfType().First(); - _defaultPasswordResetProvider = _passwordResetProviders.OfType().First(); - } - - /// - public event EventHandler>? OnUserUpdated; - - /// - public IEnumerable GetUsers() - { - using var dbContext = _dbProvider.CreateDbContext(); - return UserQuery(dbContext) - .ToArray(); - } - - /// - public IEnumerable GetUsersIds() - { - using var dbContext = _dbProvider.CreateDbContext(); - return dbContext.Users - .AsNoTracking() - .Select(user => user.Id) - .ToArray(); - } - - // This is some regex that matches only on unicode "word" characters, as well as -, _ and @ - // In theory this will cut out most if not all 'control' characters which should help minimize any weirdness - // Usernames can contain letters (a-z + whatever else unicode is cool with), numbers (0-9), at-signs (@), dashes (-), underscores (_), apostrophes ('), periods (.) and spaces ( ) - [GeneratedRegex(@"^(?!\s)[\w\ \-'._@+]+(? - public User? GetUserById(Guid id) - { - if (id.IsEmpty()) + /// + /// Initializes a new instance of the class. + /// + /// The database provider. + /// The event manager. + /// The network manager. + /// The application host. + /// The image processor. + /// The logger. + /// The system config manager. + /// The password reset providers. + /// The authentication providers. + public UserManager( + IDbContextFactory dbProvider, + IEventManager eventManager, + INetworkManager networkManager, + IApplicationHost appHost, + IImageProcessor imageProcessor, + ILogger logger, + IServerConfigurationManager serverConfigurationManager, + IEnumerable passwordResetProviders, + IEnumerable authenticationProviders) { - throw new ArgumentException("Guid can't be empty", nameof(id)); + _dbProvider = dbProvider; + _eventManager = eventManager; + _networkManager = networkManager; + _appHost = appHost; + _imageProcessor = imageProcessor; + _logger = logger; + _serverConfigurationManager = serverConfigurationManager; + + _passwordResetProviders = passwordResetProviders.ToList(); + _authenticationProviders = authenticationProviders.ToList(); + + _invalidAuthProvider = _authenticationProviders.OfType().First(); + _defaultAuthenticationProvider = _authenticationProviders.OfType().First(); + _defaultPasswordResetProvider = _passwordResetProviders.OfType().First(); } - using var dbContext = _dbProvider.CreateDbContext(); - return UserQuery(dbContext) - .FirstOrDefault(user => user.Id == id); - } + /// + public event EventHandler>? OnUserUpdated; - private static IQueryable UserQuery(JellyfinDbContext dbContext) - { - return dbContext.Users - .AsSingleQuery() - .Include(user => user.Permissions) - .Include(user => user.Preferences) - .Include(user => user.AccessSchedules) - .Include(user => user.ProfileImage) - .AsNoTracking(); - } - - /// - public User? GetFirstUser() - { - using var dbContext = _dbProvider.CreateDbContext(); - return UserQuery(dbContext) - .FirstOrDefault(); - } - - /// - public User? GetUserByName(string name) - { - if (string.IsNullOrWhiteSpace(name)) + /// + public IEnumerable GetUsers() { - throw new ArgumentException("Invalid username", nameof(name)); + using var dbContext = _dbProvider.CreateDbContext(); + return UserQuery(dbContext) + .ToArray(); } - using var dbContext = _dbProvider.CreateDbContext(); + /// + public IEnumerable GetUsersIds() + { + using var dbContext = _dbProvider.CreateDbContext(); + return dbContext.Users + .AsNoTracking() + .Select(user => user.Id) + .ToArray(); + } + + // This is some regex that matches only on unicode "word" characters, as well as -, _ and @ + // In theory this will cut out most if not all 'control' characters which should help minimize any weirdness + // Usernames can contain letters (a-z + whatever else unicode is cool with), numbers (0-9), at-signs (@), dashes (-), underscores (_), apostrophes ('), periods (.) and spaces ( ) + [GeneratedRegex(@"^(?!\s)[\w\ \-'._@+]+(? + public User? GetUserById(Guid id) + { + if (id.IsEmpty()) + { + throw new ArgumentException("Guid can't be empty", nameof(id)); + } + + using var dbContext = _dbProvider.CreateDbContext(); + return UserQuery(dbContext) + .FirstOrDefault(user => user.Id == id); + } + + private static IQueryable UserQuery(JellyfinDbContext dbContext) + { + return dbContext.Users + .AsSingleQuery() + .Include(user => user.Permissions) + .Include(user => user.Preferences) + .Include(user => user.AccessSchedules) + .Include(user => user.ProfileImage) + .AsNoTracking(); + } + + /// + public User? GetFirstUser() + { + using var dbContext = _dbProvider.CreateDbContext(); + return UserQuery(dbContext) + .FirstOrDefault(); + } + + /// + public User? GetUserByName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Invalid username", nameof(name)); + } + + using var dbContext = _dbProvider.CreateDbContext(); #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons - return UserQuery(dbContext) - .FirstOrDefault(u => u.NormalizedUsername == name.ToUpperInvariant()); + return UserQuery(dbContext) + .FirstOrDefault(u => u.NormalizedUsername == name.ToUpperInvariant()); #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons - } - - /// - public async Task RenameUser(Guid userId, string oldName, string newName) - { - ThrowIfInvalidUsername(newName); - - if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException("The new and old names must be different."); } - User user = null!; // user is never actually null where its used afterwards so we can just ignore. - using (await _userLock.LockAsync(userId).ConfigureAwait(false)) + /// + public async Task RenameUser(Guid userId, string oldName, string newName) { + ThrowIfInvalidUsername(newName); + + if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("The new and old names must be different."); + } + + User user = null!; // user is never actually null where its used afterwards so we can just ignore. + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) + { + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + if (await dbContext.Users + .AnyAsync(u => u.NormalizedUsername == newName.ToUpperInvariant() && u.Id != userId) + .ConfigureAwait(false)) + { + throw new ArgumentException(string.Format( + CultureInfo.InvariantCulture, + "A user with the name '{0}' already exists.", + newName)); + } +#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + + user = await UserQuery(dbContext) + .AsTracking() + .FirstOrDefaultAsync(u => u.Id == userId) + .ConfigureAwait(false) + ?? throw new ResourceNotFoundException(nameof(userId)); + + user.Username = newName; + user.NormalizedUsername = newName.ToUpperInvariant(); + await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); + } + } + + var eventArgs = new UserUpdatedEventArgs(user); + await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false); + OnUserUpdated?.Invoke(this, eventArgs); + } + + /// + public async Task UpdateUserAsync(User user) + { + using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) + { + await UpdateUserInternalAsync(user).ConfigureAwait(false); + } + } + + internal async Task CreateUserInternalAsync(string name, JellyfinDbContext dbContext) + { + // TODO: Remove after user item data is migrated. + var max = await dbContext.Users.AsQueryable().AnyAsync().ConfigureAwait(false) + ? await dbContext.Users.AsQueryable().Select(u => u.InternalId).MaxAsync().ConfigureAwait(false) + : 0; + + var user = new User( + name, + _defaultAuthenticationProvider.GetType().FullName!, + _defaultPasswordResetProvider.GetType().FullName!) + { + InternalId = max + 1 + }; + + user.AddDefaultPermissions(); + user.AddDefaultPreferences(); + + return user; + } + + /// + public async Task CreateUserAsync(string name) + { + ThrowIfInvalidUsername(name); + + User newUser; var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons if (await dbContext.Users - .AnyAsync(u => u.NormalizedUsername == newName.ToUpperInvariant() && u.Id != userId) + .AnyAsync(u => u.NormalizedUsername == name.ToUpperInvariant()) .ConfigureAwait(false)) { throw new ArgumentException(string.Format( CultureInfo.InvariantCulture, "A user with the name '{0}' already exists.", - newName)); + name)); } #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons - user = await UserQuery(dbContext) - .AsTracking() - .FirstOrDefaultAsync(u => u.Id == userId) - .ConfigureAwait(false) - ?? throw new ResourceNotFoundException(nameof(userId)); + newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false); - user.Username = newName; - user.NormalizedUsername = newName.ToUpperInvariant(); - await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); + dbContext.Users.Add(newUser); + await dbContext.SaveChangesAsync().ConfigureAwait(false); } + + await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false); + + return newUser; } - var eventArgs = new UserUpdatedEventArgs(user); - await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false); - OnUserUpdated?.Invoke(this, eventArgs); - } - - /// - public async Task UpdateUserAsync(User user) - { - using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) + /// + public async Task DeleteUserAsync(Guid userId) { - await UpdateUserInternalAsync(user).ConfigureAwait(false); - } - } - - internal async Task CreateUserInternalAsync(string name, JellyfinDbContext dbContext) - { - // TODO: Remove after user item data is migrated. - var max = await dbContext.Users.AsQueryable().AnyAsync().ConfigureAwait(false) - ? await dbContext.Users.AsQueryable().Select(u => u.InternalId).MaxAsync().ConfigureAwait(false) - : 0; - - var user = new User( - name, - _defaultAuthenticationProvider.GetType().FullName!, - _defaultPasswordResetProvider.GetType().FullName!) - { - InternalId = max + 1 - }; - - user.AddDefaultPermissions(); - user.AddDefaultPreferences(); - - return user; - } - - /// - public async Task CreateUserAsync(string name) - { - ThrowIfInvalidUsername(name); - - User newUser; - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { -#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons - if (await dbContext.Users - .AnyAsync(u => u.NormalizedUsername == name.ToUpperInvariant()) - .ConfigureAwait(false)) + User? user; + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { - throw new ArgumentException(string.Format( - CultureInfo.InvariantCulture, - "A user with the name '{0}' already exists.", - name)); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + user = await dbContext.Users + .Include(u => u.Permissions) + .FirstOrDefaultAsync(u => u.Id.Equals(userId)) + .ConfigureAwait(false); + + if (user is null) + { + throw new ResourceNotFoundException(nameof(userId)); + } + + var userCount = await dbContext.Users.CountAsync().ConfigureAwait(false); + if (userCount == 1) + { + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "The user '{0}' cannot be deleted because there must be at least one user in the system.", + user.Username)); + } + + if (user.HasPermission(PermissionKind.IsAdministrator) + && await dbContext.Users + .CountAsync(i => i.Permissions.Any(p => p.Kind == PermissionKind.IsAdministrator && p.Value)) + .ConfigureAwait(false) == 1) + { + throw new ArgumentException( + string.Format( + CultureInfo.InvariantCulture, + "The user '{0}' cannot be deleted because there must be at least one admin user in the system.", + user.Username), + nameof(userId)); + } + + dbContext.Users.Remove(user); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } -#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons - newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false); - - dbContext.Users.Add(newUser); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false); } - await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false); - - return newUser; - } - - /// - public async Task DeleteUserAsync(Guid userId) - { - User? user; - using (await _userLock.LockAsync(userId).ConfigureAwait(false)) + /// + public Task ResetPassword(Guid userId) { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + return ChangePassword(userId, string.Empty); + } + + /// + public async Task ChangePassword(Guid userId, string newPassword) + { + User dbUser = null!; + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { - user = await dbContext.Users - .Include(u => u.Permissions) - .FirstOrDefaultAsync(u => u.Id.Equals(userId)) - .ConfigureAwait(false); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + dbUser = await UserQuery(dbContext) + .AsTracking() + .FirstOrDefaultAsync(u => u.Id == userId) + .ConfigureAwait(false) + ?? throw new ResourceNotFoundException(nameof(userId)); + + if (dbUser.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword)) + { + throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword)); + } + + await GetAuthenticationProvider(dbUser).ChangePassword(dbUser, newPassword).ConfigureAwait(false); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + } + + await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(dbUser)).ConfigureAwait(false); + } + + /// + public UserDto GetUserDto(User user, string? remoteEndPoint = null) + { + var hasPassword = GetAuthenticationProvider(user).HasPassword(user); + var castReceiverApplications = _serverConfigurationManager.Configuration.CastReceiverApplications; + return new UserDto + { + Name = user.Username, + Id = user.Id, + ServerId = _appHost.SystemId, + HasPassword = hasPassword, + HasConfiguredPassword = hasPassword, + EnableAutoLogin = user.EnableAutoLogin, + LastLoginDate = user.LastLoginDate, + LastActivityDate = user.LastActivityDate, + PrimaryImageTag = user.ProfileImage is not null ? _imageProcessor.GetImageCacheTag(user) : null, + Configuration = new UserConfiguration + { + SubtitleMode = user.SubtitleMode, + HidePlayedInLatest = user.HidePlayedInLatest, + EnableLocalPassword = user.EnableLocalPassword, + PlayDefaultAudioTrack = user.PlayDefaultAudioTrack, + DisplayCollectionsView = user.DisplayCollectionsView, + DisplayMissingEpisodes = user.DisplayMissingEpisodes, + AudioLanguagePreference = user.AudioLanguagePreference, + RememberAudioSelections = user.RememberAudioSelections, + EnableNextEpisodeAutoPlay = user.EnableNextEpisodeAutoPlay, + RememberSubtitleSelections = user.RememberSubtitleSelections, + SubtitleLanguagePreference = user.SubtitleLanguagePreference ?? string.Empty, + OrderedViews = user.GetPreferenceValues(PreferenceKind.OrderedViews), + GroupedFolders = user.GetPreferenceValues(PreferenceKind.GroupedFolders), + MyMediaExcludes = user.GetPreferenceValues(PreferenceKind.MyMediaExcludes), + LatestItemsExcludes = user.GetPreferenceValues(PreferenceKind.LatestItemExcludes), + CastReceiverId = string.IsNullOrEmpty(user.CastReceiverId) + ? castReceiverApplications.FirstOrDefault()?.Id + : castReceiverApplications.FirstOrDefault(c => string.Equals(c.Id, user.CastReceiverId, StringComparison.Ordinal))?.Id + ?? castReceiverApplications.FirstOrDefault()?.Id + }, + Policy = new UserPolicy + { + MaxParentalRating = user.MaxParentalRatingScore, + MaxParentalSubRating = user.MaxParentalRatingSubScore, + EnableUserPreferenceAccess = user.EnableUserPreferenceAccess, + RemoteClientBitrateLimit = user.RemoteClientBitrateLimit ?? 0, + AuthenticationProviderId = user.AuthenticationProviderId, + PasswordResetProviderId = user.PasswordResetProviderId, + InvalidLoginAttemptCount = user.InvalidLoginAttemptCount, + LoginAttemptsBeforeLockout = user.LoginAttemptsBeforeLockout ?? -1, + MaxActiveSessions = user.MaxActiveSessions, + IsAdministrator = user.HasPermission(PermissionKind.IsAdministrator), + IsHidden = user.HasPermission(PermissionKind.IsHidden), + IsDisabled = user.HasPermission(PermissionKind.IsDisabled), + EnableSharedDeviceControl = user.HasPermission(PermissionKind.EnableSharedDeviceControl), + EnableRemoteAccess = user.HasPermission(PermissionKind.EnableRemoteAccess), + EnableLiveTvManagement = user.HasPermission(PermissionKind.EnableLiveTvManagement), + EnableLiveTvAccess = user.HasPermission(PermissionKind.EnableLiveTvAccess), + EnableMediaPlayback = user.HasPermission(PermissionKind.EnableMediaPlayback), + EnableAudioPlaybackTranscoding = user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding), + EnableVideoPlaybackTranscoding = user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding), + EnableContentDeletion = user.HasPermission(PermissionKind.EnableContentDeletion), + EnableContentDownloading = user.HasPermission(PermissionKind.EnableContentDownloading), + EnableSyncTranscoding = user.HasPermission(PermissionKind.EnableSyncTranscoding), + EnableMediaConversion = user.HasPermission(PermissionKind.EnableMediaConversion), + EnableAllChannels = user.HasPermission(PermissionKind.EnableAllChannels), + EnableAllDevices = user.HasPermission(PermissionKind.EnableAllDevices), + EnableAllFolders = user.HasPermission(PermissionKind.EnableAllFolders), + EnableRemoteControlOfOtherUsers = user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers), + EnablePlaybackRemuxing = user.HasPermission(PermissionKind.EnablePlaybackRemuxing), + ForceRemoteSourceTranscoding = user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding), + EnablePublicSharing = user.HasPermission(PermissionKind.EnablePublicSharing), + EnableCollectionManagement = user.HasPermission(PermissionKind.EnableCollectionManagement), + EnableSubtitleManagement = user.HasPermission(PermissionKind.EnableSubtitleManagement), + AccessSchedules = user.AccessSchedules.ToArray(), + BlockedTags = user.GetPreference(PreferenceKind.BlockedTags), + AllowedTags = user.GetPreference(PreferenceKind.AllowedTags), + EnabledChannels = user.GetPreferenceValues(PreferenceKind.EnabledChannels), + EnabledDevices = user.GetPreference(PreferenceKind.EnabledDevices), + EnabledFolders = user.GetPreferenceValues(PreferenceKind.EnabledFolders), + EnableContentDeletionFromFolders = user.GetPreference(PreferenceKind.EnableContentDeletionFromFolders), + SyncPlayAccess = user.SyncPlayAccess, + BlockedChannels = user.GetPreferenceValues(PreferenceKind.BlockedChannels), + BlockedMediaFolders = user.GetPreferenceValues(PreferenceKind.BlockedMediaFolders), + BlockUnratedItems = user.GetPreferenceValues(PreferenceKind.BlockUnratedItems) + } + }; + } + + /// + public async Task AuthenticateUser( + string username, + string password, + string remoteEndPoint, + bool isUserSession) + { + if (string.IsNullOrWhiteSpace(username)) + { + _logger.LogInformation("Authentication request without username has been denied (IP: {IP}).", remoteEndPoint); + throw new ArgumentNullException(nameof(username)); + } + + bool success; + var user = GetUserByName(username); + using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false)) + { + using var dbContext = _dbProvider.CreateDbContext(); + + // Reload the user now that we hold the lock so the RowVersion is current. + // GetUserByName uses AsNoTracking and the snapshot may be stale if another + // write (e.g. a concurrent login) incremented RowVersion after our initial load. + if (user is not null) + { + user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false) ?? user; + } + + var authResult = await AuthenticateLocalUser(username, password, user) + .ConfigureAwait(false); + var authenticationProvider = authResult.AuthenticationProvider; + success = authResult.Success; if (user is null) { - throw new ResourceNotFoundException(nameof(userId)); - } + string updatedUsername = authResult.Username; - var userCount = await dbContext.Users.CountAsync().ConfigureAwait(false); - if (userCount == 1) - { - throw new InvalidOperationException(string.Format( - CultureInfo.InvariantCulture, - "The user '{0}' cannot be deleted because there must be at least one user in the system.", - user.Username)); - } - - if (user.HasPermission(PermissionKind.IsAdministrator) - && await dbContext.Users - .CountAsync(i => i.Permissions.Any(p => p.Kind == PermissionKind.IsAdministrator && p.Value)) - .ConfigureAwait(false) == 1) - { - throw new ArgumentException( - string.Format( - CultureInfo.InvariantCulture, - "The user '{0}' cannot be deleted because there must be at least one admin user in the system.", - user.Username), - nameof(userId)); - } - - dbContext.Users.Remove(user); - await dbContext.SaveChangesAsync().ConfigureAwait(false); - } - } - - await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false); - } - - /// - public Task ResetPassword(Guid userId) - { - return ChangePassword(userId, string.Empty); - } - - /// - public async Task ChangePassword(Guid userId, string newPassword) - { - User dbUser = null!; - using (await _userLock.LockAsync(userId).ConfigureAwait(false)) - { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - dbUser = await UserQuery(dbContext) - .AsTracking() - .FirstOrDefaultAsync(u => u.Id == userId) - .ConfigureAwait(false) - ?? throw new ResourceNotFoundException(nameof(userId)); - - if (dbUser.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword)) - { - throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword)); - } - - await GetAuthenticationProvider(dbUser).ChangePassword(dbUser, newPassword).ConfigureAwait(false); - await dbContext.SaveChangesAsync().ConfigureAwait(false); - } - } - - await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(dbUser)).ConfigureAwait(false); - } - - /// - public UserDto GetUserDto(User user, string? remoteEndPoint = null) - { - var hasPassword = GetAuthenticationProvider(user).HasPassword(user); - var castReceiverApplications = _serverConfigurationManager.Configuration.CastReceiverApplications; - return new UserDto - { - Name = user.Username, - Id = user.Id, - ServerId = _appHost.SystemId, - HasPassword = hasPassword, - HasConfiguredPassword = hasPassword, - EnableAutoLogin = user.EnableAutoLogin, - LastLoginDate = user.LastLoginDate, - LastActivityDate = user.LastActivityDate, - PrimaryImageTag = user.ProfileImage is not null ? _imageProcessor.GetImageCacheTag(user) : null, - Configuration = new UserConfiguration - { - SubtitleMode = user.SubtitleMode, - HidePlayedInLatest = user.HidePlayedInLatest, - EnableLocalPassword = user.EnableLocalPassword, - PlayDefaultAudioTrack = user.PlayDefaultAudioTrack, - DisplayCollectionsView = user.DisplayCollectionsView, - DisplayMissingEpisodes = user.DisplayMissingEpisodes, - AudioLanguagePreference = user.AudioLanguagePreference, - RememberAudioSelections = user.RememberAudioSelections, - EnableNextEpisodeAutoPlay = user.EnableNextEpisodeAutoPlay, - RememberSubtitleSelections = user.RememberSubtitleSelections, - SubtitleLanguagePreference = user.SubtitleLanguagePreference ?? string.Empty, - OrderedViews = user.GetPreferenceValues(PreferenceKind.OrderedViews), - GroupedFolders = user.GetPreferenceValues(PreferenceKind.GroupedFolders), - MyMediaExcludes = user.GetPreferenceValues(PreferenceKind.MyMediaExcludes), - LatestItemsExcludes = user.GetPreferenceValues(PreferenceKind.LatestItemExcludes), - CastReceiverId = string.IsNullOrEmpty(user.CastReceiverId) - ? castReceiverApplications.FirstOrDefault()?.Id - : castReceiverApplications.FirstOrDefault(c => string.Equals(c.Id, user.CastReceiverId, StringComparison.Ordinal))?.Id - ?? castReceiverApplications.FirstOrDefault()?.Id - }, - Policy = new UserPolicy - { - MaxParentalRating = user.MaxParentalRatingScore, - MaxParentalSubRating = user.MaxParentalRatingSubScore, - EnableUserPreferenceAccess = user.EnableUserPreferenceAccess, - RemoteClientBitrateLimit = user.RemoteClientBitrateLimit ?? 0, - AuthenticationProviderId = user.AuthenticationProviderId, - PasswordResetProviderId = user.PasswordResetProviderId, - InvalidLoginAttemptCount = user.InvalidLoginAttemptCount, - LoginAttemptsBeforeLockout = user.LoginAttemptsBeforeLockout ?? -1, - MaxActiveSessions = user.MaxActiveSessions, - IsAdministrator = user.HasPermission(PermissionKind.IsAdministrator), - IsHidden = user.HasPermission(PermissionKind.IsHidden), - IsDisabled = user.HasPermission(PermissionKind.IsDisabled), - EnableSharedDeviceControl = user.HasPermission(PermissionKind.EnableSharedDeviceControl), - EnableRemoteAccess = user.HasPermission(PermissionKind.EnableRemoteAccess), - EnableLiveTvManagement = user.HasPermission(PermissionKind.EnableLiveTvManagement), - EnableLiveTvAccess = user.HasPermission(PermissionKind.EnableLiveTvAccess), - EnableMediaPlayback = user.HasPermission(PermissionKind.EnableMediaPlayback), - EnableAudioPlaybackTranscoding = user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding), - EnableVideoPlaybackTranscoding = user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding), - EnableContentDeletion = user.HasPermission(PermissionKind.EnableContentDeletion), - EnableContentDownloading = user.HasPermission(PermissionKind.EnableContentDownloading), - EnableSyncTranscoding = user.HasPermission(PermissionKind.EnableSyncTranscoding), - EnableMediaConversion = user.HasPermission(PermissionKind.EnableMediaConversion), - EnableAllChannels = user.HasPermission(PermissionKind.EnableAllChannels), - EnableAllDevices = user.HasPermission(PermissionKind.EnableAllDevices), - EnableAllFolders = user.HasPermission(PermissionKind.EnableAllFolders), - EnableRemoteControlOfOtherUsers = user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers), - EnablePlaybackRemuxing = user.HasPermission(PermissionKind.EnablePlaybackRemuxing), - ForceRemoteSourceTranscoding = user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding), - EnablePublicSharing = user.HasPermission(PermissionKind.EnablePublicSharing), - EnableCollectionManagement = user.HasPermission(PermissionKind.EnableCollectionManagement), - EnableSubtitleManagement = user.HasPermission(PermissionKind.EnableSubtitleManagement), - AccessSchedules = user.AccessSchedules.ToArray(), - BlockedTags = user.GetPreference(PreferenceKind.BlockedTags), - AllowedTags = user.GetPreference(PreferenceKind.AllowedTags), - EnabledChannels = user.GetPreferenceValues(PreferenceKind.EnabledChannels), - EnabledDevices = user.GetPreference(PreferenceKind.EnabledDevices), - EnabledFolders = user.GetPreferenceValues(PreferenceKind.EnabledFolders), - EnableContentDeletionFromFolders = user.GetPreference(PreferenceKind.EnableContentDeletionFromFolders), - SyncPlayAccess = user.SyncPlayAccess, - BlockedChannels = user.GetPreferenceValues(PreferenceKind.BlockedChannels), - BlockedMediaFolders = user.GetPreferenceValues(PreferenceKind.BlockedMediaFolders), - BlockUnratedItems = user.GetPreferenceValues(PreferenceKind.BlockUnratedItems) - } - }; - } - - /// - public async Task AuthenticateUser( - string username, - string password, - string remoteEndPoint, - bool isUserSession) - { - if (string.IsNullOrWhiteSpace(username)) - { - _logger.LogInformation("Authentication request without username has been denied (IP: {IP}).", remoteEndPoint); - throw new ArgumentNullException(nameof(username)); - } - - bool success; - var user = GetUserByName(username); - using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false)) - { - using var dbContext = _dbProvider.CreateDbContext(); - - // Reload the user now that we hold the lock so the RowVersion is current. - // GetUserByName uses AsNoTracking and the snapshot may be stale if another - // write (e.g. a concurrent login) incremented RowVersion after our initial load. - if (user is not null) - { - user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false) ?? user; - } - - var authResult = await AuthenticateLocalUser(username, password, user) - .ConfigureAwait(false); - var authenticationProvider = authResult.AuthenticationProvider; - success = authResult.Success; - - if (user is null) - { - string updatedUsername = authResult.Username; - - if (success - && authenticationProvider is not null - && authenticationProvider is not DefaultAuthenticationProvider) - { - // Trust the username returned by the authentication provider - username = updatedUsername; - - // Search the database for the user again - // the authentication provider might have created it -#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons - user = await UserQuery(dbContext) - .FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false); - - if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null) + if (success + && authenticationProvider is not null + && authenticationProvider is not DefaultAuthenticationProvider) { - await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false); + // Trust the username returned by the authentication provider + username = updatedUsername; + + // Search the database for the user again + // the authentication provider might have created it +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons user = await UserQuery(dbContext) .FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false); + + if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null) + { + await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false); + user = await UserQuery(dbContext) + .FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false); #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + } } } - } - if (success && user is not null && authenticationProvider is not null) - { - var providerId = authenticationProvider.GetType().FullName; - - if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) + if (success && user is not null && authenticationProvider is not null) { - await dbContext.Users - .Where(e => e.Id == user.Id) - .ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId)) - .ConfigureAwait(false); - } - } + var providerId = authenticationProvider.GetType().FullName; - if (user is null) - { - _logger.LogInformation( - "Authentication request for {UserName} has been denied (IP: {IP}).", - username, - remoteEndPoint); - throw new AuthenticationException("Invalid username or password entered."); - } - - if (user.HasPermission(PermissionKind.IsDisabled)) - { - _logger.LogInformation( - "Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).", - username, - remoteEndPoint); - throw new SecurityException( - $"The {user.Username} account is currently disabled. Please consult with your administrator."); - } - - if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && - !_networkManager.IsInLocalNetwork(remoteEndPoint)) - { - _logger.LogInformation( - "Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).", - username, - remoteEndPoint); - throw new SecurityException("Forbidden."); - } - - if (!user.IsParentalScheduleAllowed()) - { - _logger.LogInformation( - "Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).", - username, - remoteEndPoint); - throw new SecurityException("User is not allowed access at this time."); - } - - // Update LastActivityDate and LastLoginDate, then save - if (success) - { - if (isUserSession) - { - var date = DateTime.UtcNow; - await dbContext.Users - .Where(e => e.Id == user.Id) - .ExecuteUpdateAsync(e => e - .SetProperty(f => f.LastActivityDate, date) - .SetProperty(f => f.LastLoginDate, date)) - .ConfigureAwait(false); + if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) + { + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId)) + .ConfigureAwait(false); + } } - await dbContext.Users - .Where(e => e.Id == user.Id) - .ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, 0)) - .ConfigureAwait(false); - _logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username); - } - else - { - user.InvalidLoginAttemptCount++; - int? maxInvalidLogins = user.LoginAttemptsBeforeLockout; - if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins) + if (user is null) { - user.SetPermission(PermissionKind.IsDisabled, true); - await dbContext.SaveChangesAsync() + _logger.LogInformation( + "Authentication request for {UserName} has been denied (IP: {IP}).", + username, + remoteEndPoint); + throw new AuthenticationException("Invalid username or password entered."); + } + + if (user.HasPermission(PermissionKind.IsDisabled)) + { + _logger.LogInformation( + "Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).", + username, + remoteEndPoint); + throw new SecurityException( + $"The {user.Username} account is currently disabled. Please consult with your administrator."); + } + + if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && + !_networkManager.IsInLocalNetwork(remoteEndPoint)) + { + _logger.LogInformation( + "Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).", + username, + remoteEndPoint); + throw new SecurityException("Forbidden."); + } + + if (!user.IsParentalScheduleAllowed()) + { + _logger.LogInformation( + "Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).", + username, + remoteEndPoint); + throw new SecurityException("User is not allowed access at this time."); + } + + // Update LastActivityDate and LastLoginDate, then save + if (success) + { + if (isUserSession) + { + var date = DateTime.UtcNow; + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e + .SetProperty(f => f.LastActivityDate, date) + .SetProperty(f => f.LastLoginDate, date)) + .ConfigureAwait(false); + } + + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, 0)) .ConfigureAwait(false); - await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false); - _logger.LogWarning( - "Disabling user {Username} due to {Attempts} unsuccessful login attempts.", + _logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username); + } + else + { + user.InvalidLoginAttemptCount++; + int? maxInvalidLogins = user.LoginAttemptsBeforeLockout; + if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins) + { + user.SetPermission(PermissionKind.IsDisabled, true); + await dbContext.SaveChangesAsync() + .ConfigureAwait(false); + await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false); + _logger.LogWarning( + "Disabling user {Username} due to {Attempts} unsuccessful login attempts.", + user.Username, + user.InvalidLoginAttemptCount); + } + + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, f => f.InvalidLoginAttemptCount + 1)) + .ConfigureAwait(false); + + _logger.LogInformation( + "Authentication request for {UserName} has been denied (IP: {IP}).", user.Username, - user.InvalidLoginAttemptCount); + remoteEndPoint); } - - await dbContext.Users - .Where(e => e.Id == user.Id) - .ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, f => f.InvalidLoginAttemptCount + 1)) - .ConfigureAwait(false); - - _logger.LogInformation( - "Authentication request for {UserName} has been denied (IP: {IP}).", - user.Username, - remoteEndPoint); - } - } - - return success ? user : null; - } - - /// - public async Task StartForgotPasswordProcess(string enteredUsername, bool isInNetwork) - { - var user = string.IsNullOrWhiteSpace(enteredUsername) ? null : GetUserByName(enteredUsername); - var passwordResetProvider = GetPasswordResetProvider(user); - - var result = await passwordResetProvider - .StartForgotPasswordProcess(user, enteredUsername, isInNetwork) - .ConfigureAwait(false); - - if (user is not null && isInNetwork) - { - await UpdateUserAsync(user).ConfigureAwait(false); - } - - return result; - } - - /// - public async Task RedeemPasswordResetPin(string pin) - { - foreach (var provider in _passwordResetProviders) - { - var result = await provider.RedeemPasswordResetPin(pin).ConfigureAwait(false); - - if (result.Success) - { - return result; - } - } - - return new PinRedeemResult(); - } - - /// - public async Task InitializeAsync() - { - // TODO: Refactor the startup wizard so that it doesn't require a user to already exist. - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - if (await dbContext.Users.AnyAsync().ConfigureAwait(false)) - { - return; } - var defaultName = Environment.UserName; - if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName)) + return success ? user : null; + } + + /// + public async Task StartForgotPasswordProcess(string enteredUsername, bool isInNetwork) + { + var user = string.IsNullOrWhiteSpace(enteredUsername) ? null : GetUserByName(enteredUsername); + var passwordResetProvider = GetPasswordResetProvider(user); + + var result = await passwordResetProvider + .StartForgotPasswordProcess(user, enteredUsername, isInNetwork) + .ConfigureAwait(false); + + if (user is not null && isInNetwork) { - defaultName = "MyJellyfinUser"; + await UpdateUserAsync(user).ConfigureAwait(false); } - _logger.LogWarning("No users, creating one with username {UserName}", defaultName); - - var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false); - newUser.SetPermission(PermissionKind.IsAdministrator, true); - newUser.SetPermission(PermissionKind.EnableContentDeletion, true); - newUser.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, true); - - dbContext.Users.Add(newUser); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + return result; } - } - /// - public NameIdPair[] GetAuthenticationProviders() - { - return _authenticationProviders - .Where(provider => provider.IsEnabled) - .OrderBy(i => i is DefaultAuthenticationProvider ? 0 : 1) - .ThenBy(i => i.Name) - .Select(i => new NameIdPair - { - Name = i.Name, - Id = i.GetType().FullName - }) - .ToArray(); - } - - /// - public NameIdPair[] GetPasswordResetProviders() - { - return _passwordResetProviders - .Where(provider => provider.IsEnabled) - .OrderBy(i => i is DefaultPasswordResetProvider ? 0 : 1) - .ThenBy(i => i.Name) - .Select(i => new NameIdPair - { - Name = i.Name, - Id = i.GetType().FullName - }) - .ToArray(); - } - - /// - public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config) - { - using (await _userLock.LockAsync(userId).ConfigureAwait(false)) + /// + public async Task RedeemPasswordResetPin(string pin) { + foreach (var provider in _passwordResetProviders) + { + var result = await provider.RedeemPasswordResetPin(pin).ConfigureAwait(false); + + if (result.Success) + { + return result; + } + } + + return new PinRedeemResult(); + } + + /// + public async Task InitializeAsync() + { + // TODO: Refactor the startup wizard so that it doesn't require a user to already exist. var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - var user = UserQuery(dbContext) + if (await dbContext.Users.AnyAsync().ConfigureAwait(false)) + { + return; + } + + var defaultName = Environment.UserName; + if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName)) + { + defaultName = "MyJellyfinUser"; + } + + _logger.LogWarning("No users, creating one with username {UserName}", defaultName); + + var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false); + newUser.SetPermission(PermissionKind.IsAdministrator, true); + newUser.SetPermission(PermissionKind.EnableContentDeletion, true); + newUser.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, true); + + dbContext.Users.Add(newUser); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + } + + /// + public NameIdPair[] GetAuthenticationProviders() + { + return _authenticationProviders + .Where(provider => provider.IsEnabled) + .OrderBy(i => i is DefaultAuthenticationProvider ? 0 : 1) + .ThenBy(i => i.Name) + .Select(i => new NameIdPair + { + Name = i.Name, + Id = i.GetType().FullName + }) + .ToArray(); + } + + /// + public NameIdPair[] GetPasswordResetProviders() + { + return _passwordResetProviders + .Where(provider => provider.IsEnabled) + .OrderBy(i => i is DefaultPasswordResetProvider ? 0 : 1) + .ThenBy(i => i.Name) + .Select(i => new NameIdPair + { + Name = i.Name, + Id = i.GetType().FullName + }) + .ToArray(); + } + + /// + public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config) + { + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) + { + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var user = UserQuery(dbContext) + .AsTracking() + .FirstOrDefault(u => u.Id.Equals(userId)) + ?? throw new ArgumentException("No user exists with given Id!"); + + user.SubtitleMode = config.SubtitleMode; + user.HidePlayedInLatest = config.HidePlayedInLatest; + user.EnableLocalPassword = config.EnableLocalPassword; + user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack; + user.DisplayCollectionsView = config.DisplayCollectionsView; + user.DisplayMissingEpisodes = config.DisplayMissingEpisodes; + user.AudioLanguagePreference = config.AudioLanguagePreference; + user.RememberAudioSelections = config.RememberAudioSelections; + user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay; + user.RememberSubtitleSelections = config.RememberSubtitleSelections; + user.SubtitleLanguagePreference = config.SubtitleLanguagePreference; + + // Only set cast receiver id if it is passed in and it exists in the server config. + if (!string.IsNullOrEmpty(config.CastReceiverId) + && _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal))) + { + user.CastReceiverId = config.CastReceiverId; + } + + user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews); + user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders); + user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes); + user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes); + + dbContext.Update(user); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + } + } + + /// + public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy) + { + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) + { + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var user = UserQuery(dbContext) .AsTracking() .FirstOrDefault(u => u.Id.Equals(userId)) ?? throw new ArgumentException("No user exists with given Id!"); - user.SubtitleMode = config.SubtitleMode; - user.HidePlayedInLatest = config.HidePlayedInLatest; - user.EnableLocalPassword = config.EnableLocalPassword; - user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack; - user.DisplayCollectionsView = config.DisplayCollectionsView; - user.DisplayMissingEpisodes = config.DisplayMissingEpisodes; - user.AudioLanguagePreference = config.AudioLanguagePreference; - user.RememberAudioSelections = config.RememberAudioSelections; - user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay; - user.RememberSubtitleSelections = config.RememberSubtitleSelections; - user.SubtitleLanguagePreference = config.SubtitleLanguagePreference; + // The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0" + int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch + { + -1 => null, + 0 => 3, + _ => policy.LoginAttemptsBeforeLockout + }; - // Only set cast receiver id if it is passed in and it exists in the server config. - if (!string.IsNullOrEmpty(config.CastReceiverId) - && _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal))) - { - user.CastReceiverId = config.CastReceiverId; + user.MaxParentalRatingScore = policy.MaxParentalRating; + user.MaxParentalRatingSubScore = policy.MaxParentalSubRating; + user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess; + user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit; + user.AuthenticationProviderId = policy.AuthenticationProviderId; + user.PasswordResetProviderId = policy.PasswordResetProviderId; + user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount; + user.LoginAttemptsBeforeLockout = maxLoginAttempts; + user.MaxActiveSessions = policy.MaxActiveSessions; + user.SyncPlayAccess = policy.SyncPlayAccess; + user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator); + user.SetPermission(PermissionKind.IsHidden, policy.IsHidden); + user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled); + user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl); + user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess); + user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement); + user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess); + user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback); + user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding); + user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding); + user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion); + user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading); + user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding); + user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion); + user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels); + user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices); + user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders); + user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers); + user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing); + user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement); + user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement); + user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement); + user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding); + user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing); + + user.AccessSchedules.Clear(); + foreach (var policyAccessSchedule in policy.AccessSchedules) + { + user.AccessSchedules.Add(policyAccessSchedule); + } + + // TODO: fix this at some point + user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty()); + user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags); + user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags); + user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels); + user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices); + user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders); + user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); + + dbContext.Update(user); + await dbContext.SaveChangesAsync().ConfigureAwait(false); } - - user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews); - user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders); - user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes); - user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes); - - dbContext.Update(user); - await dbContext.SaveChangesAsync().ConfigureAwait(false); - } - } - } - - /// - public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy) - { - using (await _userLock.LockAsync(userId).ConfigureAwait(false)) - { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - var user = UserQuery(dbContext) - .AsTracking() - .FirstOrDefault(u => u.Id.Equals(userId)) - ?? throw new ArgumentException("No user exists with given Id!"); - - // The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0" - int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch - { - -1 => null, - 0 => 3, - _ => policy.LoginAttemptsBeforeLockout - }; - - user.MaxParentalRatingScore = policy.MaxParentalRating; - user.MaxParentalRatingSubScore = policy.MaxParentalSubRating; - user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess; - user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit; - user.AuthenticationProviderId = policy.AuthenticationProviderId; - user.PasswordResetProviderId = policy.PasswordResetProviderId; - user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount; - user.LoginAttemptsBeforeLockout = maxLoginAttempts; - user.MaxActiveSessions = policy.MaxActiveSessions; - user.SyncPlayAccess = policy.SyncPlayAccess; - user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator); - user.SetPermission(PermissionKind.IsHidden, policy.IsHidden); - user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled); - user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl); - user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess); - user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement); - user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess); - user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback); - user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding); - user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding); - user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion); - user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading); - user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding); - user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion); - user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels); - user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices); - user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders); - user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers); - user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing); - user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement); - user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement); - user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement); - user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding); - user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing); - - user.AccessSchedules.Clear(); - foreach (var policyAccessSchedule in policy.AccessSchedules) - { - user.AccessSchedules.Add(policyAccessSchedule); - } - - // TODO: fix this at some point - user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty()); - user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags); - user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags); - user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels); - user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices); - user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders); - user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); - - dbContext.Update(user); - await dbContext.SaveChangesAsync().ConfigureAwait(false); - } - } - } - - /// - public async Task ClearProfileImageAsync(User user) - { - if (user.ProfileImage is null) - { - return; - } - - using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) - { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - dbContext.Remove(user.ProfileImage); - await dbContext.SaveChangesAsync().ConfigureAwait(false); - } - - user.ProfileImage = null; - } - } - - internal static void ThrowIfInvalidUsername(string name) - { - if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name)) - { - return; - } - - throw new ArgumentException("Usernames can contain unicode symbols, numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)", nameof(name)); - } - - private IAuthenticationProvider GetAuthenticationProvider(User user) - { - return GetAuthenticationProviders(user)[0]; - } - - private IPasswordResetProvider GetPasswordResetProvider(User? user) - { - if (user is null) - { - return _defaultPasswordResetProvider; - } - - return GetPasswordResetProviders(user)[0]; - } - - private List GetAuthenticationProviders(User? user) - { - var authenticationProviderId = user?.AuthenticationProviderId; - - var providers = _authenticationProviders.Where(i => i.IsEnabled).ToList(); - - if (!string.IsNullOrEmpty(authenticationProviderId)) - { - providers = providers.Where(i => string.Equals(authenticationProviderId, i.GetType().FullName, StringComparison.OrdinalIgnoreCase)).ToList(); - } - - if (providers.Count == 0) - { - // Assign the user to the InvalidAuthProvider since no configured auth provider was valid/found - _logger.LogWarning( - "User {Username} was found with invalid/missing Authentication Provider {AuthenticationProviderId}. Assigning user to InvalidAuthProvider until this is corrected", - user?.Username, - user?.AuthenticationProviderId); - providers = new List - { - _invalidAuthProvider - }; - } - - return providers; - } - - private IPasswordResetProvider[] GetPasswordResetProviders(User user) - { - var passwordResetProviderId = user.PasswordResetProviderId; - var providers = _passwordResetProviders.Where(i => i.IsEnabled).ToArray(); - - if (!string.IsNullOrEmpty(passwordResetProviderId)) - { - providers = providers.Where(i => - string.Equals(passwordResetProviderId, i.GetType().FullName, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - } - - if (providers.Length == 0) - { - providers = new IPasswordResetProvider[] - { - _defaultPasswordResetProvider - }; - } - - return providers; - } - - private async Task<(IAuthenticationProvider? AuthenticationProvider, string Username, bool Success)> AuthenticateLocalUser( - string username, - string password, - User? user) - { - bool success = false; - IAuthenticationProvider? authenticationProvider = null; - - foreach (var provider in GetAuthenticationProviders(user)) - { - var providerAuthResult = - await AuthenticateWithProvider(provider, username, password, user).ConfigureAwait(false); - var updatedUsername = providerAuthResult.Username; - success = providerAuthResult.Success; - - if (success) - { - authenticationProvider = provider; - username = updatedUsername; - break; } } - return (authenticationProvider, username, success); - } - - private async Task<(string Username, bool Success)> AuthenticateWithProvider( - IAuthenticationProvider provider, - string username, - string password, - User? resolvedUser) - { - try + /// + public async Task ClearProfileImageAsync(User user) { - var authenticationResult = provider is IRequiresResolvedUser requiresResolvedUser - ? await requiresResolvedUser.Authenticate(username, password, resolvedUser).ConfigureAwait(false) - : await provider.Authenticate(username, password).ConfigureAwait(false); - - if (authenticationResult.Username != username) - { - _logger.LogDebug("Authentication provider provided updated username {1}", authenticationResult.Username); - username = authenticationResult.Username; - } - - return (username, true); - } - catch (AuthenticationException ex) - { - _logger.LogDebug(ex, "Error authenticating with provider {Provider}", provider.Name); - - return (username, false); - } - } - - private async Task UpdateUserInternalAsync(User user) - { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); - } - } - - private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) - { - dbContext.Users.Attach(user); - dbContext.Entry(user).State = EntityState.Modified; - await dbContext.SaveChangesAsync().ConfigureAwait(false); - } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Disposes all members of this class. - /// - /// Defines if the class has been cleaned up by a dispose or finalizer. - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - _userLock.Dispose(); - } - } - - internal sealed class LockHelper : IDisposable - { - private readonly AsyncKeyedLocker _userLock = new(); - - private bool _disposed; - - public static AsyncLocal IsNestedLock { get; set; } = new(); - - public bool ShouldLock() - { - return IsNestedLock.Value == 0; - } - - public ValueTask LockAsync(Guid key) - { - ThrowIfDisposed(); - var isNested = LockHelper.IsNestedLock.Value != 0; - LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value + 1; - if (isNested) - { - return new ValueTask(new LockHandle { Parent = null }); - } - - return AcquireLockAsync(key); - } - - private async ValueTask AcquireLockAsync(Guid key) - { - var lockHandle = await _userLock.LockAsync(key, true).ConfigureAwait(false); - return new LockHandle { Parent = lockHandle }; - } - - public void Dispose() - { - if (_disposed) + if (user.ProfileImage is null) { return; } - _disposed = true; - _userLock.Dispose(); + using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) + { + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + dbContext.Remove(user.ProfileImage); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + + user.ProfileImage = null; + } } - private void ThrowIfDisposed() + internal static void ThrowIfInvalidUsername(string name) { - ObjectDisposedException.ThrowIf(_disposed, this); + if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name)) + { + return; + } + + throw new ArgumentException("Usernames can contain unicode symbols, numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)", nameof(name)); } - private sealed class LockHandle : IDisposable + private IAuthenticationProvider GetAuthenticationProvider(User user) { - public required IDisposable? Parent { get; init; } + return GetAuthenticationProviders(user)[0]; + } + + private IPasswordResetProvider GetPasswordResetProvider(User? user) + { + if (user is null) + { + return _defaultPasswordResetProvider; + } + + return GetPasswordResetProviders(user)[0]; + } + + private List GetAuthenticationProviders(User? user) + { + var authenticationProviderId = user?.AuthenticationProviderId; + + var providers = _authenticationProviders.Where(i => i.IsEnabled).ToList(); + + if (!string.IsNullOrEmpty(authenticationProviderId)) + { + providers = providers.Where(i => string.Equals(authenticationProviderId, i.GetType().FullName, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + if (providers.Count == 0) + { + // Assign the user to the InvalidAuthProvider since no configured auth provider was valid/found + _logger.LogWarning( + "User {Username} was found with invalid/missing Authentication Provider {AuthenticationProviderId}. Assigning user to InvalidAuthProvider until this is corrected", + user?.Username, + user?.AuthenticationProviderId); + providers = new List + { + _invalidAuthProvider + }; + } + + return providers; + } + + private IPasswordResetProvider[] GetPasswordResetProviders(User user) + { + var passwordResetProviderId = user.PasswordResetProviderId; + var providers = _passwordResetProviders.Where(i => i.IsEnabled).ToArray(); + + if (!string.IsNullOrEmpty(passwordResetProviderId)) + { + providers = providers.Where(i => + string.Equals(passwordResetProviderId, i.GetType().FullName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + + if (providers.Length == 0) + { + providers = new IPasswordResetProvider[] + { + _defaultPasswordResetProvider + }; + } + + return providers; + } + + private async Task<(IAuthenticationProvider? AuthenticationProvider, string Username, bool Success)> AuthenticateLocalUser( + string username, + string password, + User? user) + { + bool success = false; + IAuthenticationProvider? authenticationProvider = null; + + foreach (var provider in GetAuthenticationProviders(user)) + { + var providerAuthResult = + await AuthenticateWithProvider(provider, username, password, user).ConfigureAwait(false); + var updatedUsername = providerAuthResult.Username; + success = providerAuthResult.Success; + + if (success) + { + authenticationProvider = provider; + username = updatedUsername; + break; + } + } + + return (authenticationProvider, username, success); + } + + private async Task<(string Username, bool Success)> AuthenticateWithProvider( + IAuthenticationProvider provider, + string username, + string password, + User? resolvedUser) + { + try + { + var authenticationResult = provider is IRequiresResolvedUser requiresResolvedUser + ? await requiresResolvedUser.Authenticate(username, password, resolvedUser).ConfigureAwait(false) + : await provider.Authenticate(username, password).ConfigureAwait(false); + + if (authenticationResult.Username != username) + { + _logger.LogDebug("Authentication provider provided updated username {1}", authenticationResult.Username); + username = authenticationResult.Username; + } + + return (username, true); + } + catch (AuthenticationException ex) + { + _logger.LogDebug(ex, "Error authenticating with provider {Provider}", provider.Name); + + return (username, false); + } + } + + private async Task UpdateUserInternalAsync(User user) + { + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); + } + } + + private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) + { + dbContext.Users.Attach(user); + dbContext.Entry(user).State = EntityState.Modified; + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes all members of this class. + /// + /// Defines if the class has been cleaned up by a dispose or finalizer. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _userLock.Dispose(); + } + } + + internal sealed class LockHelper : IDisposable + { + private readonly AsyncKeyedLocker _userLock = new(); + + private bool _disposed; + + public static AsyncLocal IsNestedLock { get; set; } = new(); + + public bool ShouldLock() + { + return IsNestedLock.Value == 0; + } + + public ValueTask LockAsync(Guid key) + { + ThrowIfDisposed(); + var isNested = LockHelper.IsNestedLock.Value != 0; + LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value + 1; + if (isNested) + { + return new ValueTask(new LockHandle { Parent = null }); + } + + return AcquireLockAsync(key); + } + + private async ValueTask AcquireLockAsync(Guid key) + { + var lockHandle = await _userLock.LockAsync(key, true).ConfigureAwait(false); + return new LockHandle { Parent = lockHandle }; + } public void Dispose() { - Parent?.Dispose(); - LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value - 1; - - if (LockHelper.IsNestedLock.Value < 0) + if (_disposed) { - throw new InvalidOperationException("Mismatched locking detected. Threads internal NestedLock is less then 0 which should not be possible."); + return; + } + + _disposed = true; + _userLock.Dispose(); + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + private sealed class LockHandle : IDisposable + { + public required IDisposable? Parent { get; init; } + + public void Dispose() + { + Parent?.Dispose(); + LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value - 1; + + if (LockHelper.IsNestedLock.Value < 0) + { + throw new InvalidOperationException("Mismatched locking detected. Threads internal NestedLock is less then 0 which should not be possible."); + } } } } From 8c65dfefa1342d05f07fb6108a7415686ca149ed Mon Sep 17 00:00:00 2001 From: JPVenson Date: Sun, 31 May 2026 13:38:26 +0000 Subject: [PATCH 4/6] Add more explicit copy operation in Update --- .../Users/UserManager.cs | 62 ++++++++++++++++--- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index a4ea340681..79058a679e 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -216,7 +216,58 @@ namespace Jellyfin.Server.Implementations.Users { using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) { - await UpdateUserInternalAsync(user).ConfigureAwait(false); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + // TODO: this is a bit of a hack. Because the user entity can be created in another context, it is maybe tracked elsewhere and navigation properties do not easily move between context. Solution is to use proper DTOs instead. + var dbUser = await UserQuery(dbContext) + .AsTracking() + .FirstOrDefaultAsync(u => u.Id == user.Id) + .ConfigureAwait(false) + ?? throw new ResourceNotFoundException(nameof(user.Id)); + + dbContext.Entry(dbUser).CurrentValues.SetValues(user); + dbUser.Permissions.Clear(); + foreach (var permission in user.Permissions) + { + dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value)); + } + + dbUser.Preferences.Clear(); + foreach (var preference in user.Preferences) + { + dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value)); + } + + dbUser.AccessSchedules.Clear(); + foreach (var accessSchedule in user.AccessSchedules) + { + dbUser.AccessSchedules.Add(new AccessSchedule(accessSchedule.DayOfWeek, accessSchedule.StartHour, accessSchedule.EndHour, dbUser.Id)); + } + + if (user.ProfileImage is null) + { + if (dbUser.ProfileImage is not null) + { + dbContext.Remove(dbUser.ProfileImage); + dbUser.ProfileImage = null; + } + } + else if (dbUser.ProfileImage is null) + { + dbUser.ProfileImage = new Jellyfin.Database.Implementations.Entities.ImageInfo(user.ProfileImage.Path) + { + LastModified = user.ProfileImage.LastModified + }; + } + else + { + dbUser.ProfileImage.Path = user.ProfileImage.Path; + dbUser.ProfileImage.LastModified = user.ProfileImage.LastModified; + } + + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } } @@ -968,15 +1019,6 @@ namespace Jellyfin.Server.Implementations.Users } } - private async Task UpdateUserInternalAsync(User user) - { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); - } - } - private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) { dbContext.Users.Attach(user); From 143aee7e9ecd2e83bd82421ceb89d4cd45622fef Mon Sep 17 00:00:00 2001 From: JPVenson Date: Sun, 31 May 2026 15:12:49 +0000 Subject: [PATCH 5/6] add hack for LDAP plugin --- Jellyfin.Server.Implementations/Users/UserManager.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 79058a679e..800413eee9 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -526,6 +526,13 @@ namespace Jellyfin.Server.Implementations.Users var authenticationProvider = authResult.AuthenticationProvider; success = authResult.Success; + if (success && user is not null) + { + // refresh the user if the auth provider might have updated it in the auth method. + // this is a hack, this needs removal once the LDAP plugin uses the correct interface to get the user we hand in here and update that one instead. + user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false); + } + if (user is null) { string updatedUsername = authResult.Username; From 7bde1ac224ad50417cc6a8eb02975f992c21f7e5 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Sun, 31 May 2026 17:23:31 +0000 Subject: [PATCH 6/6] Add tests --- .../Users/UserManagerLockHelperTests.cs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs new file mode 100644 index 0000000000..ab6f0fd32e --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs @@ -0,0 +1,89 @@ +using System; +using System.Threading.Tasks; +using Jellyfin.Server.Implementations.Users; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users +{ + public class UserManagerLockHelperTests + { + [Fact] + public async Task LockAsync_WhenNested_DoesNotAcquireSecondLockAndRestoresStateOnDispose() + { + UserManager.LockHelper.IsNestedLock.Value = 0; + using var helper = new UserManager.LockHelper(); + var key = Guid.NewGuid(); + + Assert.True(helper.ShouldLock()); + + var outerHandle = await helper.LockAsync(key); + Assert.False(helper.ShouldLock()); + + var innerHandle = await helper.LockAsync(key); + Assert.False(helper.ShouldLock()); + + innerHandle.Dispose(); + Assert.False(helper.ShouldLock()); + + outerHandle.Dispose(); + Assert.True(helper.ShouldLock()); + } + + [Fact] + public async Task LockAsync_WithSameKey_BlocksSecondLockUntilFirstIsReleased() + { + UserManager.LockHelper.IsNestedLock.Value = 0; + using var helper = new UserManager.LockHelper(); + var key = Guid.NewGuid(); + + var firstAcquired = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondEntered = false; + + var firstTask = Task.Run(async () => + { + using var firstHandle = await helper.LockAsync(key); + firstAcquired.SetResult(true); + await releaseFirst.Task; + }); + + await firstAcquired.Task; + + var secondTask = Task.Run(async () => + { + using var secondHandle = await helper.LockAsync(key); + secondEntered = true; + }); + + await Task.Delay(100); + Assert.False(secondEntered); + + releaseFirst.SetResult(true); + + await Task.WhenAll(firstTask, secondTask); + Assert.True(secondEntered); + } + + [Fact] + public async Task LockAsync_WhenDisposed_ThrowsObjectDisposedException() + { + UserManager.LockHelper.IsNestedLock.Value = 0; + using var helper = new UserManager.LockHelper(); + helper.Dispose(); + + await Assert.ThrowsAsync(async () => await helper.LockAsync(Guid.NewGuid())); + } + + [Fact] + public void Dispose_WhenCalledMultipleTimes_DoesNotThrow() + { + UserManager.LockHelper.IsNestedLock.Value = 0; + using var helper = new UserManager.LockHelper(); + + helper.Dispose(); + var ex = Record.Exception(() => helper.Dispose()); + + Assert.Null(ex); + } + } +}