diff --git a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs index ac0727889dc4..46fc9cf07339 100644 --- a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs +++ b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs @@ -28,8 +28,8 @@ public class SecuritySettings internal const int StaticMemberDefaultLockoutTimeInMinutes = 30 * 24 * 60; internal const int StaticUserDefaultLockoutTimeInMinutes = 30 * 24 * 60; - private const long StaticUserDefaultFailedLoginDurationInMilliseconds = 1000; - private const long StaticUserMinimumFailedLoginDurationInMilliseconds = 250; + internal const long StaticUserDefaultFailedLoginDurationInMilliseconds = 1000; + internal const long StaticUserMinimumFailedLoginDurationInMilliseconds = 250; /// /// Gets or sets a value indicating whether to keep the user logged in. diff --git a/src/Umbraco.Core/Extensions/IntExtensions.cs b/src/Umbraco.Core/Extensions/IntExtensions.cs index d347993dd07e..9b3c2c60ae6b 100644 --- a/src/Umbraco.Core/Extensions/IntExtensions.cs +++ b/src/Umbraco.Core/Extensions/IntExtensions.cs @@ -1,15 +1,17 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Diagnostics.CodeAnalysis; + namespace Umbraco.Extensions; public static class IntExtensions { /// - /// Does something 'x' amount of times + /// Does something 'x' amount of times. /// - /// - /// + /// Number of times to execute the action. + /// The action to execute. public static void Times(this int n, Action action) { for (var i = 0; i < n; i++) @@ -19,11 +21,11 @@ public static void Times(this int n, Action action) } /// - /// Creates a Guid based on an integer value + /// Creates a Guid based on an integer value. /// - /// value to convert + /// The value to convert. /// - /// + /// The converted . /// public static Guid ToGuid(this int value) { @@ -31,4 +33,28 @@ public static Guid ToGuid(this int value) BitConverter.GetBytes(value).CopyTo(bytes, 0); return new Guid(bytes); } + + /// + /// Restores a GUID previously created from an integer value using . + /// + /// The value to convert. + /// The converted . + /// + /// True if the value could be created, otherwise false. + /// + /// + /// This is used with Umbraco entities that only have integer references in the database (e.g. users). + /// + public static bool TryParseFromGuid(Guid value, [NotNullWhen(true)] out int? result) + { + if (value.ToString().EndsWith("-0000-0000-0000-000000000000") is false) + { + // We have a proper GUID, not one converted from an integer. + result = null; + return false; + } + + result = BitConverter.ToInt32(value.ToByteArray()); + return true; + } } diff --git a/src/Umbraco.Core/Persistence/Repositories/IExternalLoginWithKeyRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IExternalLoginWithKeyRepository.cs index ec9a79530cdb..0329ceb33bc0 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IExternalLoginWithKeyRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IExternalLoginWithKeyRepository.cs @@ -3,23 +3,29 @@ namespace Umbraco.Cms.Core.Persistence.Repositories; /// -/// Repository for external logins with Guid as key, so it can be shared for members and users +/// Repository for external logins with Guid as key, so it can be shared for members and users. /// public interface IExternalLoginWithKeyRepository : IReadWriteQueryRepository, IQueryRepository { /// - /// Replaces all external login providers for the user/member key + /// Replaces all external login providers for the user/member key. /// void Save(Guid userOrMemberKey, IEnumerable logins); /// - /// Replaces all external login provider tokens for the providers specified for the user/member key + /// Replaces all external login provider tokens for the providers specified for the user/member key. /// void Save(Guid userOrMemberKey, IEnumerable tokens); /// - /// Deletes all external logins for the specified the user/member key + /// Deletes all external logins for the specified the user/member key. /// void DeleteUserLogins(Guid userOrMemberKey); + + /// + /// Deletes external logins that aren't associated with the current collection of providers. + /// + /// The names of the currently configured providers. + void DeleteUserLoginsForRemovedProviders(IEnumerable currentLoginProviders) { } } diff --git a/src/Umbraco.Core/Persistence/Repositories/IUserRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IUserRepository.cs index 35458d6eba10..7c2a7e266458 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IUserRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IUserRepository.cs @@ -1,4 +1,4 @@ -using System.Linq.Expressions; +using System.Linq.Expressions; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Persistence.Querying; @@ -110,4 +110,10 @@ IEnumerable GetPagedResultsByQuery( void ClearLoginSession(Guid sessionId); IEnumerable GetNextUsers(int id, int count); + + /// + /// Invalidates sessions for users that aren't associated with the current collection of providers. + /// + /// The keys for the currently configured providers. + void InvalidateSessionsForRemovedProviders(IEnumerable currentProviderKeys) { } } diff --git a/src/Umbraco.Core/Services/ExternalLoginService.cs b/src/Umbraco.Core/Services/ExternalLoginService.cs index be49927b3689..762ec4cacb6a 100644 --- a/src/Umbraco.Core/Services/ExternalLoginService.cs +++ b/src/Umbraco.Core/Services/ExternalLoginService.cs @@ -80,4 +80,14 @@ public void DeleteUserLogins(Guid userOrMemberKey) scope.Complete(); } } + + /// + public void DeleteUserLoginsForRemovedProviders(IEnumerable currentLoginProviders) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + _externalLoginRepository.DeleteUserLoginsForRemovedProviders(currentLoginProviders); + scope.Complete(); + } + } } diff --git a/src/Umbraco.Core/Services/IExternalLoginWithKeyService.cs b/src/Umbraco.Core/Services/IExternalLoginWithKeyService.cs index 42f0708aaa73..8ce5bc3fa6b7 100644 --- a/src/Umbraco.Core/Services/IExternalLoginWithKeyService.cs +++ b/src/Umbraco.Core/Services/IExternalLoginWithKeyService.cs @@ -5,47 +5,53 @@ namespace Umbraco.Cms.Core.Services; public interface IExternalLoginWithKeyService : IService { /// - /// Returns all user logins assigned + /// Returns all user logins assigned. /// IEnumerable GetExternalLogins(Guid userOrMemberKey); /// - /// Returns all user login tokens assigned + /// Returns all user login tokens assigned. /// IEnumerable GetExternalLoginTokens(Guid userOrMemberKey); /// /// Returns all logins matching the login info - generally there should only be one but in some cases - /// there might be more than one depending on if an administrator has been editing/removing members + /// there might be more than one depending on if an administrator has been editing/removing members. /// IEnumerable Find(string loginProvider, string providerKey); /// - /// Saves the external logins associated with the user + /// Saves the external logins associated with the user. /// /// - /// The user or member key associated with the logins + /// The user or member key associated with the logins. /// /// /// - /// This will replace all external login provider information for the user + /// This will replace all external login provider information for the user. /// void Save(Guid userOrMemberKey, IEnumerable logins); /// - /// Saves the external login tokens associated with the user + /// Saves the external login tokens associated with the user. /// /// - /// The user or member key associated with the logins + /// The user or member key associated with the logins. /// /// /// - /// This will replace all external login tokens for the user + /// This will replace all external login tokens for the user. /// void Save(Guid userOrMemberKey, IEnumerable tokens); /// - /// Deletes all user logins - normally used when a member is deleted + /// Deletes all user logins - normally used when a member is deleted. /// void DeleteUserLogins(Guid userOrMemberKey); + + /// + /// Deletes external logins that aren't associated with the current collection of providers. + /// + /// The names of the currently configured providers. + void DeleteUserLoginsForRemovedProviders(IEnumerable currentLoginProviders) { } } diff --git a/src/Umbraco.Core/Services/IUserService.cs b/src/Umbraco.Core/Services/IUserService.cs index 40a3fbd89978..801a4b4e02cb 100644 --- a/src/Umbraco.Core/Services/IUserService.cs +++ b/src/Umbraco.Core/Services/IUserService.cs @@ -233,6 +233,12 @@ IEnumerable GetAll( IEnumerable GetNextUsers(int id, int count); + /// + /// Invalidates sessions for users that aren't associated with the current collection of providers. + /// + /// The keys for the currently configured providers. + void InvalidateSessionsForRemovedProviders(IEnumerable currentProviderKeys) { } + #region User groups /// diff --git a/src/Umbraco.Core/Services/UserService.cs b/src/Umbraco.Core/Services/UserService.cs index e0f65cdd5c93..d96824c89fd0 100644 --- a/src/Umbraco.Core/Services/UserService.cs +++ b/src/Umbraco.Core/Services/UserService.cs @@ -720,6 +720,16 @@ public IEnumerable GetNextUsers(int id, int count) } } + /// + public void InvalidateSessionsForRemovedProviders(IEnumerable currentProviderKeys) + { + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + _userRepository.InvalidateSessionsForRemovedProviders(currentProviderKeys); + scope.Complete(); + } + } + /// /// Gets a list of objects associated with a given group /// diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs index 352b1dd3fd47..f111cd5f0f78 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs @@ -56,6 +56,12 @@ public int Count(IQuery query) public void DeleteUserLogins(Guid userOrMemberKey) => Database.Delete("WHERE userOrMemberKey=@userOrMemberKey", new { userOrMemberKey }); + /// + public void DeleteUserLoginsForRemovedProviders(IEnumerable currentLoginProviders) => + Database.Execute(Sql() + .Delete() + .WhereNotIn(x => x.LoginProvider, currentLoginProviders)); + /// public void Save(Guid userOrMemberKey, IEnumerable logins) { diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs index eda072f04963..3fd40fec1c79 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs @@ -1070,5 +1070,45 @@ public IEnumerable GetNextUsers(int id, int count) : GetMany(ids).OrderBy(x => x.Id) ?? Enumerable.Empty(); } + /// + public void InvalidateSessionsForRemovedProviders(IEnumerable currentProviderKeys) + { + // Get all the user or member keys associated with the removed providers. + Sql idsQuery = SqlContext.Sql() + .Select(x => x.UserOrMemberKey) + .From() + .WhereNotIn(x => x.ProviderKey, currentProviderKeys); + List userAndMemberKeysAssociatedWithRemovedProviders = Database.Fetch(idsQuery); + if (userAndMemberKeysAssociatedWithRemovedProviders.Count == 0) + { + return; + } + + // Filter for actual users and convert to integer IDs. + var userIdsAssociatedWithRemovedProviders = userAndMemberKeysAssociatedWithRemovedProviders + .Select(ConvertUserKeyToUserId) + .Where(x => x.HasValue) + .Select(x => x!.Value) + .ToList(); + if (userIdsAssociatedWithRemovedProviders.Count == 0) + { + return; + } + + // Invalidate the security stamps on the users associated with the removed providers. + Sql updateQuery = Sql() + .Update(u => u.Set(x => x.SecurityStampToken, "0".PadLeft(32, '0'))) + .WhereIn(x => x.Id, userIdsAssociatedWithRemovedProviders); + Database.Execute(updateQuery); + } + + private static int? ConvertUserKeyToUserId(Guid userOrMemberKey) => + + // User Ids are stored as integers in the umbracoUser table, but as a GUID representation + // of that integer in umbracoExternalLogin (converted via IntExtensions.ToGuid()). + // We need to parse that to get the user Ids to invalidate. + // Note also that umbracoExternalLogin contains members too, as proper GUIDs, so we need to ignore them. + IntExtensions.TryParseFromGuid(userOrMemberKey, out int? userId) ? userId : null; + #endregion } diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs index fd3bfe71bc31..dfa707146d1e 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs @@ -7,6 +7,7 @@ using Umbraco.Cms.Core.Security; using Umbraco.Cms.Web.BackOffice.Authorization; using Umbraco.Cms.Web.BackOffice.Middleware; +using Umbraco.Cms.Web.BackOffice.NotificationHandlers; using Umbraco.Cms.Web.BackOffice.Security; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Cms.Web.Common.Security; @@ -65,6 +66,8 @@ public static IUmbracoBuilder AddBackOfficeAuthentication(this IUmbracoBuilder b builder.AddNotificationHandler(); builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + return builder; } diff --git a/src/Umbraco.Web.BackOffice/NotificationHandlers/ExternalLoginProviderStartupHandler.cs b/src/Umbraco.Web.BackOffice/NotificationHandlers/ExternalLoginProviderStartupHandler.cs new file mode 100644 index 000000000000..22013d2bbc0c --- /dev/null +++ b/src/Umbraco.Web.BackOffice/NotificationHandlers/ExternalLoginProviderStartupHandler.cs @@ -0,0 +1,40 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Web.BackOffice.Security; + +namespace Umbraco.Cms.Web.BackOffice.NotificationHandlers; + +/// +/// Invalidates backoffice sessions and clears external logins for removed providers if the external login +/// provider setup has changed. +/// +internal sealed class ExternalLoginProviderStartupHandler : INotificationHandler +{ + private readonly IBackOfficeExternalLoginProviders _backOfficeExternalLoginProviders; + private readonly IRuntimeState _runtimeState; + private readonly IServerRoleAccessor _serverRoleAccessor; + + public ExternalLoginProviderStartupHandler( + IBackOfficeExternalLoginProviders backOfficeExternalLoginProviders, + IRuntimeState runtimeState, + IServerRoleAccessor serverRoleAccessor) + { + _backOfficeExternalLoginProviders = backOfficeExternalLoginProviders; + _runtimeState = runtimeState; + _serverRoleAccessor = serverRoleAccessor; + } + + public void Handle(UmbracoApplicationStartingNotification notification) + { + if (_runtimeState.Level != RuntimeLevel.Run || + _serverRoleAccessor.CurrentServerRole == ServerRole.Subscriber) + { + return; + } + + _backOfficeExternalLoginProviders.InvalidateSessionsIfExternalLoginProvidersChanged(); + } +} diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviders.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviders.cs index 277fd06c6bb1..d0140133f595 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviders.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviders.cs @@ -1,4 +1,8 @@ using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Web.BackOffice.Security; @@ -7,13 +11,41 @@ public class BackOfficeExternalLoginProviders : IBackOfficeExternalLoginProvider { private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider; private readonly Dictionary _externalLogins; + private readonly IKeyValueService _keyValueService; + private readonly IExternalLoginWithKeyService _externalLoginWithKeyService; + private readonly IUserService _userService; + private readonly ILogger _logger; + private const string ExternalLoginProvidersKey = "Umbraco.Cms.Web.BackOffice.Security.BackOfficeExternalLoginProviders"; + + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")] public BackOfficeExternalLoginProviders( IEnumerable externalLogins, IAuthenticationSchemeProvider authenticationSchemeProvider) + : this( + externalLogins, + authenticationSchemeProvider, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService>()) + { + } + + public BackOfficeExternalLoginProviders( + IEnumerable externalLogins, + IAuthenticationSchemeProvider authenticationSchemeProvider, + IKeyValueService keyValueService, + IExternalLoginWithKeyService externalLoginWithKeyService, + IUserService userService, + ILogger logger) { _externalLogins = externalLogins.ToDictionary(x => x.AuthenticationType); _authenticationSchemeProvider = authenticationSchemeProvider; + _keyValueService = keyValueService; + _externalLoginWithKeyService = externalLoginWithKeyService; + _userService = userService; + _logger = logger; } /// @@ -66,4 +98,26 @@ public bool HasDenyLocalLogin() var found = _externalLogins.Values.Where(x => x.Options.DenyLocalLogin).ToList(); return found.Count > 0; } + + /// + public void InvalidateSessionsIfExternalLoginProvidersChanged() + { + var previousExternalLoginProvidersValue = _keyValueService.GetValue(ExternalLoginProvidersKey); + var currentExternalLoginProvidersValue = string.Join("|", _externalLogins.Keys.OrderBy(key => key)); + + if ((previousExternalLoginProvidersValue ?? string.Empty) != currentExternalLoginProvidersValue) + { + _logger.LogWarning( + "The configured external login providers have changed. Existing backoffice sessions using the removed providers will be invalidated and external login data removed."); + + _userService.InvalidateSessionsForRemovedProviders(_externalLogins.Keys); + _externalLoginWithKeyService.DeleteUserLoginsForRemovedProviders(_externalLogins.Keys); + + _keyValueService.SetValue(ExternalLoginProvidersKey, currentExternalLoginProvidersValue); + } + else if (previousExternalLoginProvidersValue is null) + { + _keyValueService.SetValue(ExternalLoginProvidersKey, string.Empty); + } + } } diff --git a/src/Umbraco.Web.BackOffice/Security/IBackOfficeExternalLoginProviders.cs b/src/Umbraco.Web.BackOffice/Security/IBackOfficeExternalLoginProviders.cs index 6d0a699f9a54..1b1f86186edd 100644 --- a/src/Umbraco.Web.BackOffice/Security/IBackOfficeExternalLoginProviders.cs +++ b/src/Umbraco.Web.BackOffice/Security/IBackOfficeExternalLoginProviders.cs @@ -30,4 +30,11 @@ public interface IBackOfficeExternalLoginProviders /// /// bool HasDenyLocalLogin(); + + /// + /// Used during startup to see if the configured external login providers is different from the persisted information. + /// If they are different, this will invalidate backoffice sessions and clear external logins for removed providers + /// if the external login provider setup has changed. + /// + void InvalidateSessionsIfExternalLoginProvidersChanged() { } } diff --git a/src/Umbraco.Web.Common/Security/ConfigureSecurityStampOptions.cs b/src/Umbraco.Web.Common/Security/ConfigureSecurityStampOptions.cs index 71d644d489a2..0cf4bd2a1fac 100644 --- a/src/Umbraco.Web.Common/Security/ConfigureSecurityStampOptions.cs +++ b/src/Umbraco.Web.Common/Security/ConfigureSecurityStampOptions.cs @@ -35,7 +35,7 @@ public static void ConfigureOptions(SecurityStampValidatorOptions options, Secur // Adjust the security stamp validation interval to a shorter duration // when concurrent logins are not allowed and the duration has the default interval value // (currently defaults to 30 minutes), ensuring quicker re-validation. - if (securitySettings.AllowConcurrentLogins is false && options.ValidationInterval == TimeSpan.FromMinutes(30)) + if (securitySettings.AllowConcurrentLogins is false && options.ValidationInterval == new SecurityStampValidatorOptions().ValidationInterval) { options.ValidationInterval = TimeSpan.FromSeconds(30); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/IntExtensionsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/IntExtensionsTests.cs new file mode 100644 index 000000000000..46cab0df546a --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/IntExtensionsTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using NUnit.Framework; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Extensions; + +[TestFixture] +public class IntExtensionsTests +{ + [TestCase(20, "00000014-0000-0000-0000-000000000000")] + [TestCase(106, "0000006a-0000-0000-0000-000000000000")] + [TestCase(999999, "000f423f-0000-0000-0000-000000000000")] + [TestCase(555555555, "211d1ae3-0000-0000-0000-000000000000")] + public void ToGuid_Creates_Expected_Guid(int input, string expected) + { + var result = input.ToGuid(); + Assert.AreEqual(expected, result.ToString()); + } + + [TestCase("00000014-0000-0000-0000-000000000000", 20)] + [TestCase("0000006a-0000-0000-0000-000000000000", 106)] + [TestCase("000f423f-0000-0000-0000-000000000000", 999999)] + [TestCase("211d1ae3-0000-0000-0000-000000000000", 555555555)] + [TestCase("0d93047e-558d-4311-8a9d-b89e6fca0337", null)] + public void TryParseFromGuid_Parses_Expected_Integer(string input, int? expected) + { + var result = IntExtensions.TryParseFromGuid(Guid.Parse(input), out int? intValue); + if (expected is null) + { + Assert.IsFalse(result); + Assert.IsFalse(intValue.HasValue); + } + else + { + Assert.IsTrue(result); + Assert.AreEqual(expected, intValue.Value); + } + } +}