From 83321a8fadc19f4a62dfd688577fb115625c3ac4 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Tue, 26 Sep 2023 09:22:45 +0200 Subject: [PATCH] Add member auth to the Delivery API (#14730) * Refactor OpenIddict for shared usage between APIs + implement member authentication and handling within the Delivery API * Make SwaggerRouteTemplatePipelineFilter UI config overridable * Enable token revocation + rename logout endpoint to signout * Add default implementation of SwaggerGenOptions configuration for enabling Delivery API member auth in Swagger * Correct notification handling when (un)protecting content * Fixing integration test framework * Cleanup test to not execute some composers twice * Update paths to match docs * Return Forbidden when a member is authorized but not allowed to access the requested resource * Cleanup * Rename RequestMemberService to RequestMemberAccessService * Rename badly named variable * Review comments * Hide the auth controller from Swagger * Remove semaphore * Add security requirements for content API operations in Swagger * Hide the back-office auth endpoints from Swagger * Fix merge * Update back-office API auth endpoint paths + add revoke and sign-out endpoints (as of now they do not exist, a separate task will fix that) * Swap endpoint order to maintain backwards compat with the current login screen for new back-office (will be swapped back again to ensure correct .well-known endpoints, see FIXME comment) * Make "items by IDs" endpoint support member auth * Add 401 and 403 to "items by IDs" endpoint responses --------- Co-authored-by: Bjarke Berg Co-authored-by: Elitsa --- .../UmbracoBuilderAuthExtensions.cs | 103 +++++++++++ .../SwaggerRouteTemplatePipelineFilter.cs | 32 ++-- src/Umbraco.Cms.Api.Common/Security/Paths.cs | 35 ++++ ...henticationDeliveryApiSwaggerGenOptions.cs | 74 ++++++++ .../Content/ByIdContentApiController.cs | 39 +++- .../Content/ByIdsContentApiController.cs | 44 ++++- .../Content/ByRouteContentApiController.cs | 50 ++++- .../Content/ContentApiControllerBase.cs | 13 +- .../Content/ContentApiItemControllerBase.cs | 48 ++++- .../Content/QueryContentApiController.cs | 28 ++- .../Controllers/Security/MemberController.cs | 171 ++++++++++++++++++ .../UmbracoBuilderExtensions.cs | 13 ++ .../Filters/SwaggerDocumentationFilterBase.cs | 14 +- .../Filters/SwaggerFilterBase.cs | 19 ++ ...izeMemberApplicationNotificationHandler.cs | 79 ++++++++ ...AuthenticationTokensNotificationHandler.cs | 102 +++++++++++ .../Security/MemberApplicationManager.cs | 67 +++++++ .../Services/ApiContentQueryProvider.cs | 79 +++++++- .../Services/ApiContentQueryService.cs | 20 +- .../Services/RequestMemberAccessService.cs | 84 +++++++++ ...reUmbracoManagementApiSwaggerGenOptions.cs | 5 +- .../Security/BackOfficeController.cs | 9 +- .../Controllers/Security/Paths.cs | 12 -- .../BackOfficeAuthBuilderExtensions.cs | 71 +------- src/Umbraco.Cms.Api.Management/OpenApi.json | 75 +------- .../Security/BackOfficeApplicationManager.cs | 43 +---- .../Models/DeliveryApiSettings.cs | 54 ++++++ src/Umbraco.Core/Constants-OAuthClaims.cs | 17 ++ ...ientIds.cs => Constants-OAuthClientIds.cs} | 14 +- .../DeliveryApi/IApiContentQueryProvider.cs | 22 ++- .../DeliveryApi/IApiContentQueryService.cs | 7 +- .../IRequestMemberAccessService.cs | 12 ++ .../DeliveryApi/NoopApiContentQueryService.cs | 7 +- .../NoopRequestMemberAccessService.cs | 12 ++ .../Models/DeliveryApi/ProtectedAccess.cs | 16 ++ .../Security/IPublicAccessChecker.cs | 15 ++ .../UmbracoBuilder.CoreServices.cs | 1 + .../DeliveryApiContentIndexDeferredBase.cs | 2 +- ...piContentIndexHandlePublicAccessChanges.cs | 138 ++++++++++++-- ...ryApiContentIndexFieldDefinitionBuilder.cs | 2 + .../DeliveryApiContentIndexValueSetBuilder.cs | 49 ++++- .../Examine/DeliveryApiIndexingHandler.cs | 10 + .../Examine/UmbracoExamineFieldNames.cs | 10 + .../Security/IMemberApplicationManager.cs | 8 + .../OpenIdDictApplicationManagerBase.cs | 37 ++++ .../Security/IMemberSignInManager.cs | 5 +- .../Security/PublicAccessChecker.cs | 14 +- .../NewBackoffice/OpenAPIContractTest.cs | 3 - .../UmbracoTestServerTestBase.cs | 2 + .../Testing/UmbracoIntegrationTestBase.cs | 11 +- 50 files changed, 1521 insertions(+), 276 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs create mode 100644 src/Umbraco.Cms.Api.Common/Security/Paths.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Controllers/Security/MemberController.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Filters/SwaggerFilterBase.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Handlers/InitializeMemberApplicationNotificationHandler.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Handlers/RevokeMemberAuthenticationTokensNotificationHandler.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Security/MemberApplicationManager.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Services/RequestMemberAccessService.cs delete mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Security/Paths.cs create mode 100644 src/Umbraco.Core/Constants-OAuthClaims.cs rename src/Umbraco.Core/{Constants-OauthClientIds.cs => Constants-OAuthClientIds.cs} (52%) create mode 100644 src/Umbraco.Core/DeliveryApi/IRequestMemberAccessService.cs create mode 100644 src/Umbraco.Core/DeliveryApi/NoopRequestMemberAccessService.cs create mode 100644 src/Umbraco.Core/Models/DeliveryApi/ProtectedAccess.cs create mode 100644 src/Umbraco.Infrastructure/Security/IMemberApplicationManager.cs create mode 100644 src/Umbraco.Infrastructure/Security/OpenIdDictApplicationManagerBase.cs diff --git a/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs new file mode 100644 index 0000000000..c315d545c0 --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs @@ -0,0 +1,103 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Common.Security; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Infrastructure.HostedServices; + +namespace Umbraco.Cms.Api.Common.DependencyInjection; + +public static class UmbracoBuilderAuthExtensions +{ + private static bool _initialized; + + public static IUmbracoBuilder AddUmbracoOpenIddict(this IUmbracoBuilder builder) + { + if (_initialized is false) + { + ConfigureOpenIddict(builder); + _initialized = true; + } + + return builder; + } + + private static void ConfigureOpenIddict(IUmbracoBuilder builder) + { + builder.Services.AddOpenIddict() + // Register the OpenIddict server components. + .AddServer(options => + { + // Enable the authorization and token endpoints. + // - important: member endpoints MUST be added before backoffice endpoints to ensure that auto-discovery works for members + // FIXME: swap paths here so member API is first (see comment above) + options + .SetAuthorizationEndpointUris( + Paths.BackOfficeApi.AuthorizationEndpoint.TrimStart(Constants.CharArrays.ForwardSlash), + Paths.MemberApi.AuthorizationEndpoint.TrimStart(Constants.CharArrays.ForwardSlash)) + .SetTokenEndpointUris( + Paths.BackOfficeApi.TokenEndpoint.TrimStart(Constants.CharArrays.ForwardSlash), + Paths.MemberApi.TokenEndpoint.TrimStart(Constants.CharArrays.ForwardSlash)) + .SetLogoutEndpointUris( + Paths.BackOfficeApi.LogoutEndpoint.TrimStart(Constants.CharArrays.ForwardSlash), + Paths.MemberApi.LogoutEndpoint.TrimStart(Constants.CharArrays.ForwardSlash)) + .SetRevocationEndpointUris( + Paths.BackOfficeApi.RevokeEndpoint.TrimStart(Constants.CharArrays.ForwardSlash), + Paths.MemberApi.RevokeEndpoint.TrimStart(Constants.CharArrays.ForwardSlash)); + + // Enable authorization code flow with PKCE + options + .AllowAuthorizationCodeFlow() + .RequireProofKeyForCodeExchange() + .AllowRefreshTokenFlow(); + + // Register the encryption and signing credentials. + // - see https://documentation.openiddict.com/configuration/encryption-and-signing-credentials.html + options + // TODO: use actual certificates here, see docs above + .AddDevelopmentEncryptionCertificate() + .AddDevelopmentSigningCertificate() + .DisableAccessTokenEncryption(); + + // Register the ASP.NET Core host and configure for custom authentication endpoint. + options + .UseAspNetCore() + .EnableAuthorizationEndpointPassthrough() + .EnableLogoutEndpointPassthrough(); + + // Enable reference tokens + // - see https://documentation.openiddict.com/configuration/token-storage.html + options + .UseReferenceAccessTokens() + .UseReferenceRefreshTokens(); + + // Use ASP.NET Core Data Protection for tokens instead of JWT. + // This is more secure, and has the added benefit of having a high throughput + // but means that all servers (such as in a load balanced setup) + // needs to use the same application name and key ring, + // however this is already recommended for load balancing, so should be fine. + // See https://documentation.openiddict.com/configuration/token-formats.html#switching-to-data-protection-tokens + // and https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview?view=aspnetcore-7.0 + // for more information + options.UseDataProtection(); + }) + + // Register the OpenIddict validation components. + .AddValidation(options => + { + // Import the configuration from the local OpenIddict server instance. + options.UseLocalServer(); + + // Register the ASP.NET Core host. + options.UseAspNetCore(); + + // Enable token entry validation + // - see https://documentation.openiddict.com/configuration/token-storage.html#enabling-token-entry-validation-at-the-api-level + options.EnableTokenEntryValidation(); + + // Use ASP.NET Core Data Protection for tokens instead of JWT. (see note in AddServer) + options.UseDataProtection(); + }); + + builder.Services.AddHostedService(); + } +} diff --git a/src/Umbraco.Cms.Api.Common/OpenApi/SwaggerRouteTemplatePipelineFilter.cs b/src/Umbraco.Cms.Api.Common/OpenApi/SwaggerRouteTemplatePipelineFilter.cs index b0a2a3f144..550dc214cd 100644 --- a/src/Umbraco.Cms.Api.Common/OpenApi/SwaggerRouteTemplatePipelineFilter.cs +++ b/src/Umbraco.Cms.Api.Common/OpenApi/SwaggerRouteTemplatePipelineFilter.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; +using Swashbuckle.AspNetCore.SwaggerUI; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Web.Common.ApplicationBuilder; @@ -32,20 +33,8 @@ public class SwaggerRouteTemplatePipelineFilter : UmbracoPipelineFilter { swaggerOptions.RouteTemplate = SwaggerRouteTemplate(applicationBuilder); }); - applicationBuilder.UseSwaggerUI( - swaggerUiOptions => - { - swaggerUiOptions.RoutePrefix = SwaggerUiRoutePrefix(applicationBuilder); - foreach ((var name, OpenApiInfo? apiInfo) in swaggerGenOptions.Value.SwaggerGeneratorOptions.SwaggerDocs - .OrderBy(x => x.Value.Title)) - { - swaggerUiOptions.SwaggerEndpoint($"{name}/swagger.json", $"{apiInfo.Title}"); - } - - swaggerUiOptions.OAuthClientId(Constants.OauthClientIds.Swagger); - swaggerUiOptions.OAuthUsePkce(); - }); + applicationBuilder.UseSwaggerUI(swaggerUiOptions => SwaggerUiConfiguration(swaggerUiOptions, swaggerGenOptions.Value, applicationBuilder)); } protected virtual bool SwaggerIsEnabled(IApplicationBuilder applicationBuilder) @@ -60,6 +49,23 @@ public class SwaggerRouteTemplatePipelineFilter : UmbracoPipelineFilter protected virtual string SwaggerUiRoutePrefix(IApplicationBuilder applicationBuilder) => $"{GetUmbracoPath(applicationBuilder).TrimStart(Constants.CharArrays.ForwardSlash)}/swagger"; + protected virtual void SwaggerUiConfiguration( + SwaggerUIOptions swaggerUiOptions, + SwaggerGenOptions swaggerGenOptions, + IApplicationBuilder applicationBuilder) + { + swaggerUiOptions.RoutePrefix = SwaggerUiRoutePrefix(applicationBuilder); + + foreach ((var name, OpenApiInfo? apiInfo) in swaggerGenOptions.SwaggerGeneratorOptions.SwaggerDocs + .OrderBy(x => x.Value.Title)) + { + swaggerUiOptions.SwaggerEndpoint($"{name}/swagger.json", $"{apiInfo.Title}"); + } + + swaggerUiOptions.OAuthClientId(Constants.OAuthClientIds.Swagger); + swaggerUiOptions.OAuthUsePkce(); + } + private string GetUmbracoPath(IApplicationBuilder applicationBuilder) { GlobalSettings settings = applicationBuilder.ApplicationServices.GetRequiredService>().Value; diff --git a/src/Umbraco.Cms.Api.Common/Security/Paths.cs b/src/Umbraco.Cms.Api.Common/Security/Paths.cs new file mode 100644 index 0000000000..561a5d84bb --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/Security/Paths.cs @@ -0,0 +1,35 @@ +namespace Umbraco.Cms.Api.Common.Security; + +public static class Paths +{ + public static class BackOfficeApi + { + public const string EndpointTemplate = "security/back-office"; + + public static readonly string AuthorizationEndpoint = EndpointPath($"{EndpointTemplate}/authorize"); + + public static readonly string TokenEndpoint = EndpointPath($"{EndpointTemplate}/token"); + + public static readonly string LogoutEndpoint = EndpointPath($"{EndpointTemplate}/signout"); + + public static readonly string RevokeEndpoint = EndpointPath($"{EndpointTemplate}/revoke"); + + private static string EndpointPath(string relativePath) => $"/umbraco/management/api/v1/{relativePath}"; + } + + public static class MemberApi + { + public const string EndpointTemplate = "security/member"; + + public static readonly string AuthorizationEndpoint = EndpointPath($"{EndpointTemplate}/authorize"); + + public static readonly string TokenEndpoint = EndpointPath($"{EndpointTemplate}/token"); + + public static readonly string LogoutEndpoint = EndpointPath($"{EndpointTemplate}/signout"); + + public static readonly string RevokeEndpoint = EndpointPath($"{EndpointTemplate}/revoke"); + + // NOTE: we're NOT using /api/v1.0/ here because it will clash with the Delivery API docs + private static string EndpointPath(string relativePath) => $"/umbraco/delivery/api/v1/{relativePath}"; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs new file mode 100644 index 0000000000..11e8070f4c --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using Umbraco.Cms.Api.Common.Security; +using Umbraco.Cms.Api.Delivery.Controllers.Content; +using Umbraco.Cms.Api.Delivery.Filters; + +namespace Umbraco.Cms.Api.Delivery.Configuration; + +/// +/// This configures member authentication for the Delivery API in Swagger. Consult the docs for +/// member authentication within the Delivery API for instructions on how to use this. +/// +/// +/// This class is not used by the core CMS due to the required installation dependencies (local login page among other things). +/// +public class ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions : IConfigureOptions +{ + private const string AuthSchemeName = "Umbraco Member"; + + public void Configure(SwaggerGenOptions options) + { + options.AddSecurityDefinition( + AuthSchemeName, + new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Name = AuthSchemeName, + Type = SecuritySchemeType.OAuth2, + Description = "Umbraco Member Authentication", + Flows = new OpenApiOAuthFlows + { + AuthorizationCode = new OpenApiOAuthFlow + { + AuthorizationUrl = new Uri(Paths.MemberApi.AuthorizationEndpoint, UriKind.Relative), + TokenUrl = new Uri(Paths.MemberApi.TokenEndpoint, UriKind.Relative) + } + } + }); + + // add security requirements for content API operations + options.OperationFilter(); + } + + private class DeliveryApiSecurityFilter : SwaggerFilterBase, IOperationFilter + { + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + if (CanApply(context) is false) + { + return; + } + + operation.Security = new List + { + new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = AuthSchemeName, + } + }, + new string[] { } + } + } + }; + } + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdContentApiController.cs index d16afc4d6d..877e662da7 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdContentApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdContentApiController.cs @@ -1,7 +1,9 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Services; @@ -11,14 +13,41 @@ namespace Umbraco.Cms.Api.Delivery.Controllers.Content; [ApiVersion("1.0")] public class ByIdContentApiController : ContentApiItemControllerBase { + private readonly IRequestMemberAccessService _requestMemberAccessService; + + [Obsolete($"Please use the constructor that does not accept {nameof(IPublicAccessService)}. Will be removed in V14.")] public ByIdContentApiController( IApiPublishedContentCache apiPublishedContentCache, IApiContentResponseBuilder apiContentResponseBuilder, IPublicAccessService publicAccessService) - : base(apiPublishedContentCache, apiContentResponseBuilder, publicAccessService) + : this( + apiPublishedContentCache, + apiContentResponseBuilder, + StaticServiceProvider.Instance.GetRequiredService()) { } + [Obsolete($"Please use the constructor that does not accept {nameof(IPublicAccessService)}. Will be removed in V14.")] + public ByIdContentApiController( + IApiPublishedContentCache apiPublishedContentCache, + IApiContentResponseBuilder apiContentResponseBuilder, + IPublicAccessService publicAccessService, + IRequestMemberAccessService requestMemberAccessService) + : this( + apiPublishedContentCache, + apiContentResponseBuilder, + requestMemberAccessService) + { + } + + [ActivatorUtilitiesConstructor] + public ByIdContentApiController( + IApiPublishedContentCache apiPublishedContentCache, + IApiContentResponseBuilder apiContentResponseBuilder, + IRequestMemberAccessService requestMemberAccessService) + : base(apiPublishedContentCache, apiContentResponseBuilder) + => _requestMemberAccessService = requestMemberAccessService; + /// /// Gets a content item by id. /// @@ -28,6 +57,7 @@ public class ByIdContentApiController : ContentApiItemControllerBase [MapToApiVersion("1.0")] [ProducesResponseType(typeof(IApiContentResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task ById(Guid id) { @@ -38,9 +68,10 @@ public class ByIdContentApiController : ContentApiItemControllerBase return NotFound(); } - if (IsProtected(contentItem)) + IActionResult? deniedAccessResult = await HandleMemberAccessAsync(contentItem, _requestMemberAccessService); + if (deniedAccessResult is not null) { - return Unauthorized(); + return deniedAccessResult; } IApiContentResponse? apiContentResponse = ApiContentResponseBuilder.Build(contentItem); @@ -49,6 +80,6 @@ public class ByIdContentApiController : ContentApiItemControllerBase return NotFound(); } - return await Task.FromResult(Ok(apiContentResponse)); + return Ok(apiContentResponse); } } diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdsContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdsContentApiController.cs index 5d415fffe6..df7e3b26a4 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdsContentApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdsContentApiController.cs @@ -1,7 +1,9 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Services; @@ -12,14 +14,41 @@ namespace Umbraco.Cms.Api.Delivery.Controllers.Content; [ApiVersion("1.0")] public class ByIdsContentApiController : ContentApiItemControllerBase { + private readonly IRequestMemberAccessService _requestMemberAccessService; + + [Obsolete($"Please use the constructor that does not accept {nameof(IPublicAccessService)}. Will be removed in V14.")] public ByIdsContentApiController( IApiPublishedContentCache apiPublishedContentCache, IApiContentResponseBuilder apiContentResponseBuilder, IPublicAccessService publicAccessService) - : base(apiPublishedContentCache, apiContentResponseBuilder, publicAccessService) + : this( + apiPublishedContentCache, + apiContentResponseBuilder, + StaticServiceProvider.Instance.GetRequiredService()) { } + [Obsolete($"Please use the constructor that does not accept {nameof(IPublicAccessService)}. Will be removed in V14.")] + public ByIdsContentApiController( + IApiPublishedContentCache apiPublishedContentCache, + IApiContentResponseBuilder apiContentResponseBuilder, + IPublicAccessService publicAccessService, + IRequestMemberAccessService requestMemberAccessService) + : this( + apiPublishedContentCache, + apiContentResponseBuilder, + requestMemberAccessService) + { + } + + [ActivatorUtilitiesConstructor] + public ByIdsContentApiController( + IApiPublishedContentCache apiPublishedContentCache, + IApiContentResponseBuilder apiContentResponseBuilder, + IRequestMemberAccessService requestMemberAccessService) + : base(apiPublishedContentCache, apiContentResponseBuilder) + => _requestMemberAccessService = requestMemberAccessService; + /// /// Gets content items by ids. /// @@ -28,12 +57,19 @@ public class ByIdsContentApiController : ContentApiItemControllerBase [HttpGet("item")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public async Task Item([FromQuery(Name = "id")] HashSet ids) + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task Item([FromQuery(Name = "id")] HashSet ids) { - IEnumerable contentItems = ApiPublishedContentCache.GetByIds(ids); + IPublishedContent[] contentItems = ApiPublishedContentCache.GetByIds(ids).ToArray(); + + IActionResult? deniedAccessResult = await HandleMemberAccessAsync(contentItems, _requestMemberAccessService); + if (deniedAccessResult is not null) + { + return deniedAccessResult; + } IApiContentResponse[] apiContentItems = contentItems - .Where(contentItem => !IsProtected(contentItem)) .Select(ApiContentResponseBuilder.Build) .WhereNotNull() .ToArray(); diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByRouteContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByRouteContentApiController.cs index 2d22887637..4806db45ff 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByRouteContentApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByRouteContentApiController.cs @@ -1,8 +1,10 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core; using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Services; @@ -16,7 +18,9 @@ public class ByRouteContentApiController : ContentApiItemControllerBase private readonly IRequestRoutingService _requestRoutingService; private readonly IRequestRedirectService _requestRedirectService; private readonly IRequestPreviewService _requestPreviewService; + private readonly IRequestMemberAccessService _requestMemberAccessService; + [Obsolete($"Please use the constructor that does not accept {nameof(IPublicAccessService)}. Will be removed in V14.")] public ByRouteContentApiController( IApiPublishedContentCache apiPublishedContentCache, IApiContentResponseBuilder apiContentResponseBuilder, @@ -24,11 +28,49 @@ public class ByRouteContentApiController : ContentApiItemControllerBase IRequestRoutingService requestRoutingService, IRequestRedirectService requestRedirectService, IRequestPreviewService requestPreviewService) - : base(apiPublishedContentCache, apiContentResponseBuilder, publicAccessService) + : this( + apiPublishedContentCache, + apiContentResponseBuilder, + requestRoutingService, + requestRedirectService, + requestPreviewService, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + [Obsolete($"Please use the constructor that does not accept {nameof(IPublicAccessService)}. Will be removed in V14.")] + public ByRouteContentApiController( + IApiPublishedContentCache apiPublishedContentCache, + IApiContentResponseBuilder apiContentResponseBuilder, + IPublicAccessService publicAccessService, + IRequestRoutingService requestRoutingService, + IRequestRedirectService requestRedirectService, + IRequestPreviewService requestPreviewService, + IRequestMemberAccessService requestMemberAccessService) + : this( + apiPublishedContentCache, + apiContentResponseBuilder, + requestRoutingService, + requestRedirectService, + requestPreviewService, + requestMemberAccessService) + { + } + + [ActivatorUtilitiesConstructor] + public ByRouteContentApiController( + IApiPublishedContentCache apiPublishedContentCache, + IApiContentResponseBuilder apiContentResponseBuilder, + IRequestRoutingService requestRoutingService, + IRequestRedirectService requestRedirectService, + IRequestPreviewService requestPreviewService, + IRequestMemberAccessService requestMemberAccessService) + : base(apiPublishedContentCache, apiContentResponseBuilder) { _requestRoutingService = requestRoutingService; _requestRedirectService = requestRedirectService; _requestPreviewService = requestPreviewService; + _requestMemberAccessService = requestMemberAccessService; } /// @@ -44,6 +86,7 @@ public class ByRouteContentApiController : ContentApiItemControllerBase [MapToApiVersion("1.0")] [ProducesResponseType(typeof(IApiContentResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task ByRoute(string path = "") { @@ -55,9 +98,10 @@ public class ByRouteContentApiController : ContentApiItemControllerBase IPublishedContent? contentItem = GetContent(path); if (contentItem is not null) { - if (IsProtected(contentItem)) + IActionResult? deniedAccessResult = await HandleMemberAccessAsync(contentItem, _requestMemberAccessService); + if (deniedAccessResult is not null) { - return Unauthorized(); + return deniedAccessResult; } return await Task.FromResult(Ok(ApiContentResponseBuilder.Build(contentItem))); diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ContentApiControllerBase.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ContentApiControllerBase.cs index ebfa32c479..405da6e15f 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ContentApiControllerBase.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ContentApiControllerBase.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.Builders; using Umbraco.Cms.Api.Delivery.Filters; using Umbraco.Cms.Api.Delivery.Routing; @@ -44,4 +45,14 @@ public abstract class ContentApiControllerBase : DeliveryApiControllerBase .WithDetail($"Content query status \"{status}\" was not expected here") .Build()), }; + + /// + /// Creates a 403 Forbidden result. + /// + /// + /// Use this method instead of on the controller base. The latter will yield + /// a redirect to an access denied URL because of the default cookie auth scheme. This method ensures that a proper + /// 403 Forbidden status code is returned to the client. + /// + protected IActionResult Forbidden() => new StatusCodeResult(StatusCodes.Status403Forbidden); } diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ContentApiItemControllerBase.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ContentApiItemControllerBase.cs index dad11de009..895cd376cd 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ContentApiItemControllerBase.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ContentApiItemControllerBase.cs @@ -1,22 +1,58 @@ -using Umbraco.Cms.Core.DeliveryApi; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Api.Delivery.Controllers.Content; public abstract class ContentApiItemControllerBase : ContentApiControllerBase { + // TODO: Remove this in V14 when the obsolete constructors have been removed private readonly IPublicAccessService _publicAccessService; + [Obsolete($"Please use the constructor that does not accept {nameof(IPublicAccessService)}. Will be removed in V14.")] protected ContentApiItemControllerBase( IApiPublishedContentCache apiPublishedContentCache, IApiContentResponseBuilder apiContentResponseBuilder, IPublicAccessService publicAccessService) - : base(apiPublishedContentCache, apiContentResponseBuilder) - => _publicAccessService = publicAccessService; + : this(apiPublishedContentCache, apiContentResponseBuilder) + { + } - // NOTE: we're going to test for protected content at item endpoint level, because the check has already been - // performed at content index time for the query endpoint and we don't want that extra overhead when - // returning multiple items. + protected ContentApiItemControllerBase( + IApiPublishedContentCache apiPublishedContentCache, + IApiContentResponseBuilder apiContentResponseBuilder) + : base(apiPublishedContentCache, apiContentResponseBuilder) + => _publicAccessService = StaticServiceProvider.Instance.GetRequiredService(); + + [Obsolete($"Please use {nameof(IPublicAccessService)} to test for content protection. Will be removed in V14.")] protected bool IsProtected(IPublishedContent content) => _publicAccessService.IsProtected(content.Path); + + protected async Task HandleMemberAccessAsync(IPublishedContent contentItem, IRequestMemberAccessService requestMemberAccessService) + { + PublicAccessStatus accessStatus = await requestMemberAccessService.MemberHasAccessToAsync(contentItem); + return accessStatus is PublicAccessStatus.AccessAccepted + ? null + : accessStatus is PublicAccessStatus.AccessDenied + ? Forbidden() + : Unauthorized(); + } + + protected async Task HandleMemberAccessAsync(IEnumerable contentItems, IRequestMemberAccessService requestMemberAccessService) + { + foreach (IPublishedContent content in contentItems) + { + IActionResult? result = await HandleMemberAccessAsync(content, requestMemberAccessService); + // if any of the content items yield an error based on the current member access, return that error + if (result is not null) + { + return result; + } + } + + return null; + } } diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/QueryContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/QueryContentApiController.cs index d4db82d9be..2f4e8af9c8 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/QueryContentApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/QueryContentApiController.cs @@ -1,9 +1,11 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Core; using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; @@ -15,14 +17,33 @@ namespace Umbraco.Cms.Api.Delivery.Controllers.Content; [ApiVersion("1.0")] public class QueryContentApiController : ContentApiControllerBase { + private readonly IRequestMemberAccessService _requestMemberAccessService; private readonly IApiContentQueryService _apiContentQueryService; + [Obsolete($"Please use the constructor that accepts {nameof(IRequestMemberAccessService)}. Will be removed in V14.")] public QueryContentApiController( IApiPublishedContentCache apiPublishedContentCache, IApiContentResponseBuilder apiContentResponseBuilderBuilder, IApiContentQueryService apiContentQueryService) + : this( + apiPublishedContentCache, + apiContentResponseBuilderBuilder, + apiContentQueryService, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + [ActivatorUtilitiesConstructor] + public QueryContentApiController( + IApiPublishedContentCache apiPublishedContentCache, + IApiContentResponseBuilder apiContentResponseBuilderBuilder, + IApiContentQueryService apiContentQueryService, + IRequestMemberAccessService requestMemberAccessService) : base(apiPublishedContentCache, apiContentResponseBuilderBuilder) - => _apiContentQueryService = apiContentQueryService; + { + _apiContentQueryService = apiContentQueryService; + _requestMemberAccessService = requestMemberAccessService; + } /// /// Gets a paginated list of content item(s) from query. @@ -45,7 +66,8 @@ public class QueryContentApiController : ContentApiControllerBase int skip = 0, int take = 10) { - Attempt, ApiContentQueryOperationStatus> queryAttempt = _apiContentQueryService.ExecuteQuery(fetch, filter, sort, skip, take); + ProtectedAccess protectedAccess = await _requestMemberAccessService.MemberAccessAsync(); + Attempt, ApiContentQueryOperationStatus> queryAttempt = _apiContentQueryService.ExecuteQuery(fetch, filter, sort, protectedAccess, skip, take); if (queryAttempt.Success is false) { @@ -62,6 +84,6 @@ public class QueryContentApiController : ContentApiControllerBase Items = apiContentItems }; - return await Task.FromResult(Ok(model)); + return Ok(model); } } diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Security/MemberController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Security/MemberController.cs new file mode 100644 index 0000000000..dcf3c82614 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Security/MemberController.cs @@ -0,0 +1,171 @@ +using System.Security.Claims; +using Asp.Versioning; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using Umbraco.Cms.Api.Delivery.Routing; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Web.Common.Security; +using Umbraco.Extensions; +using SignInResult = Microsoft.AspNetCore.Mvc.SignInResult; +using IdentitySignInResult = Microsoft.AspNetCore.Identity.SignInResult; + +namespace Umbraco.Cms.Api.Delivery.Controllers.Security; + +[ApiVersion("1.0")] +[ApiController] +[VersionedDeliveryApiRoute(Common.Security.Paths.MemberApi.EndpointTemplate)] +[ApiExplorerSettings(IgnoreApi = true)] +public class MemberController : DeliveryApiControllerBase +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IMemberSignInManager _memberSignInManager; + private readonly IMemberManager _memberManager; + private readonly DeliveryApiSettings _deliveryApiSettings; + private readonly ILogger _logger; + + public MemberController( + IHttpContextAccessor httpContextAccessor, + IMemberSignInManager memberSignInManager, + IMemberManager memberManager, + IOptions deliveryApiSettings, + ILogger logger) + { + _httpContextAccessor = httpContextAccessor; + _memberSignInManager = memberSignInManager; + _memberManager = memberManager; + _logger = logger; + _deliveryApiSettings = deliveryApiSettings.Value; + } + + [HttpGet("authorize")] + [MapToApiVersion("1.0")] + public async Task Authorize() + { + // in principle this is not necessary for now, since the member application has been removed, thus making + // the member client ID invalid for the authentication code flow. However, if we ever add additional flows + // to the API, we should perform this check, so we might as well include it upfront. + if (_deliveryApiSettings.MemberAuthorizationIsEnabled() is false) + { + return BadRequest("Member authorization is not allowed."); + } + + HttpContext context = _httpContextAccessor.GetRequiredHttpContext(); + OpenIddictRequest? request = context.GetOpenIddictServerRequest(); + if (request is null) + { + return BadRequest("Unable to obtain OpenID data from the current request."); + } + + // make sure this endpoint ONLY handles member authentication + if (request.ClientId is not Constants.OAuthClientIds.Member) + { + return BadRequest("The specified client ID cannot be used here."); + } + + return request.IdentityProvider.IsNullOrWhiteSpace() + ? await AuthorizeInternal(request) + : await AuthorizeExternal(request); + } + + [HttpGet("signout")] + [MapToApiVersion("1.0")] + public async Task Signout() + { + await _memberSignInManager.SignOutAsync(); + return SignOut(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + private async Task AuthorizeInternal(OpenIddictRequest request) + { + // retrieve the user principal stored in the authentication cookie. + AuthenticateResult cookieAuthResult = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); + var userName = cookieAuthResult.Succeeded + ? cookieAuthResult.Principal?.Identity?.Name + : null; + + if (userName is null) + { + return Challenge(IdentityConstants.ApplicationScheme); + } + + MemberIdentityUser? member = await _memberManager.FindByNameAsync(userName); + if (member is null) + { + _logger.LogError("The member with username {userName} was successfully authorized, but could not be retrieved by the member manager", userName); + return BadRequest("The member could not be found."); + } + + return await SignInMember(member, request); + } + + private async Task AuthorizeExternal(OpenIddictRequest request) + { + var provider = request.IdentityProvider ?? throw new ArgumentException("No identity provider found in request", nameof(request)); + ExternalLoginInfo? loginInfo = await _memberSignInManager.GetExternalLoginInfoAsync(); + + if (loginInfo?.Principal is null) + { + AuthenticationProperties properties = _memberSignInManager.ConfigureExternalAuthenticationProperties(provider, null); + return Challenge(properties, provider); + } + + // NOTE: if we're going to support 2FA for members, we need to: + // - use SecuritySettings.MemberBypassTwoFactorForExternalLogins instead of the hardcoded value (true) for "bypassTwoFactor". + // - handle IdentitySignInResult.TwoFactorRequired + IdentitySignInResult result = await _memberSignInManager.ExternalLoginSignInAsync(loginInfo, false, true); + if (result == IdentitySignInResult.Success) + { + // get the member and perform sign-in + MemberIdentityUser? member = await _memberManager.FindByLoginAsync(loginInfo.LoginProvider, loginInfo.ProviderKey); + if (member is null) + { + _logger.LogError("A member was successfully authorized using external authentication, but could not be retrieved by the member manager"); + return BadRequest("The member could not be found."); + } + + // update member authentication tokens if succeeded + await _memberSignInManager.UpdateExternalAuthenticationTokensAsync(loginInfo); + return await SignInMember(member, request); + } + + var errorProperties = new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.AccessDenied, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The member is not allowed to access this resource." + }); + return Forbid(errorProperties, provider); + } + + private async Task SignInMember(MemberIdentityUser member, OpenIddictRequest request) + { + ClaimsPrincipal memberPrincipal = await _memberSignInManager.CreateUserPrincipalAsync(member); + memberPrincipal.SetClaim(OpenIddictConstants.Claims.Subject, member.Key.ToString()); + + IList roles = await _memberManager.GetRolesAsync(member); + memberPrincipal.SetClaim(Constants.OAuthClaims.MemberKey, member.Key.ToString()); + memberPrincipal.SetClaim(Constants.OAuthClaims.MemberRoles, string.Join(",", roles)); + + Claim[] claims = memberPrincipal.Claims.ToArray(); + foreach (Claim claim in claims.Where(claim => claim.Type is not Constants.Security.SecurityStampClaimType)) + { + claim.SetDestinations(OpenIddictConstants.Destinations.AccessToken); + } + + if (request.GetScopes().Contains(OpenIddictConstants.Scopes.OfflineAccess)) + { + // "offline_access" scope is required to use refresh tokens + memberPrincipal.SetScopes(OpenIddictConstants.Scopes.OfflineAccess); + } + + return new SignInResult(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, memberPrincipal); + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs index d87741746a..8609450f3c 100644 --- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -4,12 +4,16 @@ using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Common.DependencyInjection; using Umbraco.Cms.Api.Delivery.Accessors; using Umbraco.Cms.Api.Delivery.Configuration; +using Umbraco.Cms.Api.Delivery.Handlers; using Umbraco.Cms.Api.Delivery.Json; using Umbraco.Cms.Api.Delivery.Rendering; +using Umbraco.Cms.Api.Delivery.Security; using Umbraco.Cms.Api.Delivery.Services; using Umbraco.Cms.Core; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Infrastructure.Security; namespace Umbraco.Extensions; @@ -29,6 +33,8 @@ public static class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.ConfigureOptions(); builder.AddUmbracoApiOpenApiUI(); @@ -44,6 +50,13 @@ public static class UmbracoBuilderExtensions options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); + builder.Services.AddAuthentication(); + builder.AddUmbracoOpenIddict(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); return builder; } } diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs index 32791b9b5e..36721cc0f2 100644 --- a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs @@ -1,18 +1,17 @@ -using System.Reflection; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; -using Umbraco.Extensions; namespace Umbraco.Cms.Api.Delivery.Filters; -internal abstract class SwaggerDocumentationFilterBase : IOperationFilter, IParameterFilter +internal abstract class SwaggerDocumentationFilterBase + : SwaggerFilterBase, IOperationFilter, IParameterFilter where TBaseController : Controller { public void Apply(OpenApiOperation operation, OperationFilterContext context) { - if (CanApply(context.MethodInfo)) + if (CanApply(context)) { ApplyOperation(operation, context); } @@ -20,7 +19,7 @@ internal abstract class SwaggerDocumentationFilterBase : IOpera public void Apply(OpenApiParameter parameter, ParameterFilterContext context) { - if (CanApply(context.ParameterInfo.Member)) + if (CanApply(context)) { ApplyParameter(parameter, context); } @@ -76,7 +75,4 @@ internal abstract class SwaggerDocumentationFilterBase : IOpera private string QueryParameterDescription(string description) => $"{description}. Refer to [the documentation]({DocumentationLink}#query-parameters) for more details on this."; - - private bool CanApply(MemberInfo member) - => member.DeclaringType?.Implements() is true; } diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerFilterBase.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerFilterBase.cs new file mode 100644 index 0000000000..7992ac279a --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerFilterBase.cs @@ -0,0 +1,19 @@ +using System.Reflection; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.SwaggerGen; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Filters; + +internal abstract class SwaggerFilterBase + where TBaseController : Controller +{ + protected bool CanApply(OperationFilterContext context) + => CanApply(context.MethodInfo); + + protected bool CanApply(ParameterFilterContext context) + => CanApply(context.ParameterInfo.Member); + + private bool CanApply(MemberInfo member) + => member.DeclaringType?.Implements() is true; +} diff --git a/src/Umbraco.Cms.Api.Delivery/Handlers/InitializeMemberApplicationNotificationHandler.cs b/src/Umbraco.Cms.Api.Delivery/Handlers/InitializeMemberApplicationNotificationHandler.cs new file mode 100644 index 0000000000..d0153dd122 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Handlers/InitializeMemberApplicationNotificationHandler.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Security; + +namespace Umbraco.Cms.Api.Delivery.Handlers; + +internal sealed class InitializeMemberApplicationNotificationHandler : INotificationAsyncHandler +{ + private readonly IMemberApplicationManager _memberApplicationManager; + private readonly IRuntimeState _runtimeState; + private readonly ILogger _logger; + private readonly DeliveryApiSettings _deliveryApiSettings; + + public InitializeMemberApplicationNotificationHandler( + IMemberApplicationManager memberApplicationManager, + IRuntimeState runtimeState, + IOptions deliveryApiSettings, + ILogger logger) + { + _memberApplicationManager = memberApplicationManager; + _runtimeState = runtimeState; + _logger = logger; + _deliveryApiSettings = deliveryApiSettings.Value; + } + + public async Task HandleAsync(UmbracoApplicationStartingNotification notification, CancellationToken cancellationToken) + { + if (_runtimeState.Level != RuntimeLevel.Run) + { + return; + } + + if (_deliveryApiSettings.MemberAuthorization?.AuthorizationCodeFlow?.Enabled is not true) + { + await _memberApplicationManager.DeleteMemberApplicationAsync(cancellationToken); + return; + } + + if (ValidateRedirectUrls(_deliveryApiSettings.MemberAuthorization.AuthorizationCodeFlow.LoginRedirectUrls) is false) + { + await _memberApplicationManager.DeleteMemberApplicationAsync(cancellationToken); + return; + } + + if (_deliveryApiSettings.MemberAuthorization.AuthorizationCodeFlow.LogoutRedirectUrls.Any() + && ValidateRedirectUrls(_deliveryApiSettings.MemberAuthorization.AuthorizationCodeFlow.LogoutRedirectUrls) is false) + { + await _memberApplicationManager.DeleteMemberApplicationAsync(cancellationToken); + return; + } + + await _memberApplicationManager.EnsureMemberApplicationAsync( + _deliveryApiSettings.MemberAuthorization.AuthorizationCodeFlow.LoginRedirectUrls, + _deliveryApiSettings.MemberAuthorization.AuthorizationCodeFlow.LogoutRedirectUrls, + cancellationToken); + } + + private bool ValidateRedirectUrls(Uri[] redirectUrls) + { + if (redirectUrls.Any() is false) + { + _logger.LogWarning("No redirect URLs defined for Delivery API member authentication - cannot enable member authentication"); + return false; + } + + if (redirectUrls.All(url => url.IsAbsoluteUri) is false) + { + _logger.LogWarning("All redirect URLs defined for Delivery API member authentication must be absolute - cannot enable member authentication"); + return false; + } + + return true; + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Handlers/RevokeMemberAuthenticationTokensNotificationHandler.cs b/src/Umbraco.Cms.Api.Delivery/Handlers/RevokeMemberAuthenticationTokensNotificationHandler.cs new file mode 100644 index 0000000000..428ef09f72 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Handlers/RevokeMemberAuthenticationTokensNotificationHandler.cs @@ -0,0 +1,102 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Delivery.Handlers; + +internal sealed class RevokeMemberAuthenticationTokensNotificationHandler + : INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler +{ + private readonly IMemberService _memberService; + private readonly IOpenIddictTokenManager _tokenManager; + private readonly bool _enabled; + private readonly ILogger _logger; + + public RevokeMemberAuthenticationTokensNotificationHandler( + IMemberService memberService, + IOpenIddictTokenManager tokenManager, + IOptions deliveryApiSettings, + ILogger logger) + { + _memberService = memberService; + _tokenManager = tokenManager; + _logger = logger; + _enabled = deliveryApiSettings.Value.MemberAuthorizationIsEnabled(); + } + + public async Task HandleAsync(MemberSavedNotification notification, CancellationToken cancellationToken) + { + if (_enabled is false) + { + return; + } + + foreach (IMember member in notification.SavedEntities.Where(member => member.IsLockedOut || member.IsApproved is false)) + { + // member is locked out and/or un-approved, make sure we revoke all tokens + await RevokeTokensAsync(member); + } + } + + public async Task HandleAsync(MemberDeletedNotification notification, CancellationToken cancellationToken) + { + if (_enabled is false) + { + return; + } + + foreach (IMember member in notification.DeletedEntities) + { + await RevokeTokensAsync(member); + } + } + + public async Task HandleAsync(AssignedMemberRolesNotification notification, CancellationToken cancellationToken) + => await MemberRolesChangedAsync(notification); + + public async Task HandleAsync(RemovedMemberRolesNotification notification, CancellationToken cancellationToken) + => await MemberRolesChangedAsync(notification); + + private async Task RevokeTokensAsync(IMember member) + { + var tokens = await _tokenManager.FindBySubjectAsync(member.Key.ToString()).ToArrayAsync(); + if (tokens.Any() is false) + { + return; + } + + _logger.LogInformation("Deleting {count} active tokens for member with ID {id}", tokens.Length, member.Id); + foreach (var token in tokens) + { + await _tokenManager.DeleteAsync(token); + } + } + + private async Task MemberRolesChangedAsync(MemberRolesNotification notification) + { + if (_enabled is false) + { + return; + } + + foreach (var memberId in notification.MemberIds) + { + IMember? member = _memberService.GetById(memberId); + if (member is null) + { + _logger.LogWarning("Unable to find member with ID {id}", memberId); + continue; + } + + await RevokeTokensAsync(member); + } + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Security/MemberApplicationManager.cs b/src/Umbraco.Cms.Api.Delivery/Security/MemberApplicationManager.cs new file mode 100644 index 0000000000..1f365a08aa --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Security/MemberApplicationManager.cs @@ -0,0 +1,67 @@ +using OpenIddict.Abstractions; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Security; + +namespace Umbraco.Cms.Api.Delivery.Security; + +public class MemberApplicationManager : OpenIdDictApplicationManagerBase, IMemberApplicationManager +{ + private readonly IRuntimeState _runtimeState; + + public MemberApplicationManager(IOpenIddictApplicationManager applicationManager, IRuntimeState runtimeState) + : base(applicationManager) + => _runtimeState = runtimeState; + + public async Task EnsureMemberApplicationAsync(IEnumerable loginRedirectUrls, IEnumerable logoutRedirectUrls, CancellationToken cancellationToken = default) + { + if (_runtimeState.Level < RuntimeLevel.Run) + { + return; + } + + Uri[] loginRedirectUrlsArray = loginRedirectUrls as Uri[] ?? loginRedirectUrls.ToArray(); + if (loginRedirectUrlsArray.All(r => r.IsAbsoluteUri) is false) + { + throw new ArgumentException("Expected absolute login redirect URLs for Delivery API member authentication", nameof(loginRedirectUrls)); + } + + Uri[] logoutRedirectUrlsArray = logoutRedirectUrls as Uri[] ?? logoutRedirectUrls.ToArray(); + if (logoutRedirectUrlsArray.All(r => r.IsAbsoluteUri) is false) + { + throw new ArgumentException("Expected absolute logout redirect URLs for Delivery API member authentication", nameof(logoutRedirectUrlsArray)); + } + + var applicationDescriptor = new OpenIddictApplicationDescriptor + { + DisplayName = "Umbraco member access", + ClientId = Constants.OAuthClientIds.Member, + Type = OpenIddictConstants.ClientTypes.Public, + Permissions = + { + OpenIddictConstants.Permissions.Endpoints.Authorization, + OpenIddictConstants.Permissions.Endpoints.Token, + OpenIddictConstants.Permissions.Endpoints.Logout, + OpenIddictConstants.Permissions.Endpoints.Revocation, + OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, + OpenIddictConstants.Permissions.GrantTypes.RefreshToken, + OpenIddictConstants.Permissions.ResponseTypes.Code + } + }; + + foreach (Uri redirectUrl in loginRedirectUrlsArray) + { + applicationDescriptor.RedirectUris.Add(redirectUrl); + } + + foreach (Uri redirectUrl in logoutRedirectUrlsArray) + { + applicationDescriptor.PostLogoutRedirectUris.Add(redirectUrl); + } + + await CreateOrUpdate(applicationDescriptor, cancellationToken); + } + + public async Task DeleteMemberApplicationAsync(CancellationToken cancellationToken = default) + => await Delete(Constants.OAuthClientIds.Member, cancellationToken); +} diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryProvider.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryProvider.cs index 45ae32b4f5..73e5bb4a53 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryProvider.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryProvider.cs @@ -3,9 +3,12 @@ using Examine.Lucene.Providers; using Examine.Lucene.Search; using Examine.Search; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Extensions; @@ -18,6 +21,7 @@ internal sealed class ApiContentQueryProvider : IApiContentQueryProvider { private const string ItemIdFieldName = "itemId"; private readonly IExamineManager _examineManager; + private readonly DeliveryApiSettings _deliveryApiSettings; private readonly ILogger _logger; private readonly string _fallbackGuidValue; private readonly Dictionary _fieldTypes; @@ -25,9 +29,11 @@ internal sealed class ApiContentQueryProvider : IApiContentQueryProvider public ApiContentQueryProvider( IExamineManager examineManager, ContentIndexHandlerCollection indexHandlers, + IOptions deliveryApiSettings, ILogger logger) { _examineManager = examineManager; + _deliveryApiSettings = deliveryApiSettings.Value; _logger = logger; // A fallback value is needed for Examine queries in case we don't have a value - we can't pass null or empty string @@ -41,7 +47,27 @@ internal sealed class ApiContentQueryProvider : IApiContentQueryProvider .ToDictionary(field => field.FieldName, field => field.FieldType, StringComparer.InvariantCultureIgnoreCase); } - public PagedModel ExecuteQuery(SelectorOption selectorOption, IList filterOptions, IList sortOptions, string culture, bool preview, int skip, int take) + [Obsolete($"Use the {nameof(ExecuteQuery)} method that accepts {nameof(ProtectedAccess)}. Will be removed in V14.")] + public PagedModel ExecuteQuery( + SelectorOption selectorOption, + IList filterOptions, + IList sortOptions, + string culture, + bool preview, + int skip, + int take) + => ExecuteQuery(selectorOption, filterOptions, sortOptions, culture, ProtectedAccess.None, preview, skip, take); + + /// + public PagedModel ExecuteQuery( + SelectorOption selectorOption, + IList filterOptions, + IList sortOptions, + string culture, + ProtectedAccess protectedAccess, + bool preview, + int skip, + int take) { if (!_examineManager.TryGetIndex(Constants.UmbracoIndexes.DeliveryApiContentIndexName, out IIndex? index)) { @@ -49,7 +75,7 @@ internal sealed class ApiContentQueryProvider : IApiContentQueryProvider return new PagedModel(); } - IBooleanOperation queryOperation = BuildSelectorOperation(selectorOption, index, culture, preview); + IBooleanOperation queryOperation = BuildSelectorOperation(selectorOption, index, culture, protectedAccess, preview); ApplyFiltering(filterOptions, queryOperation); ApplySorting(sortOptions, queryOperation); @@ -77,7 +103,7 @@ internal sealed class ApiContentQueryProvider : IApiContentQueryProvider FieldName = UmbracoExamineFieldNames.CategoryFieldName, Values = new[] { "content" } }; - private IBooleanOperation BuildSelectorOperation(SelectorOption selectorOption, IIndex index, string culture, bool preview) + private IBooleanOperation BuildSelectorOperation(SelectorOption selectorOption, IIndex index, string culture, ProtectedAccess protectedAccess, bool preview) { // Needed for enabling leading wildcards searches BaseLuceneSearcher searcher = index.Searcher as BaseLuceneSearcher ?? throw new InvalidOperationException($"Index searcher must be of type {nameof(BaseLuceneSearcher)}."); @@ -92,8 +118,12 @@ internal sealed class ApiContentQueryProvider : IApiContentQueryProvider ? query.Field(selectorOption.FieldName, selectorOption.Values.First()) : query.GroupedOr(new[] { selectorOption.FieldName }, selectorOption.Values); - // Item culture must be either the requested culture or "none" - selectorOperation.And().GroupedOr(new[] { UmbracoExamineFieldNames.DeliveryApiContentIndex.Culture }, culture.ToLowerInvariant().IfNullOrWhiteSpace(_fallbackGuidValue), "none"); + AddCultureQuery(culture, selectorOperation); + + if (_deliveryApiSettings.MemberAuthorizationIsEnabled()) + { + AddProtectedAccessQuery(protectedAccess, selectorOperation); + } // when not fetching for preview, make sure the "published" field is "y" if (preview is false) @@ -104,6 +134,45 @@ internal sealed class ApiContentQueryProvider : IApiContentQueryProvider return selectorOperation; } + private void AddCultureQuery(string culture, IBooleanOperation selectorOperation) => + selectorOperation + .And() + .GroupedOr( + // Item culture must be either the requested culture or "none" + new[] { UmbracoExamineFieldNames.DeliveryApiContentIndex.Culture }, + culture.ToLowerInvariant().IfNullOrWhiteSpace(_fallbackGuidValue), + "none"); + + private void AddProtectedAccessQuery(ProtectedAccess protectedAccess, IBooleanOperation selectorOperation) + { + var protectedAccessValues = new List(); + if (protectedAccess.MemberKey is not null) + { + protectedAccessValues.Add($"u:{protectedAccess.MemberKey}"); + } + + if (protectedAccess.MemberRoles?.Any() is true) + { + protectedAccessValues.AddRange(protectedAccess.MemberRoles.Select(r => $"r:{r}")); + } + + if (protectedAccessValues.Any()) + { + selectorOperation.And( + inner => inner + .Field(UmbracoExamineFieldNames.DeliveryApiContentIndex.Protected, "n") + .Or(protectedAccessInner => protectedAccessInner + .GroupedOr( + new[] { UmbracoExamineFieldNames.DeliveryApiContentIndex.ProtectedAccess }, + protectedAccessValues.ToArray())), + BooleanOperation.Or); + } + else + { + selectorOperation.And().Field(UmbracoExamineFieldNames.DeliveryApiContentIndex.Protected, "n"); + } + } + private void ApplyFiltering(IList filterOptions, IBooleanOperation queryOperation) { void HandleExact(IQuery query, string fieldName, string[] values) diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryService.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryService.cs index dc03a79e19..a81ae211ed 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiContentQueryService.cs @@ -2,6 +2,7 @@ using Umbraco.Cms.Api.Delivery.Indexing.Selectors; using Umbraco.Cms.Core; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Extensions; @@ -36,8 +37,23 @@ internal sealed class ApiContentQueryService : IApiContentQueryService _requestPreviewService = requestPreviewService; } + [Obsolete($"Use the {nameof(ExecuteQuery)} method that accepts {nameof(ProtectedAccess)}. Will be removed in V14.")] + public Attempt, ApiContentQueryOperationStatus> ExecuteQuery( + string? fetch, + IEnumerable filters, + IEnumerable sorts, + int skip, + int take) + => ExecuteQuery(fetch, filters, sorts, ProtectedAccess.None, skip, take); + /// - public Attempt, ApiContentQueryOperationStatus> ExecuteQuery(string? fetch, IEnumerable filters, IEnumerable sorts, int skip, int take) + public Attempt, ApiContentQueryOperationStatus> ExecuteQuery( + string? fetch, + IEnumerable filters, + IEnumerable sorts, + ProtectedAccess protectedAccess, + int skip, + int take) { var emptyResult = new PagedModel(); @@ -77,7 +93,7 @@ internal sealed class ApiContentQueryService : IApiContentQueryService var culture = _variationContextAccessor.VariationContext?.Culture ?? string.Empty; var isPreview = _requestPreviewService.IsPreview(); - PagedModel result = _apiContentQueryProvider.ExecuteQuery(selectorOption, filterOptions, sortOptions, culture, isPreview, skip, take); + PagedModel result = _apiContentQueryProvider.ExecuteQuery(selectorOption, filterOptions, sortOptions, culture, protectedAccess, isPreview, skip, take); return Attempt.SucceedWithStatus(ApiContentQueryOperationStatus.Success, result); } diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RequestMemberAccessService.cs b/src/Umbraco.Cms.Api.Delivery/Services/RequestMemberAccessService.cs new file mode 100644 index 0000000000..6920ef6d20 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Services/RequestMemberAccessService.cs @@ -0,0 +1,84 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Validation.AspNetCore; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Services; + +internal sealed class RequestMemberAccessService : IRequestMemberAccessService +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IPublicAccessService _publicAccessService; + private readonly IPublicAccessChecker _publicAccessChecker; + private readonly DeliveryApiSettings _deliveryApiSettings; + + public RequestMemberAccessService( + IHttpContextAccessor httpContextAccessor, + IPublicAccessService publicAccessService, + IPublicAccessChecker publicAccessChecker, + IOptions deliveryApiSettings) + { + _httpContextAccessor = httpContextAccessor; + _publicAccessService = publicAccessService; + _publicAccessChecker = publicAccessChecker; + + _deliveryApiSettings = deliveryApiSettings.Value; + } + + public async Task MemberHasAccessToAsync(IPublishedContent content) + { + PublicAccessEntry? publicAccessEntry = _publicAccessService.GetEntryForContent(content.Path); + if (publicAccessEntry is null) + { + return PublicAccessStatus.AccessAccepted; + } + + ClaimsPrincipal? requestPrincipal = await GetRequestPrincipal(); + if (requestPrincipal is null) + { + return PublicAccessStatus.NotLoggedIn; + } + + return await _publicAccessChecker.HasMemberAccessToContentAsync(content.Id, requestPrincipal); + } + + public async Task MemberAccessAsync() + { + ClaimsPrincipal? requestPrincipal = await GetRequestPrincipal(); + return new ProtectedAccess(MemberKey(requestPrincipal), MemberRoles(requestPrincipal)); + } + + private async Task GetRequestPrincipal() + { + // exit fast if no member authorization is enabled whatsoever + if (_deliveryApiSettings.MemberAuthorizationIsEnabled() is false) + { + return null; + } + + HttpContext httpContext = _httpContextAccessor.GetRequiredHttpContext(); + AuthenticateResult result = await httpContext.AuthenticateAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); + return result.Succeeded + ? result.Principal + : null; + } + + private static Guid? MemberKey(ClaimsPrincipal? claimsPrincipal) + => claimsPrincipal is not null && Guid.TryParse(claimsPrincipal.GetClaim(Constants.OAuthClaims.MemberKey), out Guid memberKey) + ? memberKey + : null; + + private static string[]? MemberRoles(ClaimsPrincipal? claimsPrincipal) + => claimsPrincipal?.GetClaim(Constants.OAuthClaims.MemberRoles)?.Split(Constants.CharArrays.Comma); +} diff --git a/src/Umbraco.Cms.Api.Management/Configuration/ConfigureUmbracoManagementApiSwaggerGenOptions.cs b/src/Umbraco.Cms.Api.Management/Configuration/ConfigureUmbracoManagementApiSwaggerGenOptions.cs index cbf954e41d..616db6c89b 100644 --- a/src/Umbraco.Cms.Api.Management/Configuration/ConfigureUmbracoManagementApiSwaggerGenOptions.cs +++ b/src/Umbraco.Cms.Api.Management/Configuration/ConfigureUmbracoManagementApiSwaggerGenOptions.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; using Umbraco.Cms.Api.Common.Serialization; -using Umbraco.Cms.Api.Management.Controllers.Security; using Umbraco.Cms.Api.Management.DependencyInjection; using Umbraco.Cms.Api.Management.OpenApi; @@ -48,8 +47,8 @@ public class ConfigureUmbracoManagementApiSwaggerGenOptions : IConfigureOptions< AuthorizationCode = new OpenApiOAuthFlow { AuthorizationUrl = - new Uri(Paths.BackOfficeApiAuthorizationEndpoint, UriKind.Relative), - TokenUrl = new Uri(Paths.BackOfficeApiTokenEndpoint, UriKind.Relative) + new Uri(Common.Security.Paths.BackOfficeApi.AuthorizationEndpoint, UriKind.Relative), + TokenUrl = new Uri(Common.Security.Paths.BackOfficeApi.TokenEndpoint, UriKind.Relative) } } }); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs index b61a16adec..7f29abd7d6 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs @@ -22,7 +22,8 @@ namespace Umbraco.Cms.Api.Management.Controllers.Security; [ApiVersion("1.0")] [ApiController] -[VersionedApiBackOfficeRoute(Paths.BackOfficeApiEndpointTemplate)] +[VersionedApiBackOfficeRoute(Common.Security.Paths.BackOfficeApi.EndpointTemplate)] +[ApiExplorerSettings(IgnoreApi = true)] public class BackOfficeController : SecurityControllerBase { private readonly IHttpContextAccessor _httpContextAccessor; @@ -81,6 +82,12 @@ public class BackOfficeController : SecurityControllerBase return BadRequest("Unable to obtain OpenID data from the current request"); } + // make sure we keep member authentication away from this endpoint + if (request.ClientId is Constants.OAuthClientIds.Member) + { + return BadRequest("The specified client ID cannot be used here."); + } + return request.IdentityProvider.IsNullOrWhiteSpace() ? await AuthorizeInternal(request) : await AuthorizeExternal(request); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Security/Paths.cs b/src/Umbraco.Cms.Api.Management/Controllers/Security/Paths.cs deleted file mode 100644 index ee527c85e2..0000000000 --- a/src/Umbraco.Cms.Api.Management/Controllers/Security/Paths.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Umbraco.Cms.Api.Management.Controllers.Security; - -public static class Paths -{ - public const string BackOfficeApiEndpointTemplate = "security/back-office"; - - public static readonly string BackOfficeApiAuthorizationEndpoint = BackOfficeApiEndpointPath($"{BackOfficeApiEndpointTemplate}/authorize"); - - public static readonly string BackOfficeApiTokenEndpoint = BackOfficeApiEndpointPath($"{BackOfficeApiEndpointTemplate}/token"); - - private static string BackOfficeApiEndpointPath(string relativePath) => $"/umbraco/management/api/v1.0/{relativePath}"; -} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs index e8c2257be4..bc642d7163 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs @@ -1,10 +1,10 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Common.DependencyInjection; using Umbraco.Cms.Core; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Api.Management.Middleware; using Umbraco.Cms.Api.Management.Security; -using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Cms.Infrastructure.Security; using Umbraco.Cms.Web.Common.ApplicationBuilder; @@ -15,85 +15,22 @@ public static class BackOfficeAuthBuilderExtensions public static IUmbracoBuilder AddBackOfficeAuthentication(this IUmbracoBuilder builder) { builder - .AddOpenIddict() + .AddAuthentication() + .AddUmbracoOpenIddict() .AddBackOfficeLogin(); return builder; } - private static IUmbracoBuilder AddOpenIddict(this IUmbracoBuilder builder) + private static IUmbracoBuilder AddAuthentication(this IUmbracoBuilder builder) { builder.Services.AddAuthentication(); builder.AddAuthorizationPolicies(); - builder.Services.AddOpenIddict() - // Register the OpenIddict server components. - .AddServer(options => - { - // Enable the authorization and token endpoints. - options - .SetAuthorizationEndpointUris(Controllers.Security.Paths.BackOfficeApiAuthorizationEndpoint.TrimStart('/')) - .SetTokenEndpointUris(Controllers.Security.Paths.BackOfficeApiTokenEndpoint.TrimStart('/')); - - // Enable authorization code flow with PKCE - options - .AllowAuthorizationCodeFlow() - .RequireProofKeyForCodeExchange() - .AllowRefreshTokenFlow(); - - // Register the encryption and signing credentials. - // - see https://documentation.openiddict.com/configuration/encryption-and-signing-credentials.html - options - // TODO: use actual certificates here, see docs above - .AddDevelopmentEncryptionCertificate() - .AddDevelopmentSigningCertificate() - .DisableAccessTokenEncryption(); - - // Register the ASP.NET Core host and configure for custom authentication endpoint. - options - .UseAspNetCore() - .EnableAuthorizationEndpointPassthrough(); - - // Enable reference tokens - // - see https://documentation.openiddict.com/configuration/token-storage.html - options - .UseReferenceAccessTokens() - .UseReferenceRefreshTokens(); - - // Use ASP.NET Core Data Protection for tokens instead of JWT. - // This is more secure, and has the added benefit of having a high throughput - // but means that all servers (such as in a load balanced setup) - // needs to use the same application name and key ring, - // however this is already recommended for load balancing, so should be fine. - // See https://documentation.openiddict.com/configuration/token-formats.html#switching-to-data-protection-tokens - // and https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview?view=aspnetcore-7.0 - // for more information - options.UseDataProtection(); - }) - - // Register the OpenIddict validation components. - .AddValidation(options => - { - // Import the configuration from the local OpenIddict server instance. - options.UseLocalServer(); - - // Register the ASP.NET Core host. - options.UseAspNetCore(); - - // Enable token entry validation - // - see https://documentation.openiddict.com/configuration/token-storage.html#enabling-token-entry-validation-at-the-api-level - options.EnableTokenEntryValidation(); - - // Use ASP.NET Core Data Protection for tokens instead of JWT. (see note in AddServer) - options.UseDataProtection(); - }); - builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.Configure(options => options.AddFilter(new BackofficePipelineFilter("Backoffice"))); - builder.Services.AddHostedService(); - return builder; } diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 595aff5428..beb4bfc5ee 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -12146,63 +12146,6 @@ ] } }, - "/umbraco/management/api/v1/security/back-office/authorize": { - "get": { - "tags": [ - "Security" - ], - "operationId": "GetSecurityBackOfficeAuthorize", - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/umbraco/management/api/v1/security/back-office/login": { - "post": { - "tags": [ - "Security" - ], - "operationId": "PostSecurityBackOfficeLogin", - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/LoginRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/LoginRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/LoginRequestModel" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "Success" - } - } - } - }, "/umbraco/management/api/v1/security/forgot-password": { "post": { "tags": [ @@ -19679,18 +19622,6 @@ }, "additionalProperties": false }, - "LoginRequestModel": { - "type": "object", - "properties": { - "username": { - "type": "string" - }, - "password": { - "type": "string" - } - }, - "additionalProperties": false - }, "MediaItemResponseModel": { "type": "object", "allOf": [ @@ -23182,12 +23113,12 @@ "description": "Umbraco Authentication", "flows": { "authorizationCode": { - "authorizationUrl": "/umbraco/management/api/v1.0/security/back-office/authorize", - "tokenUrl": "/umbraco/management/api/v1.0/security/back-office/token", + "authorizationUrl": "/umbraco/management/api/v1/security/back-office/authorize", + "tokenUrl": "/umbraco/management/api/v1/security/back-office/token", "scopes": { } } } } } } -} +} \ No newline at end of file diff --git a/src/Umbraco.Cms.Api.Management/Security/BackOfficeApplicationManager.cs b/src/Umbraco.Cms.Api.Management/Security/BackOfficeApplicationManager.cs index 615515a423..3fbd3115eb 100644 --- a/src/Umbraco.Cms.Api.Management/Security/BackOfficeApplicationManager.cs +++ b/src/Umbraco.Cms.Api.Management/Security/BackOfficeApplicationManager.cs @@ -9,9 +9,8 @@ using Umbraco.Cms.Infrastructure.Security; namespace Umbraco.Cms.Api.Management.Security; -public class BackOfficeApplicationManager : IBackOfficeApplicationManager +public class BackOfficeApplicationManager : OpenIdDictApplicationManagerBase, IBackOfficeApplicationManager { - private readonly IOpenIddictApplicationManager _applicationManager; private readonly IWebHostEnvironment _webHostEnvironment; private readonly IRuntimeState _runtimeState; private readonly Uri? _backOfficeHost; @@ -22,8 +21,8 @@ public class BackOfficeApplicationManager : IBackOfficeApplicationManager IWebHostEnvironment webHostEnvironment, IOptions securitySettings, IRuntimeState runtimeState) + : base(applicationManager) { - _applicationManager = applicationManager; _webHostEnvironment = webHostEnvironment; _runtimeState = runtimeState; _backOfficeHost = securitySettings.Value.BackOfficeHost; @@ -46,7 +45,7 @@ public class BackOfficeApplicationManager : IBackOfficeApplicationManager new OpenIddictApplicationDescriptor { DisplayName = "Umbraco back-office access", - ClientId = Constants.OauthClientIds.BackOffice, + ClientId = Constants.OAuthClientIds.BackOffice, RedirectUris = { CallbackUrlFor(_backOfficeHost ?? backOfficeUrl, _authorizeCallbackPathName ?? "/umbraco") @@ -65,8 +64,8 @@ public class BackOfficeApplicationManager : IBackOfficeApplicationManager if (_webHostEnvironment.IsProduction()) { - await Delete(Constants.OauthClientIds.Swagger, cancellationToken); - await Delete(Constants.OauthClientIds.Postman, cancellationToken); + await Delete(Constants.OAuthClientIds.Swagger, cancellationToken); + await Delete(Constants.OAuthClientIds.Postman, cancellationToken); } else { @@ -74,7 +73,7 @@ public class BackOfficeApplicationManager : IBackOfficeApplicationManager new OpenIddictApplicationDescriptor { DisplayName = "Umbraco Swagger access", - ClientId = Constants.OauthClientIds.Swagger, + ClientId = Constants.OAuthClientIds.Swagger, RedirectUris = { CallbackUrlFor(backOfficeUrl, "/umbraco/swagger/oauth2-redirect.html") @@ -94,7 +93,7 @@ public class BackOfficeApplicationManager : IBackOfficeApplicationManager new OpenIddictApplicationDescriptor { DisplayName = "Umbraco Postman access", - ClientId = Constants.OauthClientIds.Postman, + ClientId = Constants.OAuthClientIds.Postman, RedirectUris = { new Uri("https://oauth.pstmn.io/v1/callback"), new Uri("https://oauth.pstmn.io/v1/browser-callback") @@ -112,31 +111,5 @@ public class BackOfficeApplicationManager : IBackOfficeApplicationManager } } - private async Task CreateOrUpdate(OpenIddictApplicationDescriptor clientDescriptor, CancellationToken cancellationToken) - { - var identifier = clientDescriptor.ClientId ?? - throw new ApplicationException($"ClientId is missing for application: {clientDescriptor.DisplayName ?? "(no name)"}"); - var client = await _applicationManager.FindByClientIdAsync(identifier, cancellationToken); - if (client is null) - { - await _applicationManager.CreateAsync(clientDescriptor, cancellationToken); - } - else - { - await _applicationManager.UpdateAsync(client, clientDescriptor, cancellationToken); - } - } - - private async Task Delete(string identifier, CancellationToken cancellationToken) - { - var client = await _applicationManager.FindByClientIdAsync(identifier, cancellationToken); - if (client is null) - { - return; - } - - await _applicationManager.DeleteAsync(client, cancellationToken); - } - - private static Uri CallbackUrlFor(Uri url, string relativePath) => new Uri( $"{url.GetLeftPart(UriPartial.Authority)}/{relativePath.TrimStart(Core.Constants.CharArrays.ForwardSlash)}"); + private static Uri CallbackUrlFor(Uri url, string relativePath) => new Uri( $"{url.GetLeftPart(UriPartial.Authority)}/{relativePath.TrimStart(Constants.CharArrays.ForwardSlash)}"); } diff --git a/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs b/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs index 69b1943c60..c81102b8d2 100644 --- a/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs @@ -54,6 +54,19 @@ public class DeliveryApiSettings /// public MediaSettings Media { get; set; } = new (); + /// + /// Gets or sets the member authorization settings for the Delivery API. + /// + public MemberAuthorizationSettings? MemberAuthorization { get; set; } = null; + + /// + /// Gets a value indicating if any member authorization type is enabled for the Delivery API. + /// + /// + /// This method is intended for future extension - see remark in . + /// + public bool MemberAuthorizationIsEnabled() => MemberAuthorization?.AuthorizationCodeFlow?.Enabled is true; + /// /// Typed configuration options for the Media APIs of the Delivery API. /// @@ -84,4 +97,45 @@ public class DeliveryApiSettings [DefaultValue(StaticPublicAccess)] public bool PublicAccess { get; set; } = StaticPublicAccess; } + + /// + /// Typed configuration options for member authorization settings for the Delivery API. + /// + /// + /// This class is intended for future extension, if/when adding support for additional + /// authorization flows (i.e. non-interactive authorization flows). + /// + public class MemberAuthorizationSettings + { + /// + /// Gets or sets the Authorization Code Flow configuration for the Delivery API. + /// + public AuthorizationCodeFlowSettings? AuthorizationCodeFlow { get; set; } = null; + } + + /// + /// Typed configuration options for the Authorization Code Flow settings for the Delivery API. + /// + public class AuthorizationCodeFlowSettings + { + /// + /// Gets or sets a value indicating whether Authorization Code Flow should be enabled for the Delivery API. + /// + /// true if Authorization Code Flow should be enabled; otherwise, false. + [DefaultValue(StaticEnabled)] + public bool Enabled { get; set; } = StaticEnabled; + + /// + /// Gets or sets the URLs allowed to use as redirect targets after a successful login (session authorization). + /// + /// The URLs allowed as redirect targets. + public Uri[] LoginRedirectUrls { get; set; } = Array.Empty(); + + /// + /// Gets or sets the URLs allowed to use as redirect targets after a successful logout (session termination). + /// + /// The URLs allowed as redirect targets. + /// These are only required if logout is to be used. + public Uri[] LogoutRedirectUrls { get; set; } = Array.Empty(); + } } diff --git a/src/Umbraco.Core/Constants-OAuthClaims.cs b/src/Umbraco.Core/Constants-OAuthClaims.cs new file mode 100644 index 0000000000..5a777236cd --- /dev/null +++ b/src/Umbraco.Core/Constants-OAuthClaims.cs @@ -0,0 +1,17 @@ +namespace Umbraco.Cms.Core; + +public static partial class Constants +{ + public static class OAuthClaims + { + /// + /// Key for authenticated member. + /// + public const string MemberKey = "umbraco-member-key"; + + /// + /// Roles for authenticated member. + /// + public const string MemberRoles = "umbraco-member-roles"; + } +} diff --git a/src/Umbraco.Core/Constants-OauthClientIds.cs b/src/Umbraco.Core/Constants-OAuthClientIds.cs similarity index 52% rename from src/Umbraco.Core/Constants-OauthClientIds.cs rename to src/Umbraco.Core/Constants-OAuthClientIds.cs index 91056a9699..8d5ef7610f 100644 --- a/src/Umbraco.Core/Constants-OauthClientIds.cs +++ b/src/Umbraco.Core/Constants-OAuthClientIds.cs @@ -1,23 +1,27 @@ namespace Umbraco.Cms.Core; -// TODO: move this class to Umbraco.Cms.Core as a partial class public static partial class Constants { - public static partial class OauthClientIds + public static class OAuthClientIds { /// - /// Client ID used for default back-office access + /// Client ID used for default back-office access. /// public const string BackOffice = "umbraco-back-office"; /// - /// Client ID used for Swagger API access + /// Client ID used for Swagger API access. /// public const string Swagger = "umbraco-swagger"; /// - /// Client ID used for Postman API access + /// Client ID used for Postman API access. /// public const string Postman = "umbraco-postman"; + + /// + /// Client ID used for member access. + /// + public const string Member = "umbraco-member"; } } diff --git a/src/Umbraco.Core/DeliveryApi/IApiContentQueryProvider.cs b/src/Umbraco.Core/DeliveryApi/IApiContentQueryProvider.cs index feb3a3a74b..c0fed51c4e 100644 --- a/src/Umbraco.Core/DeliveryApi/IApiContentQueryProvider.cs +++ b/src/Umbraco.Core/DeliveryApi/IApiContentQueryProvider.cs @@ -1,4 +1,5 @@ using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.DeliveryApi; namespace Umbraco.Cms.Core.DeliveryApi; @@ -7,6 +8,16 @@ namespace Umbraco.Cms.Core.DeliveryApi; /// public interface IApiContentQueryProvider { + [Obsolete($"Use the {nameof(ExecuteQuery)} method that accepts {nameof(ProtectedAccess)}. Will be removed in V14.")] + PagedModel ExecuteQuery( + SelectorOption selectorOption, + IList filterOptions, + IList sortOptions, + string culture, + bool preview, + int skip, + int take); + /// /// Returns a page of item ids that passed the search criteria. /// @@ -15,10 +26,19 @@ public interface IApiContentQueryProvider /// The sorting options of the search criteria. /// The requested culture. /// Whether or not to search for preview content. + /// Defines the limitations for querying protected content. /// Number of search results to skip (for pagination). /// Number of search results to retrieve (for pagination). /// A paged model containing the resulting IDs and the total number of results that matching the search criteria. - PagedModel ExecuteQuery(SelectorOption selectorOption, IList filterOptions, IList sortOptions, string culture, bool preview, int skip, int take); + PagedModel ExecuteQuery( + SelectorOption selectorOption, + IList filterOptions, + IList sortOptions, + string culture, + ProtectedAccess protectedAccess, + bool preview, + int skip, + int take); /// /// Returns a selector option that can be applied to fetch "all content" (i.e. if a selector option is not present when performing a search). diff --git a/src/Umbraco.Core/DeliveryApi/IApiContentQueryService.cs b/src/Umbraco.Core/DeliveryApi/IApiContentQueryService.cs index 4a01cd926c..8d4b3dbac0 100644 --- a/src/Umbraco.Core/DeliveryApi/IApiContentQueryService.cs +++ b/src/Umbraco.Core/DeliveryApi/IApiContentQueryService.cs @@ -1,4 +1,5 @@ using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.DeliveryApi; @@ -8,6 +9,9 @@ namespace Umbraco.Cms.Core.DeliveryApi; /// public interface IApiContentQueryService { + [Obsolete($"Use the {nameof(ExecuteQuery)} method that accepts {nameof(ProtectedAccess)}. Will be removed in V14.")] + Attempt, ApiContentQueryOperationStatus> ExecuteQuery(string? fetch, IEnumerable filters, IEnumerable sorts, int skip, int take); + /// /// Returns an attempt with a collection of item ids that passed the search criteria as a paged model. /// @@ -16,6 +20,7 @@ public interface IApiContentQueryService /// Optional sort query parameters values. /// The amount of items to skip. /// The amount of items to take. + /// Defines the limitations for querying protected content. /// A paged model of item ids that are returned after applying the search queries in an attempt. - Attempt, ApiContentQueryOperationStatus> ExecuteQuery(string? fetch, IEnumerable filters, IEnumerable sorts, int skip, int take); + Attempt, ApiContentQueryOperationStatus> ExecuteQuery(string? fetch, IEnumerable filters, IEnumerable sorts, ProtectedAccess protectedAccess, int skip, int take); } diff --git a/src/Umbraco.Core/DeliveryApi/IRequestMemberAccessService.cs b/src/Umbraco.Core/DeliveryApi/IRequestMemberAccessService.cs new file mode 100644 index 0000000000..9d43a15a7a --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IRequestMemberAccessService.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IRequestMemberAccessService +{ + Task MemberHasAccessToAsync(IPublishedContent content); + + Task MemberAccessAsync(); +} diff --git a/src/Umbraco.Core/DeliveryApi/NoopApiContentQueryService.cs b/src/Umbraco.Core/DeliveryApi/NoopApiContentQueryService.cs index d8dda6313c..49cf782d40 100644 --- a/src/Umbraco.Core/DeliveryApi/NoopApiContentQueryService.cs +++ b/src/Umbraco.Core/DeliveryApi/NoopApiContentQueryService.cs @@ -1,11 +1,16 @@ using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.DeliveryApi; public sealed class NoopApiContentQueryService : IApiContentQueryService { - /// + [Obsolete($"Use the {nameof(ExecuteQuery)} method that accepts {nameof(ProtectedAccess)}. Will be removed in V14.")] public Attempt, ApiContentQueryOperationStatus> ExecuteQuery(string? fetch, IEnumerable filters, IEnumerable sorts, int skip, int take) => Attempt.SucceedWithStatus(ApiContentQueryOperationStatus.Success, new PagedModel()); + + /// + public Attempt, ApiContentQueryOperationStatus> ExecuteQuery(string? fetch, IEnumerable filters, IEnumerable sorts, ProtectedAccess protectedAccess, int skip, int take) + => Attempt.SucceedWithStatus(ApiContentQueryOperationStatus.Success, new PagedModel()); } diff --git a/src/Umbraco.Core/DeliveryApi/NoopRequestMemberAccessService.cs b/src/Umbraco.Core/DeliveryApi/NoopRequestMemberAccessService.cs new file mode 100644 index 0000000000..ce282bb455 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/NoopRequestMemberAccessService.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public sealed class NoopRequestMemberAccessService : IRequestMemberAccessService +{ + public Task MemberHasAccessToAsync(IPublishedContent content) => Task.FromResult(PublicAccessStatus.AccessAccepted); + + public Task MemberAccessAsync() => Task.FromResult(ProtectedAccess.None); +} diff --git a/src/Umbraco.Core/Models/DeliveryApi/ProtectedAccess.cs b/src/Umbraco.Core/Models/DeliveryApi/ProtectedAccess.cs new file mode 100644 index 0000000000..f87db9d795 --- /dev/null +++ b/src/Umbraco.Core/Models/DeliveryApi/ProtectedAccess.cs @@ -0,0 +1,16 @@ +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public sealed class ProtectedAccess +{ + public static ProtectedAccess None => new(null, null); + + public ProtectedAccess(Guid? memberKey, string[]? memberRoles) + { + MemberKey = memberKey; + MemberRoles = memberRoles; + } + + public Guid? MemberKey { get; } + + public string[]? MemberRoles { get; } +} diff --git a/src/Umbraco.Core/Security/IPublicAccessChecker.cs b/src/Umbraco.Core/Security/IPublicAccessChecker.cs index d830d757f1..82957b37de 100644 --- a/src/Umbraco.Core/Security/IPublicAccessChecker.cs +++ b/src/Umbraco.Core/Security/IPublicAccessChecker.cs @@ -1,6 +1,21 @@ +using System.Security.Claims; + namespace Umbraco.Cms.Core.Security; public interface IPublicAccessChecker { + /// + /// Tests the current member access level to a given content item. + /// + /// The ID of the content item. + /// The access level for the content item. Task HasMemberAccessToContentAsync(int publishedContentId); + + /// + /// Tests member access level to a given content item. + /// + /// The ID of the content item. + /// The member claims to test against the content item. + /// The access level for the content item. + Task HasMemberAccessToContentAsync(int publishedContentId, ClaimsPrincipal claimsPrincipal) => Task.FromResult(PublicAccessStatus.AccessDenied); } diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index f9ff67a1f5..b7a69f4323 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -440,6 +440,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexDeferredBase.cs b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexDeferredBase.cs index cd9b0b85b0..b339bfa21e 100644 --- a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexDeferredBase.cs +++ b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexDeferredBase.cs @@ -18,6 +18,6 @@ internal abstract class DeliveryApiContentIndexDeferredBase } // NOTE: the delivery api index implementation takes care of deleting descendants, so we don't have to do that here - index.DeleteFromIndex(ids.Select(id => id.ToString())); + index.DeleteFromIndex(ids); } } diff --git a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandlePublicAccessChanges.cs b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandlePublicAccessChanges.cs index e5db4b6f1e..01449f37a6 100644 --- a/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandlePublicAccessChanges.cs +++ b/src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandlePublicAccessChanges.cs @@ -1,5 +1,7 @@ using Examine; using Examine.Search; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Extensions; @@ -10,49 +12,126 @@ internal sealed class DeliveryApiContentIndexHandlePublicAccessChanges : Deliver { private readonly IPublicAccessService _publicAccessService; private readonly DeliveryApiIndexingHandler _deliveryApiIndexingHandler; + private readonly IDeliveryApiContentIndexHelper _deliveryApiContentIndexHelper; private readonly IBackgroundTaskQueue _backgroundTaskQueue; + private readonly DeliveryApiSettings _deliveryApiSettings; + private readonly IContentService _contentService; + private readonly IDeliveryApiContentIndexValueSetBuilder _deliveryApiContentIndexValueSetBuilder; public DeliveryApiContentIndexHandlePublicAccessChanges( IPublicAccessService publicAccessService, DeliveryApiIndexingHandler deliveryApiIndexingHandler, + IContentService contentService, + IDeliveryApiContentIndexValueSetBuilder deliveryApiContentIndexValueSetBuilder, + IDeliveryApiContentIndexHelper deliveryApiContentIndexHelper, + DeliveryApiSettings deliveryApiSettings, IBackgroundTaskQueue backgroundTaskQueue) { _publicAccessService = publicAccessService; _deliveryApiIndexingHandler = deliveryApiIndexingHandler; + _contentService = contentService; + _deliveryApiContentIndexValueSetBuilder = deliveryApiContentIndexValueSetBuilder; + _deliveryApiContentIndexHelper = deliveryApiContentIndexHelper; + _deliveryApiSettings = deliveryApiSettings; _backgroundTaskQueue = backgroundTaskQueue; } + // NOTE: at the time of implementing this, the distributed notifications for public access changes only ever + // sends out "refresh all" notifications, which means we can't be clever about minimizing the work + // effort to handle public access changes. instead we have to grab all protected content definitions + // and handle every last one with every notification. public void Execute() => _backgroundTaskQueue.QueueBackgroundWorkItem(_ => { - // NOTE: at the time of implementing this, the distributed notifications for public access changes only ever - // sends out "refresh all" notifications, which means we can't be clever about minimizing the work - // effort to handle public access changes. instead we have to grab all protected content definitions - // and handle every last one with every notification. + IIndex index = _deliveryApiIndexingHandler.GetIndex() ?? + throw new InvalidOperationException("Could not obtain the delivery API content index"); - // NOTE: eventually the Delivery API will support protected content, but for now we need to ensure that the - // index does not contain any protected content. this also means that whenever content is unprotected, - // one must trigger a manual republish of said content for it to be re-added to the index. not exactly - // an optimal solution, but it's the best we can do at this point, given the limitations outlined above - // and without prematurely assuming the future implementation details of protected content handling. - - var protectedContentIds = _publicAccessService.GetAll().Select(entry => entry.ProtectedNodeId).ToArray(); - if (protectedContentIds.Any() is false) + if (_deliveryApiSettings.MemberAuthorizationIsEnabled() is false) { + EnsureProtectedContentIsRemovedFromIndex(index); return Task.CompletedTask; } - IIndex index = _deliveryApiIndexingHandler.GetIndex() ?? - throw new InvalidOperationException("Could not obtain the delivery API content index"); + EnsureProtectedContentIsUpToDateInIndex(index); + return Task.CompletedTask; + }); + + private void EnsureProtectedContentIsRemovedFromIndex(IIndex index) + { + var protectedContentIds = _publicAccessService.GetAll().Select(entry => entry.ProtectedNodeId).ToArray(); + if (protectedContentIds.Any() is false) + { + return; + } List indexIds = FindIndexIdsForContentIds(protectedContentIds, index); if (indexIds.Any() is false) { - return Task.CompletedTask; + return; } RemoveFromIndex(indexIds, index); - return Task.CompletedTask; - }); + } + + private void EnsureProtectedContentIsUpToDateInIndex(IIndex index) + { + // first we need to re-index all the content items that are currently known to be protected in the index, + // as their protection might have been revoked or altered. + var protectedContentIdsInIndex = FindContentIdsForProtectedContent(index); + foreach (var contentId in protectedContentIdsInIndex) + { + UpdateIndex(contentId, index); + } + + // then we have to re-index any protected content items that were not part of the first operation. + var unhandledProtectedContentIds = _publicAccessService + .GetAll() + .Select(entry => entry.ProtectedNodeId) + .Except(protectedContentIdsInIndex) + .ToArray(); + + foreach (var contentId in unhandledProtectedContentIds) + { + UpdateIndexWithDescendants(contentId, index); + } + } + + private void UpdateIndexWithDescendants(int contentId, IIndex index) + { + if (UpdateIndex(contentId, index)) + { + _deliveryApiContentIndexHelper.EnumerateApplicableDescendantsForContentIndex( + contentId, + descendants => + { + foreach (IContent descendant in descendants) + { + UpdateIndex(descendant, index); + } + }); + } + } + + private bool UpdateIndex(int contentId, IIndex index) + { + IContent? content = _contentService.GetById(contentId); + return content is not null && UpdateIndex(content, index); + } + + private bool UpdateIndex(IContent content, IIndex index) + { + if (content.Trashed) + { + return false; + } + + ValueSet[] valueSets = _deliveryApiContentIndexValueSetBuilder.GetValueSets(content).ToArray(); + if (valueSets.Any()) + { + index.IndexItems(valueSets); + } + + return true; + } private List FindIndexIdsForContentIds(int[] contentIds, IIndex index) { @@ -87,4 +166,29 @@ internal sealed class DeliveryApiContentIndexHandlePublicAccessChanges : Deliver return ids; } + private int[] FindContentIdsForProtectedContent(IIndex index) + { + const int pageSize = 500; + + var ids = new List(); + + var page = 0; + var total = long.MaxValue; + + while (page * pageSize < total) + { + ISearchResults? results = index.Searcher + .CreateQuery() + .Field(UmbracoExamineFieldNames.DeliveryApiContentIndex.Protected, "y") + .SelectField(UmbracoExamineFieldNames.DeliveryApiContentIndex.Id) + .Execute(QueryOptions.SkipTake(page * pageSize, pageSize)); + total = results.TotalItemCount; + + ids.AddRange(results.Select(result => int.Parse(result[UmbracoExamineFieldNames.DeliveryApiContentIndex.Id]))); + + page++; + } + + return ids.Distinct().ToArray(); + } } diff --git a/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexFieldDefinitionBuilder.cs b/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexFieldDefinitionBuilder.cs index 0baaa4636d..ca9cc5e260 100644 --- a/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexFieldDefinitionBuilder.cs +++ b/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexFieldDefinitionBuilder.cs @@ -36,6 +36,8 @@ internal sealed class DeliveryApiContentIndexFieldDefinitionBuilder : IDeliveryA fieldDefinitions.Add(new(UmbracoExamineFieldNames.DeliveryApiContentIndex.ContentTypeId, FieldDefinitionTypes.Raw)); fieldDefinitions.Add(new(UmbracoExamineFieldNames.DeliveryApiContentIndex.Culture, FieldDefinitionTypes.Raw)); fieldDefinitions.Add(new(UmbracoExamineFieldNames.DeliveryApiContentIndex.Published, FieldDefinitionTypes.Raw)); + fieldDefinitions.Add(new(UmbracoExamineFieldNames.DeliveryApiContentIndex.Protected, FieldDefinitionTypes.Raw)); + fieldDefinitions.Add(new(UmbracoExamineFieldNames.DeliveryApiContentIndex.ProtectedAccess, FieldDefinitionTypes.Raw)); fieldDefinitions.Add(new(UmbracoExamineFieldNames.IndexPathFieldName, FieldDefinitionTypes.Raw)); fieldDefinitions.Add(new(UmbracoExamineFieldNames.NodeNameFieldName, FieldDefinitionTypes.Raw)); } diff --git a/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexValueSetBuilder.cs b/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexValueSetBuilder.cs index 5e4796f3b2..abf19b6bfe 100644 --- a/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexValueSetBuilder.cs +++ b/src/Umbraco.Infrastructure/Examine/DeliveryApiContentIndexValueSetBuilder.cs @@ -1,6 +1,7 @@ using Examine; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Models; @@ -16,6 +17,7 @@ internal sealed class DeliveryApiContentIndexValueSetBuilder : IDeliveryApiConte private readonly IPublicAccessService _publicAccessService; private readonly ILogger _logger; private readonly IDeliveryApiContentIndexFieldDefinitionBuilder _deliveryApiContentIndexFieldDefinitionBuilder; + private readonly IMemberService _memberService; private DeliveryApiSettings _deliveryApiSettings; public DeliveryApiContentIndexValueSetBuilder( @@ -24,12 +26,14 @@ internal sealed class DeliveryApiContentIndexValueSetBuilder : IDeliveryApiConte IPublicAccessService publicAccessService, ILogger logger, IDeliveryApiContentIndexFieldDefinitionBuilder deliveryApiContentIndexFieldDefinitionBuilder, - IOptionsMonitor deliveryApiSettings) + IOptionsMonitor deliveryApiSettings, + IMemberService memberService) { _contentIndexHandlerCollection = contentIndexHandlerCollection; _publicAccessService = publicAccessService; _logger = logger; _deliveryApiContentIndexFieldDefinitionBuilder = deliveryApiContentIndexFieldDefinitionBuilder; + _memberService = memberService; _contentService = contentService; _deliveryApiSettings = deliveryApiSettings.CurrentValue; deliveryApiSettings.OnChange(settings => _deliveryApiSettings = settings); @@ -60,6 +64,13 @@ internal sealed class DeliveryApiContentIndexValueSetBuilder : IDeliveryApiConte [UmbracoExamineFieldNames.NodeNameFieldName] = new object[] { content.GetPublishName(culture) ?? content.GetCultureName(culture) ?? string.Empty }, // primarily needed for backoffice index browsing }; + if (_deliveryApiSettings.MemberAuthorizationIsEnabled()) + { + var protectedAccessValue = ProtectedAccessValue(content, out var isProtected); + indexValues[UmbracoExamineFieldNames.DeliveryApiContentIndex.Protected] = new object[] { isProtected ? "y" : "n" }; // required for querying protected content + indexValues[UmbracoExamineFieldNames.DeliveryApiContentIndex.ProtectedAccess] = protectedAccessValue; // required for querying protected content + } + AddContentIndexHandlerFields(content, culture, fieldDefinitions, indexValues); yield return new ValueSet(DeliveryApiContentIndexUtilites.IndexId(content, indexCulture), IndexTypes.Content, content.ContentType.Alias, indexValues); @@ -112,6 +123,38 @@ internal sealed class DeliveryApiContentIndexValueSetBuilder : IDeliveryApiConte return cultures; } + private string[] ProtectedAccessValue(IContent content, out bool isProtected) + { + PublicAccessEntry? publicAccessEntry = _publicAccessService.GetEntryForContent(content.Path); + isProtected = publicAccessEntry is not null; + + if (publicAccessEntry is null) + { + return Array.Empty(); + } + + return publicAccessEntry + .Rules + // prefix member roles with "r:" and member keys with "u:" for clarity + .Select(r => + { + if (r.RuleValue.IsNullOrWhiteSpace()) + { + return null; + } + + if (r.RuleType is Constants.Conventions.PublicAccess.MemberRoleRuleType) + { + return $"r:{r.RuleValue}"; + } + + IMember? member = _memberService.GetByUsername(r.RuleValue); + return member is not null ? $"u:{member.Key}" : null; + }) + .WhereNotNull() + .ToArray(); + } + private void AddContentIndexHandlerFields(IContent content, string? culture, FieldDefinitionCollection fieldDefinitions, Dictionary> indexValues) { foreach (IContentIndexHandler handler in _contentIndexHandlerCollection) @@ -154,8 +197,8 @@ internal sealed class DeliveryApiContentIndexValueSetBuilder : IDeliveryApiConte return false; } - // is the content protected? - if (_publicAccessService.IsProtected(content.Path).Success) + // is the content protected and Delivery API member authorization disabled? + if (_deliveryApiSettings.MemberAuthorizationIsEnabled() is false && _publicAccessService.IsProtected(content.Path).Success) { return false; } diff --git a/src/Umbraco.Infrastructure/Examine/DeliveryApiIndexingHandler.cs b/src/Umbraco.Infrastructure/Examine/DeliveryApiIndexingHandler.cs index 197ab58be0..750cb4780b 100644 --- a/src/Umbraco.Infrastructure/Examine/DeliveryApiIndexingHandler.cs +++ b/src/Umbraco.Infrastructure/Examine/DeliveryApiIndexingHandler.cs @@ -1,6 +1,8 @@ using Examine; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Changes; @@ -17,6 +19,7 @@ internal sealed class DeliveryApiIndexingHandler : IDeliveryApiIndexingHandler private readonly IExamineManager _examineManager; private readonly ICoreScopeProvider _scopeProvider; private readonly ILogger _logger; + private DeliveryApiSettings _deliveryApiSettings; private readonly Lazy _enabled; // these dependencies are for the deferred handling (we don't want those handlers registered in the DI) @@ -31,6 +34,7 @@ internal sealed class DeliveryApiIndexingHandler : IDeliveryApiIndexingHandler IExamineManager examineManager, ICoreScopeProvider scopeProvider, ILogger logger, + IOptionsMonitor deliveryApiSettings, IContentService contentService, IPublicAccessService publicAccessService, IDeliveryApiContentIndexValueSetBuilder deliveryApiContentIndexValueSetBuilder, @@ -47,6 +51,8 @@ internal sealed class DeliveryApiIndexingHandler : IDeliveryApiIndexingHandler _deliveryApiContentIndexHelper = deliveryApiContentIndexHelper; _backgroundTaskQueue = backgroundTaskQueue; _enabled = new Lazy(IsEnabled); + _deliveryApiSettings = deliveryApiSettings.CurrentValue; + deliveryApiSettings.OnChange(settings => _deliveryApiSettings = settings); } /// @@ -83,6 +89,10 @@ internal sealed class DeliveryApiIndexingHandler : IDeliveryApiIndexingHandler var deferred = new DeliveryApiContentIndexHandlePublicAccessChanges( _publicAccessService, this, + _contentService, + _deliveryApiContentIndexValueSetBuilder, + _deliveryApiContentIndexHelper, + _deliveryApiSettings, _backgroundTaskQueue); Execute(deferred); } diff --git a/src/Umbraco.Infrastructure/Examine/UmbracoExamineFieldNames.cs b/src/Umbraco.Infrastructure/Examine/UmbracoExamineFieldNames.cs index 5376e91897..b0cb7c73e8 100644 --- a/src/Umbraco.Infrastructure/Examine/UmbracoExamineFieldNames.cs +++ b/src/Umbraco.Infrastructure/Examine/UmbracoExamineFieldNames.cs @@ -50,5 +50,15 @@ public static class UmbracoExamineFieldNames /// Whether or not the content exists in a published state /// public const string Published = "published"; + + /// + /// Whether or not the content is protected + /// + public const string Protected = "protected"; + + /// + /// The allowed members and member roles (for protected content) + /// + public const string ProtectedAccess = "protectedAccess"; } } diff --git a/src/Umbraco.Infrastructure/Security/IMemberApplicationManager.cs b/src/Umbraco.Infrastructure/Security/IMemberApplicationManager.cs new file mode 100644 index 0000000000..f1533ced62 --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/IMemberApplicationManager.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Infrastructure.Security; + +public interface IMemberApplicationManager +{ + Task EnsureMemberApplicationAsync(IEnumerable loginRedirectUrls, IEnumerable logoutRedirectUrls, CancellationToken cancellationToken = default); + + Task DeleteMemberApplicationAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Umbraco.Infrastructure/Security/OpenIdDictApplicationManagerBase.cs b/src/Umbraco.Infrastructure/Security/OpenIdDictApplicationManagerBase.cs new file mode 100644 index 0000000000..49aa3ef6bd --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/OpenIdDictApplicationManagerBase.cs @@ -0,0 +1,37 @@ +using OpenIddict.Abstractions; + +namespace Umbraco.Cms.Infrastructure.Security; + +public abstract class OpenIdDictApplicationManagerBase +{ + private readonly IOpenIddictApplicationManager _applicationManager; + + protected OpenIdDictApplicationManagerBase(IOpenIddictApplicationManager applicationManager) + => _applicationManager = applicationManager; + + protected async Task CreateOrUpdate(OpenIddictApplicationDescriptor clientDescriptor, CancellationToken cancellationToken) + { + var identifier = clientDescriptor.ClientId ?? + throw new ApplicationException($"ClientId is missing for application: {clientDescriptor.DisplayName ?? "(no name)"}"); + var client = await _applicationManager.FindByClientIdAsync(identifier, cancellationToken); + if (client is null) + { + await _applicationManager.CreateAsync(clientDescriptor, cancellationToken); + } + else + { + await _applicationManager.UpdateAsync(client, clientDescriptor, cancellationToken); + } + } + + protected async Task Delete(string identifier, CancellationToken cancellationToken) + { + var client = await _applicationManager.FindByClientIdAsync(identifier, cancellationToken); + if (client is null) + { + return; + } + + await _applicationManager.DeleteAsync(client, cancellationToken); + } +} diff --git a/src/Umbraco.Web.Common/Security/IMemberSignInManager.cs b/src/Umbraco.Web.Common/Security/IMemberSignInManager.cs index 27d034930d..aed070333c 100644 --- a/src/Umbraco.Web.Common/Security/IMemberSignInManager.cs +++ b/src/Umbraco.Web.Common/Security/IMemberSignInManager.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Umbraco.Cms.Core.Security; @@ -13,7 +14,7 @@ public interface IMemberSignInManager Task SignOutAsync(); - AuthenticationProperties ConfigureExternalAuthenticationProperties(string provider, string redirectUrl, string? userId = null); + AuthenticationProperties ConfigureExternalAuthenticationProperties(string? provider, string? redirectUrl, string? userId = null); Task GetExternalLoginInfoAsync(string? expectedXsrf = null); @@ -24,4 +25,6 @@ public interface IMemberSignInManager Task GetTwoFactorAuthenticationUserAsync(); Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberClient); + + Task CreateUserPrincipalAsync(MemberIdentityUser user) => Task.FromResult(new ClaimsPrincipal()); } diff --git a/src/Umbraco.Web.Common/Security/PublicAccessChecker.cs b/src/Umbraco.Web.Common/Security/PublicAccessChecker.cs index 9777f56d7d..6bb78b7d81 100644 --- a/src/Umbraco.Web.Common/Security/PublicAccessChecker.cs +++ b/src/Umbraco.Web.Common/Security/PublicAccessChecker.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.Security; @@ -19,16 +20,25 @@ public class PublicAccessChecker : IPublicAccessChecker _contentService = contentService; } + /// public async Task HasMemberAccessToContentAsync(int publishedContentId) + => await HasMemberAccessToContentAsync(publishedContentId, httpContext => httpContext.User); + + /// + public async Task HasMemberAccessToContentAsync(int publishedContentId, ClaimsPrincipal claimsPrincipal) + => await HasMemberAccessToContentAsync(publishedContentId, _ => claimsPrincipal); + + private async Task HasMemberAccessToContentAsync(int publishedContentId, Func getClaimsPrincipal) { HttpContext httpContext = _httpContextAccessor.GetRequiredHttpContext(); IMemberManager memberManager = httpContext.RequestServices.GetRequiredService(); - if (httpContext.User.Identity == null || !httpContext.User.Identity.IsAuthenticated) + ClaimsPrincipal claimsPrincipal = getClaimsPrincipal(httpContext); + if (claimsPrincipal.Identity is not { IsAuthenticated: true }) { return PublicAccessStatus.NotLoggedIn; } - MemberIdentityUser? currentMember = await memberManager.GetUserAsync(httpContext.User); + MemberIdentityUser? currentMember = await memberManager.GetUserAsync(claimsPrincipal); if (currentMember == null) { return PublicAccessStatus.NotLoggedIn; diff --git a/tests/Umbraco.Tests.Integration/NewBackoffice/OpenAPIContractTest.cs b/tests/Umbraco.Tests.Integration/NewBackoffice/OpenAPIContractTest.cs index a6c3ba6155..4c3cdfcdf1 100644 --- a/tests/Umbraco.Tests.Integration/NewBackoffice/OpenAPIContractTest.cs +++ b/tests/Umbraco.Tests.Integration/NewBackoffice/OpenAPIContractTest.cs @@ -28,9 +28,6 @@ internal sealed class OpenAPIContractTest : UmbracoTestServerTestBase mvcBuilder.AddApplicationPart(typeof(InstallControllerBase).Assembly); }); - new ManagementApiComposer().Compose(builder); - new UmbracoEFCoreComposer().Compose(builder); - // Currently we cannot do this in tests, as EF Core is not initialized builder.Services.PostConfigure(options => { diff --git a/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs b/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs index a4932bd8d0..0ab4116713 100644 --- a/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs +++ b/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs @@ -244,9 +244,11 @@ namespace Umbraco.Cms.Tests.Integration.TestServerTest .AddUmbracoSqlServerSupport() .AddUmbracoSqliteSupport() .AddDeliveryApi() + .AddComposers() .AddTestServices(TestHelper); // This is the important one! CustomTestSetup(builder); + builder.Build(); } diff --git a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestBase.cs b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestBase.cs index 4f5623b7b0..302ce8b35d 100644 --- a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestBase.cs +++ b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestBase.cs @@ -11,6 +11,8 @@ using NUnit.Framework; using Serilog; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Tests.Common.Testing; @@ -26,7 +28,7 @@ namespace Umbraco.Cms.Tests.Integration.Testing; public abstract class UmbracoIntegrationTestBase { private static readonly object s_dbLocker = new(); - private static ITestDatabase s_dbInstance; + private static ITestDatabase? s_dbInstance; private static TestDbMeta s_fixtureDbMeta; private static int s_testCount = 1; private readonly List _fixtureTeardown = new(); @@ -122,9 +124,16 @@ public abstract class UmbracoIntegrationTestBase var databaseFactory = serviceProvider.GetRequiredService(); var loggerFactory = serviceProvider.GetRequiredService(); var connectionStrings = serviceProvider.GetRequiredService>(); + var eventAggregator = serviceProvider.GetRequiredService(); // This will create a db, install the schema and ensure the app is configured to run SetupTestDatabase(testDatabaseFactoryProvider, connectionStrings, databaseFactory, loggerFactory, state); + + if (TestOptions.Database != UmbracoTestOptions.Database.None) + { + eventAggregator.Publish(new UnattendedInstallNotification()); + } + } private void ConfigureTestDatabaseFactory(