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.");
+ }
}
}
}