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

Allow for multiple backoffice hosts #18302

Merged
merged 2 commits into from
Feb 17, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
@@ -15,15 +14,13 @@ namespace Umbraco.Cms.Api.Management.Middleware;

public class BackOfficeAuthorizationInitializationMiddleware : IMiddleware
{
private bool _firstBackOfficeRequest; // this only works because this is a singleton
private SemaphoreSlim _firstBackOfficeRequestLocker = new(1); // this only works because this is a singleton
private ISet<string> _knownHosts = new HashSet<string>(); // this only works because this is a singleton

private readonly UmbracoRequestPaths _umbracoRequestPaths;
private readonly IServiceProvider _serviceProvider;
private readonly IRuntimeState _runtimeState;
private readonly IOptions<GlobalSettings> _globalSettings;
private readonly IOptions<WebRoutingSettings> _webRoutingSettings;
private readonly IHostingEnvironment _hostingEnvironment;
private readonly WebRoutingSettings _webRoutingSettings;

[Obsolete("Use the non-obsolete constructor. This will be removed in Umbraco 16.")]
public BackOfficeAuthorizationInitializationMiddleware(
@@ -34,27 +31,32 @@ public BackOfficeAuthorizationInitializationMiddleware(
umbracoRequestPaths,
serviceProvider,
runtimeState,
StaticServiceProvider.Instance.GetRequiredService<IOptions<GlobalSettings>>(),
StaticServiceProvider.Instance.GetRequiredService<IOptions<WebRoutingSettings>>(),
StaticServiceProvider.Instance.GetRequiredService<IHostingEnvironment>()
)
StaticServiceProvider.Instance.GetRequiredService<IOptions<WebRoutingSettings>>())
{
}

[Obsolete("Use the non-obsolete constructor. This will be removed in Umbraco 17.")]
public BackOfficeAuthorizationInitializationMiddleware(
UmbracoRequestPaths umbracoRequestPaths,
IServiceProvider serviceProvider,
IRuntimeState runtimeState,
IOptions<GlobalSettings> globalSettings,
IOptions<WebRoutingSettings> webRoutingSettings,
IHostingEnvironment hostingEnvironment)
: this(umbracoRequestPaths, serviceProvider, runtimeState, webRoutingSettings)
{
}

public BackOfficeAuthorizationInitializationMiddleware(
UmbracoRequestPaths umbracoRequestPaths,
IServiceProvider serviceProvider,
IRuntimeState runtimeState,
IOptions<WebRoutingSettings> webRoutingSettings)
{
_umbracoRequestPaths = umbracoRequestPaths;
_serviceProvider = serviceProvider;
_runtimeState = runtimeState;
_globalSettings = globalSettings;
_webRoutingSettings = webRoutingSettings;
_hostingEnvironment = hostingEnvironment;
_webRoutingSettings = webRoutingSettings.Value;
}

public async Task InvokeAsync(HttpContext context, RequestDelegate next)
@@ -65,37 +67,42 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next)

