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