V14: Untangle the preview functionality from the auth cookie (#16308)

* AB40660 - untangle the preview cookie from the auth cookie

* Clean up

* Allow anonymous to end preview sessions

* Some refinements

* update OpenApi.json

* Fix enter preview test

* correct tests to match new expectations of the preview cookie

* sync preview tests with correct expectations of access level

---------

Co-authored-by: Sven Geusens <sge@umbraco.dk>
Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
This commit is contained in:
Bjarke Berg
2024-05-17 16:06:26 +02:00
committed by GitHub
parent 80794f3efd
commit 11e5257b56
17 changed files with 211 additions and 58 deletions

View File

@@ -1,4 +1,5 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core.Services;
@@ -15,9 +16,10 @@ public class EndPreviewController : PreviewControllerBase
[HttpDelete]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult End(CancellationToken cancellationToken)
[AllowAnonymous] // It's okay the client can do this from the website without having a token
public async Task<IActionResult> End(CancellationToken cancellationToken)
{
_previewService.EndPreview();
await _previewService.EndPreviewAsync();
return Ok();
}
}

View File

@@ -1,6 +1,7 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Controllers.Preview;
@@ -9,15 +10,27 @@ namespace Umbraco.Cms.Api.Management.Controllers.Preview;
public class EnterPreviewController : PreviewControllerBase
{
private readonly IPreviewService _previewService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
public EnterPreviewController(IPreviewService previewService) => _previewService = previewService;
public EnterPreviewController(IPreviewService previewService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_previewService = previewService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
[HttpPost]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult Enter(CancellationToken cancellationToken)
public async Task<IActionResult> Enter(CancellationToken cancellationToken)
{
_previewService.EnterPreview();
return Ok();
return await _previewService.TryEnterPreviewAsync(CurrentUser(_backOfficeSecurityAccessor))
? Ok()
: StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
{
Title = "Could not enter preview",
Detail = "Something unexpected went wrong trying to activate preview mode for the current user",
Status = StatusCodes.Status500InternalServerError,
Type = "Error",
});
}
}

View File

@@ -62,6 +62,7 @@ public static partial class UmbracoBuilderExtensions
// We also need to register the store as a core-friendly interface that doesn't leak technology.
services.AddScoped<IBackOfficeUserStore, BackOfficeUserStore>();
services.AddScoped<ICoreBackOfficeUserManager, BackOfficeUserManager>();
services.AddScoped<ICoreBackOfficeSignInManager, BackOfficeSignInManager>();
services.AddScoped<IInviteUriProvider, InviteUriProvider>();
services.AddScoped<IForgotPasswordUriProvider, ForgotPasswordUriProvider>();
services.AddScoped<IBackOfficePasswordChanger, BackOfficePasswordChanger>();

View File