private async Task InitializeBackOfficeAuthorizationOnceAsync(HttpContext context)
{
if (_firstBackOfficeRequest)
{
return;
}

// Install is okay without this, because we do not need a token to install,
// but upgrades do, so we need to execute for everything higher then or equal to upgrade.
if (_runtimeState.Level < RuntimeLevel.Upgrade)
{
return;
}


if (_umbracoRequestPaths.IsBackOfficeRequest(context.Request.Path) == false)
{
return;
}

await _firstBackOfficeRequestLocker.WaitAsync();
if (_firstBackOfficeRequest == false)
if (_knownHosts.Add($"{context.Request.Scheme}://{context.Request.Host}") is false)
{
Uri? backOfficeUrl = string.IsNullOrWhiteSpace(_webRoutingSettings.Value.UmbracoApplicationUrl) is false
? new Uri($"{_webRoutingSettings.Value.UmbracoApplicationUrl.TrimEnd('/')}{_globalSettings.Value.GetBackOfficePath(_hostingEnvironment).EnsureStartsWith('/')}")
: null;
return;
}

using IServiceScope scope = _serviceProvider.CreateScope();
IBackOfficeApplicationManager backOfficeApplicationManager = scope.ServiceProvider.GetRequiredService<IBackOfficeApplicationManager>();
await backOfficeApplicationManager.EnsureBackOfficeApplicationAsync(backOfficeUrl ?? new Uri(context.Request.GetDisplayUrl()));
_firstBackOfficeRequest = true;
await _firstBackOfficeRequestLocker.WaitAsync();

// ensure we explicitly add UmbracoApplicationUrl if configured (https://github.com/umbraco/Umbraco-CMS/issues/16179)
if (_webRoutingSettings.UmbracoApplicationUrl.IsNullOrWhiteSpace() is false)
{
_knownHosts.Add(_webRoutingSettings.UmbracoApplicationUrl);
}

Uri[] backOfficeHosts = _knownHosts
.Select(host => Uri.TryCreate(host, UriKind.Absolute, out Uri? hostUri)
? hostUri
: null)
.WhereNotNull()
.ToArray();

using IServiceScope scope = _serviceProvider.CreateScope();
IBackOfficeApplicationManager backOfficeApplicationManager = scope.ServiceProvider.GetRequiredService<IBackOfficeApplicationManager>();
await backOfficeApplicationManager.EnsureBackOfficeApplicationAsync(backOfficeHosts);

_firstBackOfficeRequestLocker.Release();
}
}
Original file line number Diff line number Diff line change
@@ -32,7 +32,11 @@ public BackOfficeApplicationManager(
_authorizeCallbackLogoutPathName = securitySettings.Value.AuthorizeCallbackLogoutPathName;
}

[Obsolete("Please use the overload that allows for multiple back-office hosts. Will be removed in V17.")]
public async Task EnsureBackOfficeApplicationAsync(Uri backOfficeUrl, CancellationToken cancellationToken = default)
=> await EnsureBackOfficeApplicationAsync([backOfficeUrl], cancellationToken);

public async Task EnsureBackOfficeApplicationAsync(IEnumerable<Uri> backOfficeHosts, CancellationToken cancellationToken = default)
{
// Install is okay without this, because we do not need a token to install,
// but upgrades do, so we need to execute for everything higher then or equal to upgrade.
@@ -41,13 +45,14 @@ public async Task EnsureBackOfficeApplicationAsync(Uri backOfficeUrl, Cancellati
return;
}

if (backOfficeUrl.IsAbsoluteUri is false)
Uri[] backOfficeHostsAsArray = backOfficeHosts as Uri[] ?? backOfficeHosts.ToArray();
if (backOfficeHostsAsArray.Any(url => url.IsAbsoluteUri) is false)
{
throw new ArgumentException($"Expected an absolute URL, got: {backOfficeUrl}", nameof(backOfficeUrl));
throw new ArgumentException($"Expected absolute URLs, got: {string.Join(", ", backOfficeHostsAsArray.Select(url => url.ToString()))}", nameof(backOfficeHosts));
}

await CreateOrUpdate(
BackofficeOpenIddictApplicationDescriptor(backOfficeUrl),
BackofficeOpenIddictApplicationDescriptor(backOfficeHostsAsArray),
cancellationToken);

if (_webHostEnvironment.IsProduction())
@@ -57,77 +62,60 @@ await CreateOrUpdate(
}
else
{
var developerClientTimeOutValue = new GlobalSettings().TimeOut.ToString("c", CultureInfo.InvariantCulture);

await CreateOrUpdate(
new OpenIddictApplicationDescriptor
{
DisplayName = "Umbraco Swagger access",
ClientId = Constants.OAuthClientIds.Swagger,
RedirectUris =
{
CallbackUrlFor(backOfficeUrl, "/umbraco/swagger/oauth2-redirect.html")
},
ClientType = OpenIddictConstants.ClientTypes.Public,
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.ResponseTypes.Code
},
Settings =
{
// use a fixed access token lifetime for tokens issued to the Swagger application.
[OpenIddictConstants.Settings.TokenLifetimes.AccessToken] = developerClientTimeOutValue
}
},
DeveloperOpenIddictApplicationDescriptor(
"Umbraco Swagger access",
Constants.OAuthClientIds.Swagger,
backOfficeHostsAsArray.Select(backOfficeUrl => CallbackUrlFor(backOfficeUrl, "/umbraco/swagger/oauth2-redirect.html")).ToArray()),
cancellationToken);

await CreateOrUpdate(
new OpenIddictApplicationDescriptor
{
DisplayName = "Umbraco Postman access",
ClientId = Constants.OAuthClientIds.Postman,
RedirectUris =
{
new Uri("https://oauth.pstmn.io/v1/callback"), new Uri("https://oauth.pstmn.io/v1/browser-callback")
},
ClientType = OpenIddictConstants.ClientTypes.Public,
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.ResponseTypes.Code
},
Settings =
{
// use a fixed access token lifetime for tokens issued to the Postman application.
[OpenIddictConstants.Settings.TokenLifetimes.AccessToken] = developerClientTimeOutValue
}
},
DeveloperOpenIddictApplicationDescriptor(
"Umbraco Postman access",
Constants.OAuthClientIds.Postman,
[new Uri("https://oauth.pstmn.io/v1/callback"), new Uri("https://oauth.pstmn.io/v1/browser-callback")]),
cancellationToken);
}
}

