From ec054f6a345ab6407f39a95c3404b3d7651b2993 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Tue, 5 May 2026 17:57:27 +0000 Subject: [PATCH] Backport changes from #15368 --- .../Session/SessionManager.cs | 2 +- Jellyfin.Api/Controllers/StartupController.cs | 13 +- Jellyfin.Api/Controllers/UserController.cs | 14 +- .../Users/DefaultPasswordResetProvider.cs | 2 +- .../Users/UserManager.cs | 695 ++++++++++-------- .../Library/IUserManager.cs | 27 +- src/Jellyfin.LiveTv/LiveTvManager.cs | 4 +- .../Recordings/RecordingNotifier.cs | 2 +- 8 files changed, 434 insertions(+), 325 deletions(-) diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 5652d0c9b5..582e8ce8dc 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -2049,7 +2049,7 @@ namespace Emby.Server.Implementations.Session { CheckDisposed(); - var adminUserIds = _userManager.Users + var adminUserIds = _userManager.GetUsers() .Where(i => i.HasPermission(PermissionKind.IsAdministrator)) .Select(i => i.Id) .ToList(); diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index 09f20558fe..9378cfedb6 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -1,5 +1,5 @@ +using System; using System.ComponentModel.DataAnnotations; -using System.Linq; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Models.StartupDtos; @@ -111,7 +111,7 @@ public class StartupController : BaseJellyfinApiController { // TODO: Remove this method when startup wizard no longer requires an existing user. await _userManager.InitializeAsync().ConfigureAwait(false); - var user = _userManager.Users.First(); + var user = _userManager.GetFirstUser() ?? throw new InvalidOperationException("No user exists after initialization."); return new StartupUserDto { Name = user.Username @@ -131,7 +131,12 @@ public class StartupController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task UpdateStartupUser([FromBody] StartupUserDto startupUserDto) { - var user = _userManager.Users.First(); + var user = _userManager.GetFirstUser(); + if (user is null) + { + return NotFound(); + } + if (string.IsNullOrWhiteSpace(startupUserDto.Password)) { return BadRequest("Password must not be empty"); @@ -146,7 +151,7 @@ public class StartupController : BaseJellyfinApiController if (!string.IsNullOrEmpty(startupUserDto.Password)) { - await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false); + await _userManager.ChangePassword(user.Id, startupUserDto.Password).ConfigureAwait(false); } return NoContent(); diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs index 536b95dbb5..55cc66f79f 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -288,7 +288,7 @@ public class UserController : BaseJellyfinApiController if (request.ResetPassword) { - await _userManager.ResetPassword(user).ConfigureAwait(false); + await _userManager.ResetPassword(user.Id).ConfigureAwait(false); } else { @@ -306,7 +306,7 @@ public class UserController : BaseJellyfinApiController } } - await _userManager.ChangePassword(user, request.NewPw ?? string.Empty).ConfigureAwait(false); + await _userManager.ChangePassword(user.Id, request.NewPw ?? string.Empty).ConfigureAwait(false); var currentToken = User.GetToken(); @@ -369,7 +369,7 @@ public class UserController : BaseJellyfinApiController if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) { - await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false); + await _userManager.RenameUser(user.Id, user.Username, updateUser.Name).ConfigureAwait(false); } await _userManager.UpdateConfigurationAsync(requestUserId, updateUser.Configuration).ConfigureAwait(false); @@ -425,7 +425,7 @@ public class UserController : BaseJellyfinApiController // If removing admin access if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator)) { - if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) + if (_userManager.GetUsers().Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) { return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access."); } @@ -440,7 +440,7 @@ public class UserController : BaseJellyfinApiController // If disabling if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled)) { - if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) + if (_userManager.GetUsers().Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) { return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system."); } @@ -522,7 +522,7 @@ public class UserController : BaseJellyfinApiController // no need to authenticate password for new user if (request.Password is not null) { - await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); + await _userManager.ChangePassword(newUser.Id, request.Password).ConfigureAwait(false); } var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIP().ToString()); @@ -597,7 +597,7 @@ public class UserController : BaseJellyfinApiController private IEnumerable Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) { - var users = _userManager.Users; + var users = _userManager.GetUsers(); if (isDisabled.HasValue) { diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs index 49a9fda943..7371545914 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs @@ -74,7 +74,7 @@ namespace Jellyfin.Server.Implementations.Users var resetUser = userManager.GetUserByName(spr.UserName) ?? throw new ResourceNotFoundException($"User with a username of {spr.UserName} not found"); - await userManager.ChangePassword(resetUser, pin).ConfigureAwait(false); + await userManager.ChangePassword(resetUser.Id, pin).ConfigureAwait(false); usersReset.Add(resetUser.Username); File.Delete(resetFile); } diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 7292e9c7a9..8c0cbbd448 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -1,12 +1,14 @@ #pragma warning disable CA1307 +#pragma warning disable RS0030 // Do not use banned APIs using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; @@ -35,7 +37,7 @@ namespace Jellyfin.Server.Implementations.Users /// /// Manages the creation and retrieval of instances. /// - public partial class UserManager : IUserManager + public partial class UserManager : IUserManager, IDisposable { private readonly IDbContextFactory _dbProvider; private readonly IEventManager _eventManager; @@ -50,7 +52,7 @@ namespace Jellyfin.Server.Implementations.Users private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IDictionary _users; + private readonly AsyncKeyedLocker _userLock = new(); /// /// Initializes a new instance of the class. @@ -89,29 +91,28 @@ namespace Jellyfin.Server.Implementations.Users _invalidAuthProvider = _authenticationProviders.OfType().First(); _defaultAuthenticationProvider = _authenticationProviders.OfType().First(); _defaultPasswordResetProvider = _passwordResetProviders.OfType().First(); - - _users = new ConcurrentDictionary(); - using var dbContext = _dbProvider.CreateDbContext(); - foreach (var user in dbContext.Users - .AsSingleQuery() - .Include(user => user.Permissions) - .Include(user => user.Preferences) - .Include(user => user.AccessSchedules) - .Include(user => user.ProfileImage) - .AsEnumerable()) - { - _users.Add(user.Id, user); - } } /// public event EventHandler>? OnUserUpdated; /// - public IEnumerable Users => _users.Values; + public IEnumerable GetUsers() + { + using var dbContext = _dbProvider.CreateDbContext(); + return UserQuery(dbContext) + .ToArray(); + } /// - public IEnumerable UsersIds => _users.Keys; + 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 @@ -127,8 +128,27 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentException("Guid can't be empty", nameof(id)); } - _users.TryGetValue(id, out var user); - return user; + 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(); } /// @@ -139,42 +159,57 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentException("Invalid username", nameof(name)); } - return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase)); + using var dbContext = _dbProvider.CreateDbContext(); +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons +#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture +#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings + return UserQuery(dbContext) + .FirstOrDefault(u => u.Username.ToUpper() == name.ToUpper()); +#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings +#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture +#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons } /// - public async Task RenameUser(User user, string newName) + public async Task RenameUser(Guid userId, string oldName, string newName) { - ArgumentNullException.ThrowIfNull(user); - ThrowIfInvalidUsername(newName); - if (user.Username.Equals(newName, StringComparison.Ordinal)) + if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException("The new and old names must be different."); } - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + 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 #pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture #pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings - if (await dbContext.Users - .AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && !u.Id.Equals(user.Id)) - .ConfigureAwait(false)) - { - throw new ArgumentException(string.Format( - CultureInfo.InvariantCulture, - "A user with the name '{0}' already exists.", - newName)); - } + if (await dbContext.Users + .AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && u.Id != userId) + .ConfigureAwait(false)) + { + throw new ArgumentException(string.Format( + CultureInfo.InvariantCulture, + "A user with the name '{0}' already exists.", + newName)); + } #pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings #pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons - user.Username = newName; - await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); + user = await UserQuery(dbContext) + .AsTracking() + .FirstOrDefaultAsync(u => u.Id == userId) + .ConfigureAwait(false) + ?? throw new ResourceNotFoundException(nameof(userId)); + user.Username = newName; + await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); + } } var eventArgs = new UserUpdatedEventArgs(user); @@ -185,10 +220,9 @@ namespace Jellyfin.Server.Implementations.Users /// public async Task UpdateUserAsync(User user) { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) { - await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); + await UpdateUserInternalAsync(user).ConfigureAwait(false); } } @@ -218,23 +252,30 @@ namespace Jellyfin.Server.Implementations.Users { ThrowIfInvalidUsername(name); - if (Users.Any(u => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase))) - { - throw new ArgumentException(string.Format( - CultureInfo.InvariantCulture, - "A user with the name '{0}' already exists.", - 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 +#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture +#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings + if (await dbContext.Users + .AnyAsync(u => u.Username.ToUpper() == name.ToUpper()) + .ConfigureAwait(false)) + { + throw new ArgumentException(string.Format( + CultureInfo.InvariantCulture, + "A user with the name '{0}' already exists.", + name)); + } +#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings +#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture +#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); - _users.Add(newUser.Id, newUser); } await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false); @@ -245,62 +286,82 @@ namespace Jellyfin.Server.Implementations.Users /// public async Task DeleteUserAsync(Guid userId) { - if (!_users.TryGetValue(userId, out var user)) + User? user; + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { - throw new ResourceNotFoundException(nameof(userId)); - } + 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)); + } - if (_users.Count == 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)); - } + 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) - && Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 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)); - } + 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)); + } - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - dbContext.Users.Attach(user); - dbContext.Users.Remove(user); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + dbContext.Users.Remove(user); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } - _users.Remove(userId); - await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false); } /// - public Task ResetPassword(User user) + public Task ResetPassword(Guid userId) { - return ChangePassword(user, string.Empty); + return ChangePassword(userId, string.Empty); } /// - public async Task ChangePassword(User user, string newPassword) + public async Task ChangePassword(Guid userId, string newPassword) { - ArgumentNullException.ThrowIfNull(user); - if (user.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword)) + User dbUser = null!; + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { - throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword)); + 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 GetAuthenticationProvider(user).ChangePassword(user, newPassword).ConfigureAwait(false); - await UpdateUserAsync(user).ConfigureAwait(false); - - await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(user)).ConfigureAwait(false); + await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(dbUser)).ConfigureAwait(false); } /// @@ -400,102 +461,114 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentNullException(nameof(username)); } - var user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase)); - var authResult = await AuthenticateLocalUser(username, password, user) - .ConfigureAwait(false); - var authenticationProvider = authResult.AuthenticationProvider; - var success = authResult.Success; - - if (user is null) + bool success; + var user = GetUserByName(username); + using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false)) { - string updatedUsername = authResult.Username; - - if (success - && authenticationProvider is not null - && authenticationProvider is not DefaultAuthenticationProvider) + // 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) { - // Trust the username returned by the authentication provider - username = updatedUsername; + user = GetUserById(user.Id) ?? user; + } - // Search the database for the user again - // the authentication provider might have created it - user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase)); + var authResult = await AuthenticateLocalUser(username, password, user) + .ConfigureAwait(false); + var authenticationProvider = authResult.AuthenticationProvider; + success = authResult.Success; - if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null) + if (user is null) + { + string updatedUsername = authResult.Username; + + 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 + 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)) + if (success && user is not null && authenticationProvider is not null) { - user.AuthenticationProviderId = providerId; - await UpdateUserAsync(user).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) - { - user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow; + if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) + { + user.AuthenticationProviderId = providerId; + await UpdateUserInternalAsync(user).ConfigureAwait(false); + } } - user.InvalidLoginAttemptCount = 0; - await UpdateUserAsync(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); + 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; @@ -539,22 +612,22 @@ namespace Jellyfin.Server.Implementations.Users public async Task InitializeAsync() { // TODO: Refactor the startup wizard so that it doesn't require a user to already exist. - if (_users.Any()) - { - 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 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); @@ -562,7 +635,6 @@ namespace Jellyfin.Server.Implementations.Users dbContext.Users.Add(newUser); await dbContext.SaveChangesAsync().ConfigureAwait(false); - _users.Add(newUser.Id, newUser); } } @@ -599,124 +671,120 @@ namespace Jellyfin.Server.Implementations.Users /// public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config) { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { - var user = dbContext.Users - .Include(u => u.Permissions) - .Include(u => u.Preferences) - .Include(u => u.AccessSchedules) - .Include(u => u.ProfileImage) - .AsSingleQuery() - .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))) + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - user.CastReceiverId = config.CastReceiverId; + 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); } - - 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); - _users[user.Id] = user; - await dbContext.SaveChangesAsync().ConfigureAwait(false); } } /// public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy) { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + using (await _userLock.LockAsync(userId).ConfigureAwait(false)) { - var user = dbContext.Users - .Include(u => u.Permissions) - .Include(u => u.Preferences) - .Include(u => u.AccessSchedules) - .Include(u => u.ProfileImage) - .AsSingleQuery() - .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 + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - -1 => null, - 0 => 3, - _ => policy.LoginAttemptsBeforeLockout - }; + var user = UserQuery(dbContext) + .AsTracking() + .FirstOrDefault(u => u.Id.Equals(userId)) + ?? throw new ArgumentException("No user exists with given Id!"); - 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); + // 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.AccessSchedules.Clear(); - foreach (var policyAccessSchedule in policy.AccessSchedules) - { - user.AccessSchedules.Add(policyAccessSchedule); + 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); } - - // 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); - _users[user.Id] = user; - await dbContext.SaveChangesAsync().ConfigureAwait(false); } } @@ -728,15 +796,17 @@ namespace Jellyfin.Server.Implementations.Users return; } - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) { - dbContext.Remove(user.ProfileImage); - await dbContext.SaveChangesAsync().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; - _users[user.Id] = user; + user.ProfileImage = null; + } } internal static void ThrowIfInvalidUsername(string name) @@ -882,15 +952,42 @@ namespace Jellyfin.Server.Implementations.Users user.InvalidLoginAttemptCount); } - await UpdateUserAsync(user).ConfigureAwait(false); + 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; - _users[user.Id] = user; 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(); + } + } } } diff --git a/MediaBrowser.Controller/Library/IUserManager.cs b/MediaBrowser.Controller/Library/IUserManager.cs index 0109cf4b7d..e2b54ea7a7 100644 --- a/MediaBrowser.Controller/Library/IUserManager.cs +++ b/MediaBrowser.Controller/Library/IUserManager.cs @@ -24,14 +24,14 @@ namespace MediaBrowser.Controller.Library /// /// Gets the users. /// - /// The users. - IEnumerable Users { get; } + /// The users. + IEnumerable GetUsers(); /// /// Gets the user ids. /// - /// The users ids. - IEnumerable UsersIds { get; } + /// The users ids. + IEnumerable GetUsersIds(); /// /// Initializes the user manager and ensures that a user exists. @@ -47,6 +47,12 @@ namespace MediaBrowser.Controller.Library /// id is an empty Guid. User? GetUserById(Guid id); + /// + /// Gets the first available user. + /// + /// The first user, or null if no users exist. + User? GetFirstUser(); + /// /// Gets the name of the user by. /// @@ -57,12 +63,13 @@ namespace MediaBrowser.Controller.Library /// /// Renames the user. /// - /// The user. + /// The UserId to change. + /// The old Username. /// The new name. /// Task. /// If user is null. /// If the provided user doesn't exist. - Task RenameUser(User user, string newName); + Task RenameUser(Guid userId, string oldName, string newName); /// /// Updates the user. @@ -92,17 +99,17 @@ namespace MediaBrowser.Controller.Library /// /// Resets the password. /// - /// The user. + /// The users Id. /// Task. - Task ResetPassword(User user); + Task ResetPassword(Guid userId); /// /// Changes the password. /// - /// The user. + /// The users id. /// New password to use. /// Awaitable task. - Task ChangePassword(User user, string newPassword); + Task ChangePassword(Guid userId, string newPassword); /// /// Gets the user dto. diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 1d18ade9dc..2abc8a8c09 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -1204,7 +1204,7 @@ namespace Jellyfin.LiveTv { Services = services, IsEnabled = services.Length > 0, - EnabledUsers = _userManager.Users + EnabledUsers = _userManager.GetUsers() .Where(IsLiveTvEnabled) .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)) .ToArray() @@ -1220,7 +1220,7 @@ namespace Jellyfin.LiveTv public IEnumerable GetEnabledUsers() { - return _userManager.Users + return _userManager.GetUsers() .Where(IsLiveTvEnabled); } diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs index a5d186ce18..4b0f63b041 100644 --- a/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs +++ b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs @@ -79,7 +79,7 @@ namespace Jellyfin.LiveTv.Recordings private async Task SendMessage(SessionMessageType name, TimerEventInfo info) { - var users = _userManager.Users + var users = _userManager.GetUsers() .Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess)) .Select(i => i.Id) .ToList();