Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add member auth to the Delivery API #14730

Merged
merged 30 commits into from Sep 26, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c0b8052
Refactor OpenIddict for shared usage between APIs + implement member …
kjac Jul 21, 2023
6d04c17
Merge branch 'v14/dev' into v14/feature/delivery-api-member-auth
kjac Aug 3, 2023
43c2e30
Merge branch 'v14/dev' into v14/feature/delivery-api-member-auth
kjac Aug 16, 2023
9d3a15d
Make SwaggerRouteTemplatePipelineFilter UI config overridable
kjac Aug 16, 2023
6410916
Merge branch 'v14/dev' into v14/feature/delivery-api-member-auth
kjac Aug 22, 2023
c94fe59
Enable token revocation + rename logout endpoint to signout
kjac Aug 25, 2023
c25a230
Add default implementation of SwaggerGenOptions configuration for ena…
kjac Aug 25, 2023
112a52c
Correct notification handling when (un)protecting content
kjac Aug 27, 2023
71f823d
Merge branch 'v14/dev' into v14/feature/delivery-api-member-auth
kjac Aug 27, 2023
a1a0436
Fixing integration test framework
bergmania Aug 28, 2023
8ca60c5
Cleanup test to not execute some composers twice
bergmania Aug 28, 2023
b1141b8
Update paths to match docs
kjac Sep 5, 2023
6a31190
Return Forbidden when a member is authorized but not allowed to acces…
kjac Sep 6, 2023
4a97037
Merge branch 'v14/dev' into v14/feature/delivery-api-member-auth
kjac Sep 6, 2023
4f3617a
Cleanup
elit0451 Sep 6, 2023
e36997b
Rename RequestMemberService to RequestMemberAccessService
kjac Sep 7, 2023
c1e34ac
Rename badly named variable
kjac Sep 7, 2023
e1e01b7
Review comments
kjac Sep 7, 2023
b7338e9
Hide the auth controller from Swagger
kjac Sep 7, 2023
36c8a72
Remove semaphore
kjac Sep 8, 2023
8db1670
Add security requirements for content API operations in Swagger
kjac Sep 12, 2023
7cda8e4
Merge branch 'v14/dev' into v14/feature/delivery-api-member-auth
kjac Sep 13, 2023
9e0ddce
Hide the back-office auth endpoints from Swagger
kjac Sep 13, 2023
1f0c35e
Fix merge
kjac Sep 13, 2023
b0d8ce3
Update back-office API auth endpoint paths + add revoke and sign-out …
kjac Sep 13, 2023
32e2065
Swap endpoint order to maintain backwards compat with the current log…
kjac Sep 13, 2023
b807ffa
Merge remote-tracking branch 'origin/v14/dev' into v14/feature/delive…
bergmania Sep 22, 2023
4390929
Merge branch 'v14/dev' into v14/feature/delivery-api-member-auth
kjac Sep 25, 2023
609c0ef
Make "items by IDs" endpoint support member auth
kjac Sep 25, 2023
9e5f24f
Add 401 and 403 to "items by IDs" endpoint responses
kjac Sep 25, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -0,0 +1,109 @@
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;
private static SemaphoreSlim _initializedLocker = new(1);

public static IUmbracoBuilder AddUmbracoOpenIddict(this IUmbracoBuilder builder)
{
if (_initialized)
{
return builder;
}

_initializedLocker.Wait();

if (_initialized is false)
{
ConfigureOpenIddict(builder);
_initialized = true;
}

_initializedLocker.Release();
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
options
.SetAuthorizationEndpointUris(
Paths.MemberApi.AuthorizationEndpoint.TrimStart(Constants.CharArrays.ForwardSlash),
Paths.BackOfficeApi.AuthorizationEndpoint.TrimStart(Constants.CharArrays.ForwardSlash))
.SetTokenEndpointUris(
Paths.MemberApi.TokenEndpoint.TrimStart(Constants.CharArrays.ForwardSlash),
Paths.BackOfficeApi.TokenEndpoint.TrimStart(Constants.CharArrays.ForwardSlash))
.SetLogoutEndpointUris(
Paths.MemberApi.LogoutEndpoint.TrimStart(Constants.CharArrays.ForwardSlash))
.SetRevocationEndpointUris(
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<OpenIddictCleanup>();
}
}
Expand Up @@ -5,6 +5,7 @@
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;
Expand Down Expand Up @@ -32,20 +33,8 @@ private void PostPipelineAction(IApplicationBuilder applicationBuilder)
{
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)
Expand All @@ -60,6 +49,23 @@ protected virtual string SwaggerRouteTemplate(IApplicationBuilder applicationBui
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<IOptions<GlobalSettings>>().Value;
Expand Down
32 changes: 32 additions & 0 deletions src/Umbraco.Cms.Api.Common/Security/Paths.cs
@@ -0,0 +1,32 @@
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");

// TODO: talk to Jacob about what is the preferred versioning scheme for the new backoffice client - /v1.0/ or /v1/
private static string EndpointPath(string relativePath) => $"/umbraco/management/api/v1.0/{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}";
}
}
@@ -0,0 +1,36 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using Umbraco.Cms.Api.Common.Security;

namespace Umbraco.Cms.Api.Delivery.Configuration;

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// This class is not used by the core CMS due to the required installation dependencies (local login page among other things).
/// </remarks>
public class ConfigureUmbracoMemberAuthenticationDeliveryApiSwaggerGenOptions : IConfigureOptions<SwaggerGenOptions>
{
public void Configure(SwaggerGenOptions options)
=> options.AddSecurityDefinition(
"Umbraco Member",
new OpenApiSecurityScheme
{
In = ParameterLocation.Header,
Name = "Umbraco Member",
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)
}
}
});
}
@@ -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;
Expand All @@ -11,14 +13,41 @@ namespace Umbraco.Cms.Api.Delivery.Controllers;
[ApiVersion("1.0")]
public class ByIdContentApiController : ContentApiItemControllerBase
{
private readonly IRequestMemberService _requestMemberService;

[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<IRequestMemberService>())
{
}

[Obsolete($"Please use the constructor that does not accept {nameof(IPublicAccessService)}. Will be removed in V14.")]
public ByIdContentApiController(
IApiPublishedContentCache apiPublishedContentCache,
IApiContentResponseBuilder apiContentResponseBuilder,
IPublicAccessService publicAccessService,
IRequestMemberService requestMemberService)
: this(
apiPublishedContentCache,
apiContentResponseBuilder,
requestMemberService)
{
}

[ActivatorUtilitiesConstructor]
public ByIdContentApiController(
IApiPublishedContentCache apiPublishedContentCache,
IApiContentResponseBuilder apiContentResponseBuilder,
IRequestMemberService requestMemberService)
: base(apiPublishedContentCache, apiContentResponseBuilder)
=> _requestMemberService = requestMemberService;

/// <summary>
/// Gets a content item by id.
/// </summary>
Expand All @@ -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<IActionResult> ById(Guid id)
{
Expand All @@ -38,9 +68,10 @@ public async Task<IActionResult> ById(Guid id)
return NotFound();
}

if (IsProtected(contentItem))
IActionResult? deniedAccessResult = await HandleMemberAccessAsync(contentItem, _requestMemberService);
if (deniedAccessResult is not null)
{
return Unauthorized();
return deniedAccessResult;
}

IApiContentResponse? apiContentResponse = ApiContentResponseBuilder.Build(contentItem);
Expand All @@ -49,6 +80,6 @@ public async Task<IActionResult> ById(Guid id)
return NotFound();
}

return await Task.FromResult(Ok(apiContentResponse));
return Ok(apiContentResponse);
}
}