public async Task EnsureBackOfficeClientCredentialsApplicationAsync(string clientId, string clientSecret, CancellationToken cancellationToken = default)
{
var applicationDescriptor = new OpenIddictApplicationDescriptor
{
DisplayName = $"Umbraco client credentials back-office access: {clientId}",
ClientId = clientId,
ClientSecret = clientSecret,
ClientType = OpenIddictConstants.ClientTypes.Confidential,
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.Endpoints.Revocation,
OpenIddictConstants.Permissions.GrantTypes.ClientCredentials
}
};

await CreateOrUpdate(applicationDescriptor, cancellationToken);
}

public async Task DeleteBackOfficeClientCredentialsApplicationAsync(string clientId, CancellationToken cancellationToken = default)
=> await Delete(clientId, cancellationToken);

[Obsolete("Do not use - for internal usage only. Will be made internal in V17.")]
public OpenIddictApplicationDescriptor BackofficeOpenIddictApplicationDescriptor(Uri backOfficeUrl)
=> BackofficeOpenIddictApplicationDescriptor([backOfficeUrl]);

internal OpenIddictApplicationDescriptor BackofficeOpenIddictApplicationDescriptor(Uri[] backOfficeHosts)
{
Uri CallbackUrl(string path) => CallbackUrlFor(_backOfficeHost ?? backOfficeUrl, path);
return new OpenIddictApplicationDescriptor
if (_backOfficeHost is not null)
{
backOfficeHosts = [_backOfficeHost];
}

var descriptor = new OpenIddictApplicationDescriptor
{
DisplayName = "Umbraco back-office access",
ClientId = Constants.OAuthClientIds.BackOffice,
RedirectUris =
{
CallbackUrl(_authorizeCallbackPathName),
},
ClientType = OpenIddictConstants.ClientTypes.Public,
PostLogoutRedirectUris =
{
CallbackUrl(_authorizeCallbackPathName),
CallbackUrl(_authorizeCallbackLogoutPathName),
},
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
@@ -139,29 +127,47 @@ public OpenIddictApplicationDescriptor BackofficeOpenIddictApplicationDescriptor
OpenIddictConstants.Permissions.ResponseTypes.Code,
},
};

foreach (Uri backOfficeHost in backOfficeHosts)
{
descriptor.RedirectUris.Add(CallbackUrlFor(backOfficeHost, _authorizeCallbackPathName));
descriptor.PostLogoutRedirectUris.Add(CallbackUrlFor(backOfficeHost, _authorizeCallbackPathName));
descriptor.PostLogoutRedirectUris.Add(CallbackUrlFor(backOfficeHost, _authorizeCallbackLogoutPathName));
}

return descriptor;
}