@@ -22393,16 +22393,8 @@
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
}
},
"security": [
{
"Backoffice User": [ ]
}
]
}
},
"post": {
"tags": [

View File

@@ -35,6 +35,7 @@ using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.ContentTypeEditing;
using Umbraco.Cms.Core.DynamicRoot;
using Umbraco.Cms.Core.Preview;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services.FileSystem;
using Umbraco.Cms.Core.Services.ImportExport;
@@ -349,6 +350,7 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddSingleton<ConflictingPackageData>();
Services.AddSingleton<CompiledPackageXmlParser>();
Services.AddUnique<IPreviewTokenGenerator, NoopPreviewTokenGenerator>();
Services.AddUnique<IPreviewService, PreviewService>();
// Register a noop IHtmlSanitizer & IMarkdownSanitizer to be replaced

View File

@@ -0,0 +1,7 @@
namespace Umbraco.Cms.Core.Preview;
public interface IPreviewTokenGenerator
{
Task<Attempt<string?>> GenerateTokenAsync(Guid userKey);
Task<Attempt<Guid?>> VerifyAsync(string token);
}

View File

@@ -0,0 +1,8 @@
namespace Umbraco.Cms.Core.Preview;
public class NoopPreviewTokenGenerator : IPreviewTokenGenerator
{
public Task<Attempt<string?>> GenerateTokenAsync(Guid userKey) => Task.FromResult(Attempt.Fail(string.Empty));
public Task<Attempt<Guid?>> VerifyAsync(string token) => Task.FromResult(Attempt.Fail<Guid?>(null));
}

View File

@@ -0,0 +1,8 @@
using System.Security.Claims;
namespace Umbraco.Cms.Core.Security;
public interface ICoreBackOfficeSignInManager
{
Task<ClaimsPrincipal?> CreateUserPrincipalAsync(Guid userKey);
}

View File

@@ -1,14 +1,19 @@
namespace Umbraco.Cms.Core.Services;
using System.Security.Claims;
using Umbraco.Cms.Core.Models.Membership;
namespace Umbraco.Cms.Core.Services;
public interface IPreviewService
{
/// <summary>
/// Enters preview mode for a given user that calls this
/// </summary>
void EnterPreview();
Task<bool> TryEnterPreviewAsync(IUser user);
/// <summary>
/// Exits preview mode for a given user that calls this
/// </summary>
void EndPreview();
Task EndPreviewAsync();
Task<Attempt<ClaimsIdentity>> TryGetPreviewClaimsIdentityAsync();
}

View File

@@ -1,15 +1,68 @@
using Umbraco.Cms.Core.Web;
using System.Security.Claims;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Preview;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Web;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Services;
public class PreviewService : IPreviewService
{
private readonly ICookieManager _cookieManager;
private readonly IPreviewTokenGenerator _previewTokenGenerator;
private readonly IServiceScopeFactory _serviceScopeFactory;
public PreviewService(ICookieManager cookieManager) => _cookieManager = cookieManager;
public PreviewService(
ICookieManager cookieManager,
IPreviewTokenGenerator previewTokenGenerator,
IServiceScopeFactory serviceScopeFactory)
{
_cookieManager = cookieManager;
_previewTokenGenerator = previewTokenGenerator;
_serviceScopeFactory = serviceScopeFactory;
}
public void EnterPreview() => _cookieManager.SetCookieValue(Constants.Web.PreviewCookieName, "preview", true);
public async Task<bool> TryEnterPreviewAsync(IUser user)
{
Attempt<string?> attempt = await _previewTokenGenerator.GenerateTokenAsync(user.Key);
if (attempt.Success)
{
_cookieManager.SetCookieValue(Constants.Web.PreviewCookieName, attempt.Result!, true);
}
public void EndPreview() => _cookieManager.ExpireCookie(Constants.Web.PreviewCookieName);
return attempt.Success;
}
public Task EndPreviewAsync()
{
_cookieManager.ExpireCookie(Constants.Web.PreviewCookieName);
return Task.CompletedTask;
}
public async Task<Attempt<ClaimsIdentity>> TryGetPreviewClaimsIdentityAsync()
{
var cookieValue = _cookieManager.GetCookieValue(Constants.Web.PreviewCookieName);
if (string.IsNullOrWhiteSpace(cookieValue))
{
return Attempt<ClaimsIdentity>.Fail();
}
Attempt<Guid?> userKeyAttempt = await _previewTokenGenerator.VerifyAsync(cookieValue);
if (userKeyAttempt.Success is false)
{
return Attempt<ClaimsIdentity>.Fail();
}
IServiceScope serviceScope = _serviceScopeFactory.CreateScope();
ICoreBackOfficeSignInManager coreBackOfficeSignInManager = serviceScope.ServiceProvider.GetRequiredService<ICoreBackOfficeSignInManager>();
ClaimsPrincipal? principal = await coreBackOfficeSignInManager.CreateUserPrincipalAsync(userKeyAttempt.Result!.Value);
ClaimsIdentity? backOfficeIdentity = principal?.GetUmbracoIdentity();
return Attempt<ClaimsIdentity>.Succeed(backOfficeIdentity);
}
}

View File

@@ -27,6 +27,7 @@ using Umbraco.Cms.Core.Logging;
using Umbraco.Cms.Core.Net;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Preview;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Templates;
@@ -52,6 +53,7 @@ using Umbraco.Cms.Web.Common.Localization;
using Umbraco.Cms.Web.Common.Middleware;
using Umbraco.Cms.Web.Common.ModelBinders;
using Umbraco.Cms.Web.Common.Mvc;
using Umbraco.Cms.Web.Common.Preview;
using Umbraco.Cms.Web.Common.Profiler;
using Umbraco.Cms.Web.Common.Repositories;
using Umbraco.Cms.Web.Common.Security;
@@ -164,6 +166,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddUnique<IUmbracoApplicationLifetime, AspNetCoreUmbracoApplicationLifetime>();
builder.Services.AddUnique<IApplicationShutdownRegistry, AspNetCoreApplicationShutdownRegistry>();
builder.Services.AddTransient<IIpAddressUtilities, IpAddressUtilities>();
builder.Services.AddUnique<IPreviewTokenGenerator, UserBasedPreviewTokenGenerator>();
return builder;
}

View File

@@ -5,6 +5,12 @@ using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Preview;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.AspNetCore;
using Umbraco.Cms.Web.Common.Security;
using Umbraco.Extensions;
namespace Umbraco.Cms.Web.Common.Middleware;
@@ -16,8 +22,19 @@ namespace Umbraco.Cms.Web.Common.Middleware;
public class PreviewAuthenticationMiddleware : IMiddleware
{
private readonly ILogger<PreviewAuthenticationMiddleware> _logger;
private readonly IPreviewTokenGenerator _previewTokenGenerator;
private readonly IPreviewService _previewService;
public PreviewAuthenticationMiddleware(ILogger<PreviewAuthenticationMiddleware> logger) => _logger = logger;
public PreviewAuthenticationMiddleware(
ILogger<PreviewAuthenticationMiddleware> logger,
IPreviewTokenGenerator previewTokenGenerator,
IPreviewService previewService)
{
_logger = logger;
_previewTokenGenerator = previewTokenGenerator;
_previewService = previewService;
}
/// <inheritdoc />
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
@@ -38,37 +55,18 @@ public class PreviewAuthenticationMiddleware : IMiddleware
if (isPreview)
{
CookieAuthenticationOptions? cookieOptions = context.RequestServices
.GetRequiredService<IOptionsSnapshot<CookieAuthenticationOptions>>()
.Get(Core.Constants.Security.BackOfficeAuthenticationType);
Attempt<ClaimsIdentity> backOfficeIdentityAttempt = await _previewService.TryGetPreviewClaimsIdentityAsync();
if (cookieOptions == null)
if (backOfficeIdentityAttempt.Success)
{
throw new InvalidOperationException("No cookie options found with name " +
Core.Constants.Security.BackOfficeAuthenticationType);
// Ok, we've got a real ticket, now we can add this ticket's identity to the current
// Principal, this means we'll have 2 identities assigned to the principal which we can
// use to authorize the preview and allow for a back office User.
context.User.AddIdentity(backOfficeIdentityAttempt.Result!);
}
// If we've gotten this far it means a preview cookie has been set and a front-end umbraco document request is executing.
// In this case, authentication will not have occurred for an Umbraco back office User, however we need to perform the authentication
// for the user here so that the preview capability can be authorized otherwise only the non-preview page will be rendered.
if (cookieOptions.Cookie.Name != null)
else
{
var chunkingCookieManager = new ChunkingCookieManager();
var cookie = chunkingCookieManager.GetRequestCookie(context, cookieOptions.Cookie.Name);
if (!string.IsNullOrEmpty(cookie))
{
AuthenticationTicket? unprotected = cookieOptions.TicketDataFormat.Unprotect(cookie);
ClaimsIdentity? backOfficeIdentity = unprotected?.Principal.GetUmbracoIdentity();
if (backOfficeIdentity != null)
{
// Ok, we've got a real ticket, now we can add this ticket's identity to the current
// Principal, this means we'll have 2 identities assigned to the principal which we can
// use to authorize the preview and allow for a back office User.
context.User.AddIdentity(backOfficeIdentity);
}
}
_logger.LogDebug("Could not transform previewCookie value into a claimsIdentity");
}
}
}

View File

@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Preview;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Security;
namespace Umbraco.Cms.Web.Common.Preview;
public class UserBasedPreviewTokenGenerator : IPreviewTokenGenerator
{
private readonly IDataProtectionProvider _dataProtectionProvider;
private readonly IUserService _userService;
private readonly ILogger<UserBasedPreviewTokenGenerator> _logger;
public UserBasedPreviewTokenGenerator(
IDataProtectionProvider dataProtectionProvider,
IUserService userService,
ILogger<UserBasedPreviewTokenGenerator> logger)
{
_dataProtectionProvider = dataProtectionProvider;
_userService = userService;
_logger = logger;
}
public Task<Attempt<string?>> GenerateTokenAsync(Guid userKey)
{
var token = EncryptionHelper.Encrypt(userKey.ToString(), _dataProtectionProvider);
return Task.FromResult(Attempt.Succeed(token));
}
public async Task<Attempt<Guid?>> VerifyAsync(string token)
{
try
{
var decrypted = EncryptionHelper.Decrypt(token, _dataProtectionProvider);
if (Guid.TryParse(decrypted, out Guid key))
{
IUser? user = await _userService.GetAsync(key);
if (user is { IsApproved: true, IsLockedOut: false })
{
return Attempt.Succeed<Guid?>(user.Key);
}
}
}
catch (Exception e)
{
_logger.LogDebug(e, "An error occured when trying to get the user from the encrypted token");
}
return Attempt.Fail<Guid?>(null);
}
}

View File

@@ -9,7 +9,7 @@ namespace Umbraco.Cms.Web.Common.Security;
/// A <see cref="SignInManager{BackOfficeIdentityUser}" /> for the back office with a
/// <seealso cref="BackOfficeIdentityUser" />
/// </summary>
public interface IBackOfficeSignInManager
public interface IBackOfficeSignInManager : ICoreBackOfficeSignInManager
{
AuthenticationProperties ConfigureExternalAuthenticationProperties(string provider, string? redirectUrl, string? userId = null);

View File

@@ -85,6 +85,14 @@ public abstract class UmbracoSignInManager<TUser> : SignInManager<TUser>
return result;
}
public virtual async Task<ClaimsPrincipal?> CreateUserPrincipalAsync(Guid userKey)
{
TUser? user = await UserManager.FindByIdAsync(userKey.ToString());
return user is null
? null
: await this.ClaimsFactory.CreateAsync(user);
}
/// <inheritdoc />
public override async Task<ExternalLoginInfo?> GetExternalLoginInfoAsync(string? expectedXsrf = null)
{

View File

@@ -13,10 +13,8 @@ public class EndPreviewTests : ManagementApiTest<EndPreviewController>
[Test]
public virtual async Task As_Admin_I_Have_Access()
public virtual async Task As_Anonymous_I_Can_End_Preview_Mode()
{
await AuthenticateClientAsync(Client, "admin@umbraco.com", "1234567890", true);
var response = await Client.DeleteAsync(Url);
// Check if the set cookie header is sent

View File

@@ -13,18 +13,17 @@ public class EnterPreviewTests : ManagementApiTest<EnterPreviewController>
[Test]
public virtual async Task As_Admin_I_Have_Access()
public virtual async Task As_Editor_I_Can_Enter_Preview_Mode()
{
await AuthenticateClientAsync(Client, "admin@umbraco.com", "1234567890", true);
await AuthenticateClientAsync(Client, "admin@umbraco.com", "1234567890", false);
var response = await Client.PostAsync(Url, null);
// Check if the set cookie header is sent
var doesHeaderExist = response.Headers.TryGetValues("Set-Cookie", out var setCookieValues) &&
setCookieValues.Any(value => value.Contains($"{Constants.Web.PreviewCookieName}=preview; path=/"));
setCookieValues.Any(value => value.Contains($"{Constants.Web.PreviewCookieName}=") && value.Contains("path=/") && value.Contains("httponly"));
Assert.IsTrue(doesHeaderExist);
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, await response.Content.ReadAsStringAsync());
}
}