diff --git a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs index 5b36de1bfa72..7c693b51c0b1 100644 --- a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs +++ b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs @@ -17,6 +17,7 @@ public class SecuritySettings internal const bool StaticHideDisabledUsersInBackOffice = false; internal const bool StaticAllowPasswordReset = true; internal const bool StaticAllowEditInvariantFromNonDefault = false; + internal const bool StaticAllowConcurrentLogins = true; internal const string StaticAuthCookieName = "UMB_UCONTEXT"; internal const string StaticAllowedUserNameCharacters = @@ -109,4 +110,10 @@ public class SecuritySettings [Obsolete("Use ContentSettings.AllowEditFromInvariant instead")] [DefaultValue(StaticAllowEditInvariantFromNonDefault)] public bool AllowEditInvariantFromNonDefault { get; set; } = StaticAllowEditInvariantFromNonDefault; + + /// + /// Gets or sets a value indicating whether to allow concurrent logins. + /// + [DefaultValue(StaticAllowConcurrentLogins)] + public bool AllowConcurrentLogins { get; set; } = StaticAllowConcurrentLogins; } diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs index 81fe5b5aa08c..8af883135d31 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs @@ -36,8 +36,9 @@ public BackOfficeSignInManager( ILogger> logger, IAuthenticationSchemeProvider schemes, IUserConfirmation confirmation, - IEventAggregator eventAggregator) - : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) + IEventAggregator eventAggregator, + IOptions securitySettings) + : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation, securitySettings) { _userManager = userManager; _externalLogins = externalLogins; @@ -45,7 +46,34 @@ public BackOfficeSignInManager( _globalSettings = globalSettings.Value; } - [Obsolete("Use ctor with all params")] + [Obsolete("Use non-obsolete constructor. This is scheduled for removal in V14.")] + public BackOfficeSignInManager( + BackOfficeUserManager userManager, + IHttpContextAccessor contextAccessor, + IBackOfficeExternalLoginProviders externalLogins, + IUserClaimsPrincipalFactory claimsFactory, + IOptions optionsAccessor, + IOptions globalSettings, + ILogger> logger, + IAuthenticationSchemeProvider schemes, + IUserConfirmation confirmation, + IEventAggregator eventAggregator) + : this( + userManager, + contextAccessor, + externalLogins, + claimsFactory, + optionsAccessor, + globalSettings, + logger, + schemes, + confirmation, + eventAggregator, + StaticServiceProvider.Instance.GetRequiredService>()) + { + } + + [Obsolete("Use non-obsolete constructor. This is scheduled for removal in V14.")] public BackOfficeSignInManager( BackOfficeUserManager userManager, IHttpContextAccessor contextAccessor, @@ -56,7 +84,18 @@ public BackOfficeSignInManager( ILogger> logger, IAuthenticationSchemeProvider schemes, IUserConfirmation confirmation) - : this(userManager, contextAccessor, externalLogins, claimsFactory, optionsAccessor, globalSettings, logger, schemes, confirmation, StaticServiceProvider.Instance.GetRequiredService()) + : this( + userManager, + contextAccessor, + externalLogins, + claimsFactory, + optionsAccessor, + globalSettings, + logger, + schemes, + confirmation, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService>()) { } diff --git a/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeSecurityStampValidatorOptions.cs b/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeSecurityStampValidatorOptions.cs index ec5f0f669686..fcb180170311 100644 --- a/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeSecurityStampValidatorOptions.cs +++ b/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeSecurityStampValidatorOptions.cs @@ -1,14 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Web.Common.Security; namespace Umbraco.Cms.Web.BackOffice.Security; /// -/// Configures the back office security stamp options +/// Configures the back office security stamp options. /// -public class - ConfigureBackOfficeSecurityStampValidatorOptions : IConfigureOptions +public class ConfigureBackOfficeSecurityStampValidatorOptions : IConfigureOptions { + private readonly SecuritySettings _securitySettings; + + public ConfigureBackOfficeSecurityStampValidatorOptions() + : this(StaticServiceProvider.Instance.GetRequiredService>()) + { + } + + public ConfigureBackOfficeSecurityStampValidatorOptions(IOptions securitySettings) + => _securitySettings = securitySettings.Value; + + /// public void Configure(BackOfficeSecurityStampValidatorOptions options) - => ConfigureSecurityStampOptions.ConfigureOptions(options); + => ConfigureSecurityStampOptions.ConfigureOptions(options, _securitySettings); } diff --git a/src/Umbraco.Web.Common/Security/ConfigureSecurityStampOptions.cs b/src/Umbraco.Web.Common/Security/ConfigureSecurityStampOptions.cs index 5cebab43b978..71d644d489a2 100644 --- a/src/Umbraco.Web.Common/Security/ConfigureSecurityStampOptions.cs +++ b/src/Umbraco.Web.Common/Security/ConfigureSecurityStampOptions.cs @@ -1,20 +1,44 @@ using System.Security.Claims; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Web.Common.Security; public class ConfigureSecurityStampOptions : IConfigureOptions { + private readonly SecuritySettings _securitySettings; + + public ConfigureSecurityStampOptions() + : this(StaticServiceProvider.Instance.GetRequiredService>()) + { + } + + public ConfigureSecurityStampOptions(IOptions securitySettings) + => _securitySettings = securitySettings.Value; + + [Obsolete("Use the overload accepting SecuritySettings instead. Scheduled for removal in v14.")] + public static void ConfigureOptions(SecurityStampValidatorOptions options) + => ConfigureOptions(options, StaticServiceProvider.Instance.GetRequiredService()); + /// /// Configures security stamp options and ensures any custom claims /// set on the identity are persisted to the new identity when it's refreshed. /// - /// - public static void ConfigureOptions(SecurityStampValidatorOptions options) + /// Options for . + /// The options. + public static void ConfigureOptions(SecurityStampValidatorOptions options, SecuritySettings securitySettings) { - options.ValidationInterval = TimeSpan.FromMinutes(30); + // 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)) + { + options.ValidationInterval = TimeSpan.FromSeconds(30); + } // When refreshing the principal, ensure custom claims that // might have been set with an external identity continue @@ -34,6 +58,7 @@ public static void ConfigureOptions(SecurityStampValidatorOptions options) }; } + /// public void Configure(SecurityStampValidatorOptions options) - => ConfigureOptions(options); + => ConfigureOptions(options, _securitySettings); } diff --git a/src/Umbraco.Web.Common/Security/MemberSignInManager.cs b/src/Umbraco.Web.Common/Security/MemberSignInManager.cs index d402e5f8435a..9a8aaa72f499 100644 --- a/src/Umbraco.Web.Common/Security/MemberSignInManager.cs +++ b/src/Umbraco.Web.Common/Security/MemberSignInManager.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Notifications; @@ -30,14 +31,40 @@ public MemberSignInManager( IAuthenticationSchemeProvider schemes, IUserConfirmation confirmation, IMemberExternalLoginProviders memberExternalLoginProviders, - IEventAggregator eventAggregator) - : base(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) + IEventAggregator eventAggregator, + IOptions securitySettings) + : base(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation, securitySettings) { _memberExternalLoginProviders = memberExternalLoginProviders; _eventAggregator = eventAggregator; } - [Obsolete("Use ctor with all params")] + [Obsolete("Use non-obsolete constructor. This is scheduled for removal in V14.")] + public MemberSignInManager( + UserManager memberManager, + IHttpContextAccessor contextAccessor, + IUserClaimsPrincipalFactory claimsFactory, + IOptions optionsAccessor, + ILogger> logger, + IAuthenticationSchemeProvider schemes, + IUserConfirmation confirmation, + IMemberExternalLoginProviders memberExternalLoginProviders, + IEventAggregator eventAggregator) + : this( + memberManager, + contextAccessor, + claimsFactory, + optionsAccessor, + logger, + schemes, + confirmation, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService>()) + { + } + + [Obsolete("Use non-obsolete constructor. This is scheduled for removal in V14.")] public MemberSignInManager( UserManager memberManager, IHttpContextAccessor contextAccessor, diff --git a/src/Umbraco.Web.Common/Security/UmbracoSignInManager.cs b/src/Umbraco.Web.Common/Security/UmbracoSignInManager.cs index 6a167b39f7c4..84cbce6d8df6 100644 --- a/src/Umbraco.Web.Common/Security/UmbracoSignInManager.cs +++ b/src/Umbraco.Web.Common/Security/UmbracoSignInManager.cs @@ -2,26 +2,32 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Security; using Umbraco.Extensions; namespace Umbraco.Cms.Web.Common.Security; /// -/// Abstract sign in manager implementation allowing modifying all defeault authentication schemes +/// Abstract sign in manager implementation allowing modifying all default authentication schemes. /// /// public abstract class UmbracoSignInManager : SignInManager where TUser : UmbracoIdentityUser { + private SecuritySettings _securitySettings; + // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs protected const string UmbracoSignInMgrLoginProviderKey = "LoginProvider"; // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs protected const string UmbracoSignInMgrXsrfKey = "XsrfId"; + [Obsolete("Use non-obsolete constructor. This is scheduled for removal in V14.")] public UmbracoSignInManager( UserManager userManager, IHttpContextAccessor contextAccessor, @@ -30,8 +36,30 @@ public UmbracoSignInManager( ILogger> logger, IAuthenticationSchemeProvider schemes, IUserConfirmation confirmation) + : this( + userManager, + contextAccessor, + claimsFactory, + optionsAccessor, + logger, + schemes, + confirmation, + StaticServiceProvider.Instance.GetRequiredService>()) + { + } + + public UmbracoSignInManager( + UserManager userManager, + IHttpContextAccessor contextAccessor, + IUserClaimsPrincipalFactory claimsFactory, + IOptions optionsAccessor, + ILogger> logger, + IAuthenticationSchemeProvider schemes, + IUserConfirmation confirmation, + IOptions securitySettingsOptions) : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) { + _securitySettings = securitySettingsOptions.Value; } protected abstract string AuthenticationType { get; } @@ -47,7 +75,7 @@ public override async Task PasswordSignInAsync(TUser user, string { // override to handle logging/events SignInResult result = await base.PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure); - return await HandleSignIn(user, user.UserName, result); + return result; } /// @@ -340,6 +368,11 @@ protected virtual async Task HandleSignIn(TUser? user, string? use await UserManager.UpdateAsync(user); + if (_securitySettings.AllowConcurrentLogins is false) + { + await UserManager.UpdateSecurityStampAsync(user); + } + Logger.LogInformation("User: {UserName} logged in from IP address {IpAddress}", username, Context.Connection.RemoteIpAddress); } else if (result.IsLockedOut) diff --git a/templates/UmbracoProject/appsettings.json b/templates/UmbracoProject/appsettings.json index 03457ad0b742..667847895197 100644 --- a/templates/UmbracoProject/appsettings.json +++ b/templates/UmbracoProject/appsettings.json @@ -36,6 +36,9 @@ }, "Unattended": { "UpgradeUnattended": true + }, + "Security": { + "AllowConcurrentLogins": false } } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs index a17f748f0ca7..5394de5fd7ae 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs @@ -70,7 +70,8 @@ public MemberSignInManager CreateSut() Mock.Of(), Mock.Of>(), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of>(x => x.Value == new SecuritySettings())); } private static Mock MockMemberManager()