public async Task EnsureBackOfficeClientCredentialsApplicationAsync(string clientId, string clientSecret, CancellationToken cancellationToken = default)
internal OpenIddictApplicationDescriptor DeveloperOpenIddictApplicationDescriptor(string name, string clientId, Uri[] redirectUrls)
{
var applicationDescriptor = new OpenIddictApplicationDescriptor
var developerClientTimeOutValue = new GlobalSettings().TimeOut.ToString("c", CultureInfo.InvariantCulture);

var descriptor = new OpenIddictApplicationDescriptor
{
DisplayName = $"Umbraco client credentials back-office access: {clientId}",
DisplayName = name,
ClientId = clientId,
ClientSecret = clientSecret,
ClientType = OpenIddictConstants.ClientTypes.Confidential,
ClientType = OpenIddictConstants.ClientTypes.Public,
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.Endpoints.Revocation,
OpenIddictConstants.Permissions.GrantTypes.ClientCredentials
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.ResponseTypes.Code
},
Settings =
{
// use a fixed access token lifetime for tokens issued to the developer applications.
[OpenIddictConstants.Settings.TokenLifetimes.AccessToken] = developerClientTimeOutValue
}
};

await CreateOrUpdate(applicationDescriptor, cancellationToken);
}
foreach (Uri redirectUrl in redirectUrls)
{
descriptor.RedirectUris.Add(redirectUrl);
}

public async Task DeleteBackOfficeClientCredentialsApplicationAsync(string clientId, CancellationToken cancellationToken = default)
=> await Delete(clientId, cancellationToken);
return descriptor;
}

private static Uri CallbackUrlFor(Uri url, string relativePath) => new Uri($"{url.GetLeftPart(UriPartial.Authority)}/{relativePath.TrimStart(Constants.CharArrays.ForwardSlash)}");
}
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@
<Title>Umbraco CMS - Management API</Title>
<Description>Contains the presentation layer for the Umbraco CMS Management API.</Description>
</PropertyGroup>

<PropertyGroup>
<!--
TODO: Fix and remove overrides:
@@ -22,7 +22,7 @@
-->
<WarningsNotAsErrors>$(WarningsNotAsErrors),SA1117,SA1401,SA1134,CS0108,CS0618,CS9042,CS1998,CS8524,IDE0060,SA1649,CS0419,CS1573,CS1574</WarningsNotAsErrors>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="JsonPatch.Net" />
<PackageReference Include="Swashbuckle.AspNetCore" />
@@ -38,6 +38,9 @@
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Tests.UnitTests</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Tests.Integration</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>DynamicProxyGenAssembly2</_Parameter1>
</AssemblyAttribute>
Original file line number Diff line number Diff line change
@@ -2,8 +2,12 @@

public interface IBackOfficeApplicationManager
{
[Obsolete("Please use the overload that allows for multiple back-office hosts. Will be removed in V17.")]
Task EnsureBackOfficeApplicationAsync(Uri backOfficeUrl, CancellationToken cancellationToken = default);

Task EnsureBackOfficeApplicationAsync(IEnumerable<Uri> backOfficeHosts, CancellationToken cancellationToken = default)
=> Task.CompletedTask;

Task EnsureBackOfficeClientCredentialsApplicationAsync(string clientId, string clientSecret, CancellationToken cancellationToken = default);

Task DeleteBackOfficeClientCredentialsApplicationAsync(string clientId, CancellationToken cancellationToken = default);
Original file line number Diff line number Diff line change
@@ -113,7 +113,7 @@ protected async Task AuthenticateClientAsync(HttpClient client, Func<IUserServic
serviceScope.ServiceProvider.GetRequiredService<IBackOfficeApplicationManager>() as
BackOfficeApplicationManager;
backofficeOpenIddictApplicationDescriptor =
backOfficeApplicationManager.BackofficeOpenIddictApplicationDescriptor(client.BaseAddress);
backOfficeApplicationManager.BackofficeOpenIddictApplicationDescriptor([client.BaseAddress]);

scope.Complete();
}
Loading