|
| 1 | +using System.Security.Claims; |
| 2 | +using Microsoft.AspNetCore.Authentication; |
| 3 | +using Microsoft.AspNetCore.Authentication.Cookies; |
| 4 | +using Microsoft.AspNetCore.DataProtection; |
| 5 | +using Microsoft.AspNetCore.Http; |
| 6 | +using Microsoft.Extensions.DependencyInjection; |
| 7 | +using Microsoft.Extensions.Options; |
| 8 | +using Umbraco.Cms.Api.Management.Security; |
| 9 | +using Umbraco.Cms.Core; |
| 10 | +using Umbraco.Cms.Core.Configuration.Models; |
| 11 | +using Umbraco.Cms.Core.Net; |
| 12 | +using Umbraco.Cms.Core.Services; |
| 13 | +using Umbraco.Cms.Web.Common.Security; |
| 14 | +using Umbraco.Extensions; |
| 15 | + |
| 16 | +namespace Umbraco.Cms.Api.Management.Configuration; |
| 17 | + |
| 18 | +/// <summary> |
| 19 | +/// Used to configure <see cref="CookieAuthenticationOptions" /> for the back office authentication type |
| 20 | +/// </summary> |
| 21 | +public class ConfigureBackOfficeCookieOptions : IConfigureNamedOptions<CookieAuthenticationOptions> |
| 22 | +{ |
| 23 | + private readonly IDataProtectionProvider _dataProtection; |
| 24 | + private readonly GlobalSettings _globalSettings; |
| 25 | + private readonly IIpResolver _ipResolver; |
| 26 | + private readonly IRuntimeState _runtimeState; |
| 27 | + private readonly SecuritySettings _securitySettings; |
| 28 | + private readonly IUserService _userService; |
| 29 | + private readonly TimeProvider _timeProvider; |
| 30 | + |
| 31 | + /// <summary> |
| 32 | + /// Initializes a new instance of the <see cref="ConfigureBackOfficeCookieOptions" /> class. |
| 33 | + /// </summary> |
| 34 | + /// <param name="securitySettings">The <see cref="SecuritySettings" /> options</param> |
| 35 | + /// <param name="globalSettings">The <see cref="GlobalSettings" /> options</param> |
| 36 | + /// <param name="runtimeState">The <see cref="IRuntimeState" /></param> |
| 37 | + /// <param name="dataProtection">The <see cref="IDataProtectionProvider" /></param> |
| 38 | + /// <param name="userService">The <see cref="IUserService" /></param> |
| 39 | + /// <param name="ipResolver">The <see cref="IIpResolver" /></param> |
| 40 | + /// <param name="timeProvider">The <see cref="TimeProvider" /></param> |
| 41 | + public ConfigureBackOfficeCookieOptions( |
| 42 | + IOptions<SecuritySettings> securitySettings, |
| 43 | + IOptions<GlobalSettings> globalSettings, |
| 44 | + IRuntimeState runtimeState, |
| 45 | + IDataProtectionProvider dataProtection, |
| 46 | + IUserService userService, |
| 47 | + IIpResolver ipResolver, |
| 48 | + TimeProvider timeProvider) |
| 49 | + { |
| 50 | + _securitySettings = securitySettings.Value; |
| 51 | + _globalSettings = globalSettings.Value; |
| 52 | + _runtimeState = runtimeState; |
| 53 | + _dataProtection = dataProtection; |
| 54 | + _userService = userService; |
| 55 | + _ipResolver = ipResolver; |
| 56 | + _timeProvider = timeProvider; |
| 57 | + } |
| 58 | + |
| 59 | + /// <inheritdoc /> |
| 60 | + public void Configure(string? name, CookieAuthenticationOptions options) |
| 61 | + { |
| 62 | + if (name != Constants.Security.BackOfficeAuthenticationType) |
| 63 | + { |
| 64 | + return; |
| 65 | + } |
| 66 | + |
| 67 | + Configure(options); |
| 68 | + } |
| 69 | + |
| 70 | + /// <inheritdoc /> |
| 71 | + public void Configure(CookieAuthenticationOptions options) |
| 72 | + { |
| 73 | + options.SlidingExpiration = false; |
| 74 | + options.ExpireTimeSpan = _globalSettings.TimeOut; |
| 75 | + options.Cookie.Domain = _securitySettings.AuthCookieDomain; |
| 76 | + options.Cookie.Name = _securitySettings.AuthCookieName; |
| 77 | + options.Cookie.HttpOnly = true; |
| 78 | + options.Cookie.SecurePolicy = |
| 79 | + _globalSettings.UseHttps ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest; |
| 80 | + options.Cookie.Path = "/"; |
| 81 | + |
| 82 | + // NOTE: matches route in BackOfficeLoginController |
| 83 | + const string backOfficeLoginPath = "/umbraco/login"; |
| 84 | + options.LoginPath = backOfficeLoginPath; |
| 85 | + options.LogoutPath = backOfficeLoginPath; |
| 86 | + options.AccessDeniedPath = backOfficeLoginPath; |
| 87 | + |
| 88 | + options.DataProtectionProvider = _dataProtection; |
| 89 | + |
| 90 | + // NOTE: This is borrowed directly from aspnetcore source |
| 91 | + // Note: the purpose for the data protector must remain fixed for interop to work. |
| 92 | + IDataProtector dataProtector = options.DataProtectionProvider.CreateProtector( |
| 93 | + "Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationMiddleware", |
| 94 | + Constants.Security.BackOfficeAuthenticationType, |
| 95 | + "v2"); |
| 96 | + var ticketDataFormat = new TicketDataFormat(dataProtector); |
| 97 | + |
| 98 | + options.TicketDataFormat = new BackOfficeSecureDataFormat(_globalSettings.TimeOut, ticketDataFormat); |
| 99 | + |
| 100 | + options.Events = new CookieAuthenticationEvents |
| 101 | + { |
| 102 | + // IMPORTANT! If you set any of OnRedirectToLogin, OnRedirectToAccessDenied, OnRedirectToLogout, OnRedirectToReturnUrl |
| 103 | + // you need to be aware that this will bypass the default behavior of returning the correct status codes for ajax requests and |
| 104 | + // not redirecting for non-ajax requests. This is because the default behavior is baked into this class here: |
| 105 | + // https://github.com/dotnet/aspnetcore/blob/master/src/Security/Authentication/Cookies/src/CookieAuthenticationEvents.cs#L58 |
| 106 | + // 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 |
| 107 | + // our back office requests will not function correctly. For now we don't need to set/configure any of these callbacks because |
| 108 | + // the defaults work fine with our setup. |
| 109 | + OnValidatePrincipal = async ctx => |
| 110 | + { |
| 111 | + // We need to resolve the BackOfficeSecurityStampValidator per request as a requirement (even in aspnetcore they do this) |
| 112 | + BackOfficeSecurityStampValidator securityStampValidator = |
| 113 | + ctx.HttpContext.RequestServices.GetRequiredService<BackOfficeSecurityStampValidator>(); |
| 114 | + |
| 115 | + // Same goes for the signinmanager |
| 116 | + IBackOfficeSignInManager signInManager = |
| 117 | + ctx.HttpContext.RequestServices.GetRequiredService<IBackOfficeSignInManager>(); |
| 118 | + |
| 119 | + ClaimsIdentity? backOfficeIdentity = ctx.Principal?.GetUmbracoIdentity(); |
| 120 | + if (backOfficeIdentity == null) |
| 121 | + { |
| 122 | + ctx.RejectPrincipal(); |
| 123 | + await signInManager.SignOutAsync(); |
| 124 | + } |
| 125 | + |
| 126 | + // ensure the thread culture is set |
| 127 | + backOfficeIdentity?.EnsureCulture(); |
| 128 | + |
| 129 | + EnsureTicketRenewalIfKeepUserLoggedIn(ctx); |
| 130 | + |
| 131 | + // add or update a claim to track when the cookie expires, we use this to track time remaining |
| 132 | + backOfficeIdentity?.AddOrUpdateClaim(new Claim( |
| 133 | + Constants.Security.TicketExpiresClaimType, |
| 134 | + ctx.Properties.ExpiresUtc!.Value.ToString("o"), |
| 135 | + ClaimValueTypes.DateTime, |
| 136 | + Constants.Security.BackOfficeAuthenticationType, |
| 137 | + Constants.Security.BackOfficeAuthenticationType, |
| 138 | + backOfficeIdentity)); |
| 139 | + |
| 140 | + await securityStampValidator.ValidateAsync(ctx); |
| 141 | + |
| 142 | + // We have to manually specify Issued and Expires, |
| 143 | + // because the SecurityStampValidator refreshes the principal every 30 minutes, |
| 144 | + // When the principal is refreshed the Issued is update to time of refresh, however, the Expires remains unchanged |
| 145 | + // When we then try and renew, the difference of issued and expires effectively becomes the new ExpireTimeSpan |
| 146 | + // meaning we effectively lose 30 minutes of our ExpireTimeSpan for EVERY principal refresh if we don't |
| 147 | + // https://github.com/dotnet/aspnetcore/blob/main/src/Security/Authentication/Cookies/src/CookieAuthenticationHandler.cs#L115 |
| 148 | + ctx.Properties.IssuedUtc = _timeProvider.GetUtcNow(); |
| 149 | + ctx.Properties.ExpiresUtc = _timeProvider.GetUtcNow().Add(_globalSettings.TimeOut); |
| 150 | + ctx.ShouldRenew = true; |
| 151 | + }, |
| 152 | + OnSigningIn = ctx => |
| 153 | + { |
| 154 | + // occurs when sign in is successful but before the ticket is written to the outbound cookie |
| 155 | + ClaimsIdentity? backOfficeIdentity = ctx.Principal?.GetUmbracoIdentity(); |
| 156 | + if (backOfficeIdentity != null) |
| 157 | + { |
| 158 | + // generate a session id and assign it |
| 159 | + // create a session token - if we are configured and not in an upgrade state then use the db, otherwise just generate one |
| 160 | + Guid session = _runtimeState.Level == RuntimeLevel.Run |
| 161 | + ? _userService.CreateLoginSession( |
| 162 | + backOfficeIdentity.GetId()!.Value, |
| 163 | + _ipResolver.GetCurrentRequestIpAddress()) |
| 164 | + : Guid.NewGuid(); |
| 165 | + |
| 166 | + // add our session claim |
| 167 | + backOfficeIdentity.AddClaim(new Claim( |
| 168 | + Constants.Security.SessionIdClaimType, |
| 169 | + session.ToString(), |
| 170 | + ClaimValueTypes.String, |
| 171 | + Constants.Security.BackOfficeAuthenticationType, |
| 172 | + Constants.Security.BackOfficeAuthenticationType, |
| 173 | + backOfficeIdentity)); |
| 174 | + |
| 175 | + // since it is a cookie-based authentication add that claim |
| 176 | + backOfficeIdentity.AddClaim(new Claim( |
| 177 | + ClaimTypes.CookiePath, |
| 178 | + "/", |
| 179 | + ClaimValueTypes.String, |
| 180 | + Constants.Security.BackOfficeAuthenticationType, |
| 181 | + Constants.Security.BackOfficeAuthenticationType, |
| 182 | + backOfficeIdentity)); |
| 183 | + } |
| 184 | + |
| 185 | + return Task.CompletedTask; |
| 186 | + }, |
| 187 | + OnSignedIn = ctx => |
| 188 | + { |
| 189 | + // occurs when sign in is successful and after the ticket is written to the outbound cookie |
| 190 | + |
| 191 | + // When we are signed in with the cookie, assign the principal to the current HttpContext |
| 192 | + ctx.HttpContext.SetPrincipalForRequest(ctx.Principal); |
| 193 | + |
| 194 | + return Task.CompletedTask; |
| 195 | + }, |
| 196 | + OnSigningOut = ctx => |
| 197 | + { |
| 198 | + // Clear the user's session on sign out |
| 199 | + if (ctx.HttpContext?.User?.Identity != null) |
| 200 | + { |
| 201 | + var claimsIdentity = ctx.HttpContext.User.Identity as ClaimsIdentity; |
| 202 | + var sessionId = claimsIdentity?.FindFirstValue(Constants.Security.SessionIdClaimType); |
| 203 | + if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out Guid guidSession)) |
| 204 | + { |
| 205 | + _userService.ClearLoginSession(guidSession); |
| 206 | + } |
| 207 | + } |
| 208 | + |
| 209 | + // Remove all of our cookies |
| 210 | + var cookies = new[] |
| 211 | + { |
| 212 | + _securitySettings.AuthCookieName, |
| 213 | + Constants.Web.PreviewCookieName, Constants.Security.BackOfficeExternalCookieName, |
| 214 | + Constants.Web.AngularCookieName, Constants.Web.CsrfValidationCookieName |
| 215 | + }; |
| 216 | + foreach (var cookie in cookies) |
| 217 | + { |
| 218 | + ctx.Options.CookieManager.DeleteCookie(ctx.HttpContext!, cookie, new CookieOptions { Path = "/" }); |
| 219 | + } |
| 220 | + |
| 221 | + return Task.CompletedTask; |
| 222 | + } |
| 223 | + }; |
| 224 | + } |
| 225 | + |
| 226 | + /// <summary> |
| 227 | + /// Ensures the ticket is renewed if the <see cref="SecuritySettings.KeepUserLoggedIn" /> is set to true |
| 228 | + /// and the current request is for the get user seconds endpoint |
| 229 | + /// </summary> |
| 230 | + /// <param name="context">The <see cref="CookieValidatePrincipalContext" /></param> |
| 231 | + private void EnsureTicketRenewalIfKeepUserLoggedIn(CookieValidatePrincipalContext context) |
| 232 | + { |
| 233 | + if (!_securitySettings.KeepUserLoggedIn) |
| 234 | + { |
| 235 | + return; |
| 236 | + } |
| 237 | + |
| 238 | + DateTimeOffset currentUtc = _timeProvider.GetUtcNow(); |
| 239 | + DateTimeOffset? issuedUtc = context.Properties.IssuedUtc; |
| 240 | + DateTimeOffset? expiresUtc = context.Properties.ExpiresUtc; |
| 241 | + |
| 242 | + if (expiresUtc.HasValue && issuedUtc.HasValue) |
| 243 | + { |
| 244 | + TimeSpan timeElapsed = currentUtc.Subtract(issuedUtc.Value); |
| 245 | + TimeSpan timeRemaining = expiresUtc.Value.Subtract(currentUtc); |
| 246 | + |
| 247 | + // if it's time to renew, then do it |
| 248 | + if (timeRemaining < timeElapsed) |
| 249 | + { |
| 250 | + context.ShouldRenew = true; |
| 251 | + } |
| 252 | + } |
| 253 | + } |
| 254 | +} |
0 commit comments