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()