Files
Umbraco-CMS/src/Umbraco.Web.BackOffice/Security/ConfigureBackOfficeCookieOptions.cs

277 lines
14 KiB
C#

using System;
using System.Security.Claims;
using System.Threading.Tasks;
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.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.Net;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web;
using Umbraco.Extensions;
using ClaimsIdentityExtensions = Umbraco.Extensions.ClaimsIdentityExtensions;
using Constants = Umbraco.Cms.Core.Constants;
namespace Umbraco.Cms.Web.BackOffice.Security
{
/// <summary>
/// Used to configure <see cref="CookieAuthenticationOptions"/> for the back office authentication type
/// </summary>
public class ConfigureBackOfficeCookieOptions : IConfigureNamedOptions<CookieAuthenticationOptions>
{
private readonly IServiceProvider _serviceProvider;
private readonly IUmbracoContextAccessor _umbracoContextAccessor;
private readonly SecuritySettings _securitySettings;
private readonly GlobalSettings _globalSettings;
private readonly IHostingEnvironment _hostingEnvironment;
private readonly IRuntimeState _runtimeState;
private readonly IDataProtectionProvider _dataProtection;
private readonly IUserService _userService;
private readonly IIpResolver _ipResolver;
private readonly ISystemClock _systemClock;
private readonly UmbracoRequestPaths _umbracoRequestPaths;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigureBackOfficeCookieOptions"/> class.
/// </summary>
/// <param name="serviceProvider">The <see cref="IServiceProvider"/></param>
/// <param name="umbracoContextAccessor">The <see cref="IUmbracoContextAccessor"/></param>
/// <param name="securitySettings">The <see cref="SecuritySettings"/> options</param>
/// <param name="globalSettings">The <see cref="GlobalSettings"/> options</param>
/// <param name="hostingEnvironment">The <see cref="IHostingEnvironment"/></param>
/// <param name="runtimeState">The <see cref="IRuntimeState"/></param>
/// <param name="dataProtection">The <see cref="IDataProtectionProvider"/></param>
/// <param name="userService">The <see cref="IUserService"/></param>
/// <param name="ipResolver">The <see cref="IIpResolver"/></param>
/// <param name="systemClock">The <see cref="ISystemClock"/></param>
public ConfigureBackOfficeCookieOptions(
IServiceProvider serviceProvider,
IUmbracoContextAccessor umbracoContextAccessor,
IOptions<SecuritySettings> securitySettings,
IOptions<GlobalSettings> globalSettings,
IHostingEnvironment hostingEnvironment,
IRuntimeState runtimeState,
IDataProtectionProvider dataProtection,
IUserService userService,
IIpResolver ipResolver,
ISystemClock systemClock,
UmbracoRequestPaths umbracoRequestPaths)
{
_serviceProvider = serviceProvider;
_umbracoContextAccessor = umbracoContextAccessor;
_securitySettings = securitySettings.Value;
_globalSettings = globalSettings.Value;
_hostingEnvironment = hostingEnvironment;
_runtimeState = runtimeState;
_dataProtection = dataProtection;
_userService = userService;
_ipResolver = ipResolver;
_systemClock = systemClock;
_umbracoRequestPaths = umbracoRequestPaths;
}
/// <inheritdoc />
public void Configure(string name, CookieAuthenticationOptions options)
{
if (name != Constants.Security.BackOfficeAuthenticationType)
{
return;
}
Configure(options);
}
/// <inheritdoc />
public void Configure(CookieAuthenticationOptions options)
{
options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromMinutes(_globalSettings.TimeOutInMinutes);
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 = "/";
// For any redirections that may occur for the back office, they all go to the same path
var backOfficePath = _globalSettings.GetBackOfficePath(_hostingEnvironment);
options.AccessDeniedPath = backOfficePath;
options.LoginPath = backOfficePath;
options.LogoutPath = backOfficePath;
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.TimeOutInMinutes, ticketDataFormat);
// Custom cookie manager so we can filter requests
options.CookieManager = new BackOfficeCookieManager(
_umbracoContextAccessor,
_runtimeState,
_umbracoRequestPaths);
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<BackOfficeSecurityStampValidator>();
// Same goes for the signinmanager
IBackOfficeSignInManager signInManager = ctx.HttpContext.RequestServices.GetRequiredService<IBackOfficeSignInManager>();
ClaimsIdentity backOfficeIdentity = ctx.Principal.GetUmbracoIdentity();
if (backOfficeIdentity == null)
{
ctx.RejectPrincipal();
await signInManager.SignOutAsync();
}
// ensure the thread culture is set
backOfficeIdentity.EnsureCulture();
await EnsureValidSessionId(ctx);
await securityStampValidator.ValidateAsync(ctx);
EnsureTicketRenewalIfKeepUserLoggedIn(ctx);
// add a claim to track when the cookie expires, we use this to track time remaining
backOfficeIdentity.AddClaim(new Claim(
Constants.Security.TicketExpiresClaimType,
ctx.Properties.ExpiresUtc.Value.ToString("o"),
ClaimValueTypes.DateTime,
ClaimsIdentityExtensions.Issuer,
ClaimsIdentityExtensions.Issuer,
backOfficeIdentity));
},
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(), _ipResolver.GetCurrentRequestIpAddress())
: Guid.NewGuid();
// add our session claim
backOfficeIdentity.AddClaim(new Claim(Constants.Security.SessionIdClaimType, session.ToString(), ClaimValueTypes.String, ClaimsIdentityExtensions.Issuer, ClaimsIdentityExtensions.Issuer, backOfficeIdentity));
// since it is a cookie-based authentication add that claim
backOfficeIdentity.AddClaim(new Claim(ClaimTypes.CookiePath, "/", ClaimValueTypes.String, ClaimsIdentityExtensions.Issuer, ClaimsIdentityExtensions.Issuer, 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.User = 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[]
{
BackOfficeSessionIdValidator.CookieName,
_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;
}
};
}
/// <summary>
/// Ensures that the user has a valid session id
/// </summary>
/// <remarks>
/// So that we are not overloading the database this throttles it's check to every minute
/// </remarks>
private async Task EnsureValidSessionId(CookieValidatePrincipalContext context)
{
if (_runtimeState.Level != RuntimeLevel.Run)
{
return;
}
using IServiceScope scope = _serviceProvider.CreateScope();
BackOfficeSessionIdValidator validator = scope.ServiceProvider.GetRequiredService<BackOfficeSessionIdValidator>();
await validator.ValidateSessionAsync(TimeSpan.FromMinutes(1), context);
}
/// <summary>
/// Ensures the ticket is renewed if the <see cref="SecuritySettings.KeepUserLoggedIn"/> is set to true
/// and the current request is for the get user seconds endpoint
/// </summary>
/// <param name="context">The <see cref="CookieValidatePrincipalContext"/></param>
private void EnsureTicketRenewalIfKeepUserLoggedIn(CookieValidatePrincipalContext context)
{
if (!_securitySettings.KeepUserLoggedIn)
{
return;
}
DateTimeOffset currentUtc = _systemClock.UtcNow;
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;
}
}
}
}
}