diff --git a/src/Umbraco.Cms.Api.Management/Configuration/ConfigureBackOfficeCookieOptions.cs b/src/Umbraco.Cms.Api.Management/Configuration/ConfigureBackOfficeCookieOptions.cs
new file mode 100644
index 000000000000..f5af3a62b795
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Configuration/ConfigureBackOfficeCookieOptions.cs
@@ -0,0 +1,254 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Umbraco.Cms.Api.Management.Security;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.Configuration.Models;
+using Umbraco.Cms.Core.Net;
+using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Web.Common.Security;
+using Umbraco.Extensions;
+
+namespace Umbraco.Cms.Api.Management.Configuration;
+
+///
+/// Used to configure for the back office authentication type
+///
+public class ConfigureBackOfficeCookieOptions : IConfigureNamedOptions
+{
+ private readonly IDataProtectionProvider _dataProtection;
+ private readonly GlobalSettings _globalSettings;
+ private readonly IIpResolver _ipResolver;
+ private readonly IRuntimeState _runtimeState;
+ private readonly SecuritySettings _securitySettings;
+ private readonly IUserService _userService;
+ private readonly TimeProvider _timeProvider;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The options
+ /// The options
+ /// The
+ /// The
+ /// The
+ /// The
+ /// The
+ public ConfigureBackOfficeCookieOptions(
+ IOptions securitySettings,
+ IOptions globalSettings,
+ IRuntimeState runtimeState,
+ IDataProtectionProvider dataProtection,
+ IUserService userService,
+ IIpResolver ipResolver,
+ TimeProvider timeProvider)
+ {
+ _securitySettings = securitySettings.Value;
+ _globalSettings = globalSettings.Value;
+ _runtimeState = runtimeState;
+ _dataProtection = dataProtection;
+ _userService = userService;
+ _ipResolver = ipResolver;
+ _timeProvider = timeProvider;
+ }
+
+ ///
+ public void Configure(string? name, CookieAuthenticationOptions options)
+ {
+ if (name != Constants.Security.BackOfficeAuthenticationType)
+ {
+ return;
+ }
+
+ Configure(options);
+ }
+
+ ///
+ public void Configure(CookieAuthenticationOptions options)
+ {
+ options.SlidingExpiration = false;
+ options.ExpireTimeSpan = _globalSettings.TimeOut;
+ options.Cookie.Domain = _securitySettings.AuthCookieDomain;
+ options.Cookie.Name = _securitySettings.AuthCookieName;
+ options.Cookie.HttpOnly = true;
+ options.Cookie.SecurePolicy =
+ _globalSettings.UseHttps ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
+ options.Cookie.Path = "/";
+
+ // NOTE: matches route in BackOfficeLoginController
+ const string backOfficeLoginPath = "/umbraco/login";
+ options.LoginPath = backOfficeLoginPath;
+ options.LogoutPath = backOfficeLoginPath;
+ options.AccessDeniedPath = backOfficeLoginPath;
+
+ options.DataProtectionProvider = _dataProtection;
+
+ // NOTE: This is borrowed directly from aspnetcore source
+ // Note: the purpose for the data protector must remain fixed for interop to work.
+ IDataProtector dataProtector = options.DataProtectionProvider.CreateProtector(
+ "Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationMiddleware",
+ Constants.Security.BackOfficeAuthenticationType,
+ "v2");
+ var ticketDataFormat = new TicketDataFormat(dataProtector);
+
+ options.TicketDataFormat = new BackOfficeSecureDataFormat(_globalSettings.TimeOut, ticketDataFormat);
+
+ options.Events = new CookieAuthenticationEvents
+ {
+ // IMPORTANT! If you set any of OnRedirectToLogin, OnRedirectToAccessDenied, OnRedirectToLogout, OnRedirectToReturnUrl
+ // you need to be aware that this will bypass the default behavior of returning the correct status codes for ajax requests and
+ // not redirecting for non-ajax requests. This is because the default behavior is baked into this class here:
+ // https://github.com/dotnet/aspnetcore/blob/master/src/Security/Authentication/Cookies/src/CookieAuthenticationEvents.cs#L58
+ // It would be possible to re-use the default behavior if any of these need to be set but that must be taken into account else
+ // our back office requests will not function correctly. For now we don't need to set/configure any of these callbacks because
+ // the defaults work fine with our setup.
+ OnValidatePrincipal = async ctx =>
+ {
+ // We need to resolve the BackOfficeSecurityStampValidator per request as a requirement (even in aspnetcore they do this)
+ BackOfficeSecurityStampValidator securityStampValidator =
+ ctx.HttpContext.RequestServices.GetRequiredService();
+
+ // Same goes for the signinmanager
+ IBackOfficeSignInManager signInManager =
+ ctx.HttpContext.RequestServices.GetRequiredService();
+
+ ClaimsIdentity? backOfficeIdentity = ctx.Principal?.GetUmbracoIdentity();
+ if (backOfficeIdentity == null)
+ {
+ ctx.RejectPrincipal();
+ await signInManager.SignOutAsync();
+ }
+
+ // ensure the thread culture is set
+ backOfficeIdentity?.EnsureCulture();
+
+ EnsureTicketRenewalIfKeepUserLoggedIn(ctx);
+
+ // add or update a claim to track when the cookie expires, we use this to track time remaining
+ backOfficeIdentity?.AddOrUpdateClaim(new Claim(
+ Constants.Security.TicketExpiresClaimType,
+ ctx.Properties.ExpiresUtc!.Value.ToString("o"),
+ ClaimValueTypes.DateTime,
+ Constants.Security.BackOfficeAuthenticationType,
+ Constants.Security.BackOfficeAuthenticationType,
+ backOfficeIdentity));
+
+ await securityStampValidator.ValidateAsync(ctx);
+
+ // We have to manually specify Issued and Expires,
+ // because the SecurityStampValidator refreshes the principal every 30 minutes,
+ // When the principal is refreshed the Issued is update to time of refresh, however, the Expires remains unchanged
+ // When we then try and renew, the difference of issued and expires effectively becomes the new ExpireTimeSpan
+ // meaning we effectively lose 30 minutes of our ExpireTimeSpan for EVERY principal refresh if we don't
+ // https://github.com/dotnet/aspnetcore/blob/main/src/Security/Authentication/Cookies/src/CookieAuthenticationHandler.cs#L115
+ ctx.Properties.IssuedUtc = _timeProvider.GetUtcNow();
+ ctx.Properties.ExpiresUtc = _timeProvider.GetUtcNow().Add(_globalSettings.TimeOut);
+ ctx.ShouldRenew = true;
+ },
+ OnSigningIn = ctx =>
+ {
+ // occurs when sign in is successful but before the ticket is written to the outbound cookie
+ ClaimsIdentity? backOfficeIdentity = ctx.Principal?.GetUmbracoIdentity();
+ if (backOfficeIdentity != null)
+ {
+ // generate a session id and assign it
+ // create a session token - if we are configured and not in an upgrade state then use the db, otherwise just generate one
+ Guid session = _runtimeState.Level == RuntimeLevel.Run
+ ? _userService.CreateLoginSession(
+ backOfficeIdentity.GetId()!.Value,
+ _ipResolver.GetCurrentRequestIpAddress())
+ : Guid.NewGuid();
+
+ // add our session claim
+ backOfficeIdentity.AddClaim(new Claim(
+ Constants.Security.SessionIdClaimType,
+ session.ToString(),
+ ClaimValueTypes.String,
+ Constants.Security.BackOfficeAuthenticationType,
+ Constants.Security.BackOfficeAuthenticationType,
+ backOfficeIdentity));
+
+ // since it is a cookie-based authentication add that claim
+ backOfficeIdentity.AddClaim(new Claim(
+ ClaimTypes.CookiePath,
+ "/",
+ ClaimValueTypes.String,
+ Constants.Security.BackOfficeAuthenticationType,
+ Constants.Security.BackOfficeAuthenticationType,
+ backOfficeIdentity));
+ }
+
+ return Task.CompletedTask;
+ },
+ OnSignedIn = ctx =>
+ {
+ // occurs when sign in is successful and after the ticket is written to the outbound cookie
+
+ // When we are signed in with the cookie, assign the principal to the current HttpContext
+ ctx.HttpContext.SetPrincipalForRequest(ctx.Principal);
+
+ return Task.CompletedTask;
+ },
+ OnSigningOut = ctx =>
+ {
+ // Clear the user's session on sign out
+ if (ctx.HttpContext?.User?.Identity != null)
+ {
+ var claimsIdentity = ctx.HttpContext.User.Identity as ClaimsIdentity;
+ var sessionId = claimsIdentity?.FindFirstValue(Constants.Security.SessionIdClaimType);
+ if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out Guid guidSession))
+ {
+ _userService.ClearLoginSession(guidSession);
+ }
+ }
+
+ // Remove all of our cookies
+ var cookies = new[]
+ {
+ _securitySettings.AuthCookieName,
+ Constants.Web.PreviewCookieName, Constants.Security.BackOfficeExternalCookieName,
+ Constants.Web.AngularCookieName, Constants.Web.CsrfValidationCookieName
+ };
+ foreach (var cookie in cookies)
+ {
+ ctx.Options.CookieManager.DeleteCookie(ctx.HttpContext!, cookie, new CookieOptions { Path = "/" });
+ }
+
+ return Task.CompletedTask;
+ }
+ };
+ }
+
+ ///
+ /// Ensures the ticket is renewed if the is set to true
+ /// and the current request is for the get user seconds endpoint
+ ///
+ /// The
+ private void EnsureTicketRenewalIfKeepUserLoggedIn(CookieValidatePrincipalContext context)
+ {
+ if (!_securitySettings.KeepUserLoggedIn)
+ {
+ return;
+ }
+
+ DateTimeOffset currentUtc = _timeProvider.GetUtcNow();
+ DateTimeOffset? issuedUtc = context.Properties.IssuedUtc;
+ DateTimeOffset? expiresUtc = context.Properties.ExpiresUtc;
+
+ if (expiresUtc.HasValue && issuedUtc.HasValue)
+ {
+ TimeSpan timeElapsed = currentUtc.Subtract(issuedUtc.Value);
+ TimeSpan timeRemaining = expiresUtc.Value.Subtract(currentUtc);
+
+ // if it's time to renew, then do it
+ if (timeRemaining < timeElapsed)
+ {
+ context.ShouldRenew = true;
+ }
+ }
+ }
+}
diff --git a/src/Umbraco.Cms.Api.Management/Configuration/ConfigureBackOfficeSecurityStampValidatorOptions.cs b/src/Umbraco.Cms.Api.Management/Configuration/ConfigureBackOfficeSecurityStampValidatorOptions.cs
new file mode 100644
index 000000000000..7b299ec226b8
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Configuration/ConfigureBackOfficeSecurityStampValidatorOptions.cs
@@ -0,0 +1,28 @@
+using Microsoft.Extensions.Options;
+using Umbraco.Cms.Api.Management.Security;
+using Umbraco.Cms.Core.Configuration.Models;
+using Umbraco.Cms.Web.Common.Security;
+
+namespace Umbraco.Cms.Api.Management.Configuration;
+
+///
+/// Configures the back office security stamp options.
+///
+public class ConfigureBackOfficeSecurityStampValidatorOptions : IConfigureOptions
+{
+ private readonly SecuritySettings _securitySettings;
+ private readonly TimeProvider _timeProvider;
+
+ public ConfigureBackOfficeSecurityStampValidatorOptions(IOptions securitySettings, TimeProvider timeProvider)
+ {
+ _timeProvider = timeProvider;
+ _securitySettings = securitySettings.Value;
+ }
+
+ ///
+ public void Configure(BackOfficeSecurityStampValidatorOptions options)
+ {
+ options.TimeProvider = _timeProvider;
+ ConfigureSecurityStampOptions.ConfigureOptions(options, _securitySettings);
+ }
+}
diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs
index 0c303d2dc203..1821aad479fb 100644
--- a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs
+++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Common.DependencyInjection;
+using Umbraco.Cms.Api.Management.Configuration;
using Umbraco.Cms.Api.Management.Handlers;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
@@ -52,11 +53,7 @@ private static IUmbracoBuilder AddBackOfficeLogin(this IUmbracoBuilder builder)
builder.Services
.AddAuthentication()
// Add our custom schemes which are cookie handlers
- .AddCookie(Constants.Security.BackOfficeAuthenticationType, options =>
- {
- options.LoginPath = "/umbraco/login";
- options.Cookie.Name = Constants.Security.BackOfficeAuthenticationType;
- })
+ .AddCookie(Constants.Security.BackOfficeAuthenticationType)
.AddCookie(Constants.Security.BackOfficeExternalAuthenticationType, o =>
{
o.Cookie.Name = Constants.Security.BackOfficeExternalAuthenticationType;
@@ -76,6 +73,10 @@ private static IUmbracoBuilder AddBackOfficeLogin(this IUmbracoBuilder builder)
o.ExpireTimeSpan = TimeSpan.FromMinutes(5);
});
+ builder.Services.AddScoped();
+ builder.Services.ConfigureOptions();
+ builder.Services.ConfigureOptions();
+
return builder;
}
}
diff --git a/src/Umbraco.Cms.Api.Management/Security/BackOfficeSecurityStampValidator.cs b/src/Umbraco.Cms.Api.Management/Security/BackOfficeSecurityStampValidator.cs
new file mode 100644
index 000000000000..c496ef4451a5
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Security/BackOfficeSecurityStampValidator.cs
@@ -0,0 +1,20 @@
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Umbraco.Cms.Core.Security;
+
+namespace Umbraco.Cms.Api.Management.Security;
+
+///
+/// A security stamp validator for the back office
+///
+public class BackOfficeSecurityStampValidator : SecurityStampValidator
+{
+ public BackOfficeSecurityStampValidator(
+ IOptions options,
+ BackOfficeSignInManager signInManager,
+ ILoggerFactory logger)
+ : base(options, signInManager, logger)
+ {
+ }
+}
diff --git a/src/Umbraco.Cms.Api.Management/Security/BackOfficeSecurityStampValidatorOptions.cs b/src/Umbraco.Cms.Api.Management/Security/BackOfficeSecurityStampValidatorOptions.cs
new file mode 100644
index 000000000000..f61da51ca622
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Security/BackOfficeSecurityStampValidatorOptions.cs
@@ -0,0 +1,10 @@
+using Microsoft.AspNetCore.Identity;
+
+namespace Umbraco.Cms.Api.Management.Security;
+
+///
+/// Custom for the back office
+///
+public class BackOfficeSecurityStampValidatorOptions : SecurityStampValidatorOptions
+{
+}