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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -22393,16 +22393,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "The resource is protected and requires an authentication token"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice User": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
|
||||
@@ -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
|
||||
|
||||
7
src/Umbraco.Core/Preview/IPreviewTokenGenerator.cs
Normal file
7
src/Umbraco.Core/Preview/IPreviewTokenGenerator.cs
Normal 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);
|
||||
}
|
||||
8
src/Umbraco.Core/Preview/NoopPreviewTokenGenerator.cs
Normal file
8
src/Umbraco.Core/Preview/NoopPreviewTokenGenerator.cs
Normal 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));
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Umbraco.Cms.Core.Security;
|
||||
|
||||
public interface ICoreBackOfficeSignInManager
|
||||
{
|
||||
Task<ClaimsPrincipal?> CreateUserPrincipalAsync(Guid userKey);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user