OpenId Connect authentication for new management API (#13318)

* First attempt at OpenIddict

* Making headway and more TODOs

* Redo current policies for multiple schemas + clean up auth controller

* Fix bad merge

* Clean up some more test code

* Fix spacing

* Include AddAuthentication() in OpenIddict addition

* A little more clean-up

* Move application creation to its own implementation + prepare for middleware to handle valid callback URL

* Enable refresh token flow

* Fix bad merge from v11/dev

* Support auth for Swagger and Postman in non-production environments + use default login screen for back-office logins

* Add workaround to client side login handling so the OAuth return URL is not corrupted before redirection

* Add temporary configuration handling for new backoffice

* Restructure the code somewhat, move singular responsibility from management API project

* Add recurring task for cleaning up old tokens in the DB

* Fix bad merge + make auth controller align with the new management API structure

* Explicitly handle the new management API path as a backoffice path (NOTE: this is potentially behaviorally breaking!)

* Redo handle the new management API requests as backoffice requests, this time in a non-breaking way

* Add/update TODOs

* Revert duplication of current auth policies for OpenIddict (as it breaks everything for V11 without the new management APIs) and introduce a dedicated PoC policy setup for OpenIddict.

* Fix failing unit tests

* Update src/Umbraco.Cms.ManagementApi/Security/BackOfficeApplicationManager.cs

Co-authored-by: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com>

* Update src/Umbraco.Cms.ManagementApi/Security/BackOfficeApplicationManager.cs

Co-authored-by: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com>

* Update src/Umbraco.Cms.ManagementApi/Security/BackOfficeApplicationManager.cs

Co-authored-by: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com>

* Update src/Umbraco.Core/Routing/UmbracoRequestPaths.cs

Co-authored-by: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com>
This commit is contained in:
Kenn Jacobsen
2022-11-01 11:15:31 +01:00
committed by GitHub
parent 746ab4bb23
commit dc9d4155a3
25 changed files with 763 additions and 17 deletions

View File

@@ -0,0 +1,77 @@
using System.Security.Claims;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using NSwag.Annotations;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Web.BackOffice.Security;
using Umbraco.Extensions;
using Umbraco.New.Cms.Web.Common.Routing;
namespace Umbraco.Cms.ManagementApi.Controllers.Security;
[ApiController]
[VersionedApiBackOfficeRoute(Paths.BackOfficeApiEndpointTemplate)]
[OpenApiTag("Security")]
public class BackOfficeController : ManagementApiControllerBase
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IBackOfficeSignInManager _backOfficeSignInManager;
private readonly IBackOfficeUserManager _backOfficeUserManager;
public BackOfficeController(IHttpContextAccessor httpContextAccessor, IBackOfficeSignInManager backOfficeSignInManager, IBackOfficeUserManager backOfficeUserManager)
{
_httpContextAccessor = httpContextAccessor;
_backOfficeSignInManager = backOfficeSignInManager;
_backOfficeUserManager = backOfficeUserManager;
}
[HttpGet("authorize")]
[HttpPost("authorize")]
[MapToApiVersion("1.0")]
public async Task<IActionResult> Authorize()
{
HttpContext context = _httpContextAccessor.GetRequiredHttpContext();
OpenIddictRequest? request = context.GetOpenIddictServerRequest();
if (request == null)
{
return BadRequest("Unable to obtain OpenID data from the current request");
}
// retrieve the user principal stored in the authentication cookie.
AuthenticateResult cookieAuthResult = await HttpContext.AuthenticateAsync(Constants.Security.BackOfficeAuthenticationType);
if (cookieAuthResult.Succeeded && cookieAuthResult.Principal?.Identity?.Name != null)
{
BackOfficeIdentityUser? backOfficeUser = await _backOfficeUserManager.FindByNameAsync(cookieAuthResult.Principal.Identity.Name);
if (backOfficeUser != null)
{
ClaimsPrincipal backOfficePrincipal = await _backOfficeSignInManager.CreateUserPrincipalAsync(backOfficeUser);
backOfficePrincipal.SetClaim(OpenIddictConstants.Claims.Subject, backOfficeUser.Key.ToString());
// TODO: it is not optimal to append all claims to the token.
// the token size grows with each claim, although it is still smaller than the old cookie.
// see if we can find a better way so we do not risk leaking sensitive data in bearer tokens.
// maybe work with scopes instead?
Claim[] backOfficeClaims = backOfficePrincipal.Claims.ToArray();
foreach (Claim backOfficeClaim in backOfficeClaims)
{
backOfficeClaim.SetDestinations(OpenIddictConstants.Destinations.AccessToken);
}
if (request.GetScopes().Contains(OpenIddictConstants.Scopes.OfflineAccess))
{
// "offline_access" scope is required to use refresh tokens
backOfficePrincipal.SetScopes(OpenIddictConstants.Scopes.OfflineAccess);
}
return new SignInResult(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, backOfficePrincipal);
}
}
return new ChallengeResult(new[] { Constants.Security.BackOfficeAuthenticationType });
}
}

View File

@@ -0,0 +1,12 @@
namespace Umbraco.Cms.ManagementApi.Controllers.Security;
public static class Paths
{
public const string BackOfficeApiEndpointTemplate = "security/back-office";
public static string BackOfficeApiAuthorizationEndpoint = BackOfficeApiEndpointPath($"{BackOfficeApiEndpointTemplate}/authorize");
public static string BackOfficeApiTokenEndpoint = BackOfficeApiEndpointPath($"{BackOfficeApiEndpointTemplate}/token");
private static string BackOfficeApiEndpointPath(string relativePath) => $"/umbraco/management/api/v1.0/{relativePath}";
}

View File

@@ -0,0 +1,146 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OpenIddict.Validation.AspNetCore;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.ManagementApi.Middleware;
using Umbraco.Cms.ManagementApi.Security;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.New.Cms.Infrastructure.HostedServices;
using Umbraco.New.Cms.Infrastructure.Security;
namespace Umbraco.Cms.ManagementApi.DependencyInjection;
public static class BackOfficeAuthBuilderExtensions
{
public static IUmbracoBuilder AddBackOfficeAuthentication(this IUmbracoBuilder builder)
{
builder
.AddDbContext()
.AddOpenIddict();
return builder;
}
private static IUmbracoBuilder AddDbContext(this IUmbracoBuilder builder)
{
builder.Services.AddDbContext<DbContext>(options =>
{
// Configure the DB context
// TODO: use actual Umbraco DbContext once EF is implemented - and remove dependency on Microsoft.EntityFrameworkCore.InMemory
options.UseInMemoryDatabase(nameof(DbContext));
// Register the entity sets needed by OpenIddict.
options.UseOpenIddict();
});
return builder;
}
private static IUmbracoBuilder AddOpenIddict(this IUmbracoBuilder builder)
{
builder.Services.AddAuthentication();
builder.Services.AddAuthorization(CreatePolicies);
builder.Services.AddOpenIddict()
// Register the OpenIddict core components.
.AddCore(options =>
{
options
.UseEntityFrameworkCore()
.UseDbContext<DbContext>();
})
// Register the OpenIddict server components.
.AddServer(options =>
{
// Enable the authorization and token endpoints.
options
.SetAuthorizationEndpointUris(Controllers.Security.Paths.BackOfficeApiAuthorizationEndpoint)
.SetTokenEndpointUris(Controllers.Security.Paths.BackOfficeApiTokenEndpoint);
// 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();
})
// 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();
});
builder.Services.AddTransient<IBackOfficeApplicationManager, BackOfficeApplicationManager>();
builder.Services.AddSingleton<IClientSecretManager, ClientSecretManager>();
builder.Services.AddSingleton<BackOfficeAuthorizationInitializationMiddleware>();
builder.Services.AddHostedService<OpenIddictCleanup>();
builder.Services.AddHostedService<DatabaseManager>();
return builder;
}
// TODO: remove this once EF is implemented
public class DatabaseManager : IHostedService
{
private readonly IServiceProvider _serviceProvider;
public DatabaseManager(IServiceProvider serviceProvider) => _serviceProvider = serviceProvider;
public async Task StartAsync(CancellationToken cancellationToken)
{
using IServiceScope scope = _serviceProvider.CreateScope();
DbContext context = scope.ServiceProvider.GetRequiredService<DbContext>();
await context.Database.EnsureCreatedAsync(cancellationToken);
// TODO: add BackOfficeAuthorizationInitializationMiddleware before UseAuthorization (to make it run for unauthorized API requests) and remove this
IBackOfficeApplicationManager backOfficeApplicationManager = scope.ServiceProvider.GetRequiredService<IBackOfficeApplicationManager>();
await backOfficeApplicationManager.EnsureBackOfficeApplicationAsync(new Uri("https://localhost:44331/"), cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
// TODO: move this to an appropriate location and implement the policy scheme that should be used for the new management APIs
private static void CreatePolicies(AuthorizationOptions options)
{
void AddPolicy(string policyName, string claimType, params string[] allowedClaimValues)
{
options.AddPolicy($"New{policyName}", policy =>
{
policy.AuthenticationSchemes.Add(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
policy.RequireClaim(claimType, allowedClaimValues);
});
}
// NOTE: these are ONLY sample policies that allow us to test the new management APIs
AddPolicy(AuthorizationPolicies.SectionAccessContent, Constants.Security.AllowedApplicationsClaimType, Constants.Applications.Content);
AddPolicy(AuthorizationPolicies.SectionAccessForContentTree, Constants.Security.AllowedApplicationsClaimType, Constants.Applications.Content);
AddPolicy(AuthorizationPolicies.SectionAccessForMediaTree, Constants.Security.AllowedApplicationsClaimType, Constants.Applications.Media);
AddPolicy(AuthorizationPolicies.SectionAccessMedia, Constants.Security.AllowedApplicationsClaimType, Constants.Applications.Media);
AddPolicy(AuthorizationPolicies.SectionAccessContentOrMedia, Constants.Security.AllowedApplicationsClaimType, Constants.Applications.Content, Constants.Applications.Media);
}
}

View File

@@ -1,7 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.ManagementApi.Serialization;
using Umbraco.Cms.ManagementApi.Services;
using Umbraco.Extensions;
using Umbraco.New.Cms.Core.Services.Installer;
using Umbraco.New.Cms.Core.Services.Languages;
@@ -17,6 +19,12 @@ public static class ServicesBuilderExtensions
builder.Services.AddTransient<ISystemTextJsonSerializer, SystemTextJsonSerializer>();
builder.Services.AddTransient<IUploadFileService, UploadFileService>();
// TODO: handle new management API path in core UmbracoRequestPaths (it's a behavioural breaking change so it goes here for now)
builder.Services.Configure<UmbracoRequestPathsOptions>(options =>
{
options.IsBackOfficeRequest = urlPath => urlPath.InvariantStartsWith($"/umbraco/management/api/");
});
return builder;
}
}

View File

@@ -8,17 +8,18 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;
using NSwag;
using NSwag.AspNetCore;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.ManagementApi.Configuration;
using Umbraco.Cms.ManagementApi.DependencyInjection;
using Umbraco.Cms.ManagementApi.Security;
using Umbraco.Cms.Web.Common.ApplicationBuilder;
using Umbraco.Extensions;
using Umbraco.New.Cms.Core;
using Umbraco.New.Cms.Core.Models.Configuration;
using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment;
namespace Umbraco.Cms.ManagementApi;
@@ -44,7 +45,8 @@ public class ManagementApiComposer : IComposer
.AddTrees()
.AddFactories()
.AddServices()
.AddMappers();
.AddMappers()
.AddBackOfficeAuthentication();
services.AddApiVersioning(options =>
{
@@ -65,6 +67,20 @@ public class ManagementApiComposer : IComposer
{
document.Tags = document.Tags.OrderBy(tag => tag.Name).ToList();
};
options.AddSecurity("Bearer", Enumerable.Empty<string>(), new OpenApiSecurityScheme
{
Name = "Umbraco",
Type = OpenApiSecuritySchemeType.OAuth2,
Description = "Umbraco Authentication",
Flow = OpenApiOAuth2Flow.AccessCode,
AuthorizationUrl = Controllers.Security.Paths.BackOfficeApiAuthorizationEndpoint,
TokenUrl = Controllers.Security.Paths.BackOfficeApiTokenEndpoint,
Scopes = new Dictionary<string, string>(),
});
// this is documented in OAuth2 setup for swagger, but does not seem to be necessary at the moment.
// it is worth try it if operation authentication starts failing.
// options.OperationProcessors.Add(new AspNetCoreOperationSecurityScopeProcessor("Bearer"));
});
services.AddVersionedApiExplorer(options =>
@@ -78,6 +94,10 @@ public class ManagementApiComposer : IComposer
services.AddControllers();
builder.Services.ConfigureOptions<ConfigureMvcOptions>();
// TODO: when this is moved to core, make the AddUmbracoOptions extension private again and remove core InternalsVisibleTo for Umbraco.Cms.ManagementApi
builder.AddUmbracoOptions<NewBackOfficeSettings>();
builder.Services.AddSingleton<IValidateOptions<NewBackOfficeSettings>, NewBackOfficeSettingsValidator>();
builder.Services.Configure<UmbracoPipelineOptions>(options =>
{
options.AddFilter(new UmbracoPipelineFilter(
@@ -125,6 +145,7 @@ public class ManagementApiComposer : IComposer
{
GlobalSettings? settings = provider.GetRequiredService<IOptions<GlobalSettings>>().Value;
IHostingEnvironment hostingEnvironment = provider.GetRequiredService<IHostingEnvironment>();
IClientSecretManager clientSecretManager = provider.GetRequiredService<IClientSecretManager>();
var officePath = settings.GetBackOfficePath(hostingEnvironment);
// serve documents (same as app.UseSwagger())
applicationBuilder.UseOpenApi(config =>
@@ -141,6 +162,14 @@ public class ManagementApiComposer : IComposer
config.SwaggerRoutes.Add(new SwaggerUi3Route(ApiAllName, swaggerPath));
config.OperationsSorter = "alpha";
config.TagsSorter = "alpha";
config.OAuth2Client = new OAuth2ClientSettings
{
AppName = "Umbraco",
UsePkceWithAuthorizationCodeGrant = true,
ClientId = Constants.OauthClientIds.Swagger,
ClientSecret = clientSecretManager.Get(Constants.OauthClientIds.Swagger)
};
});
}
},

View File

@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.Routing;
using Umbraco.New.Cms.Infrastructure.Security;
namespace Umbraco.Cms.ManagementApi.Middleware;
public class BackOfficeAuthorizationInitializationMiddleware : IMiddleware
{
private static bool _firstBackOfficeRequest;
private static SemaphoreSlim _firstBackOfficeRequestLocker = new(1);
private readonly UmbracoRequestPaths _umbracoRequestPaths;
private readonly IServiceProvider _serviceProvider;
public BackOfficeAuthorizationInitializationMiddleware(UmbracoRequestPaths umbracoRequestPaths, IServiceProvider serviceProvider)
{
_umbracoRequestPaths = umbracoRequestPaths;
_serviceProvider = serviceProvider;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
await InitializeBackOfficeAuthorizationOnceAsync(context);
await next(context);
}
private async Task InitializeBackOfficeAuthorizationOnceAsync(HttpContext context)
{
if (_firstBackOfficeRequest)
{
return;
}
if (_umbracoRequestPaths.IsBackOfficeRequest(context.Request.Path) == false)
{
return;
}
await _firstBackOfficeRequestLocker.WaitAsync();
if (_firstBackOfficeRequest == false)
{
using IServiceScope scope = _serviceProvider.CreateScope();
IBackOfficeApplicationManager backOfficeApplicationManager = scope.ServiceProvider.GetRequiredService<IBackOfficeApplicationManager>();
await backOfficeApplicationManager.EnsureBackOfficeApplicationAsync(new Uri(context.Request.GetDisplayUrl()));
_firstBackOfficeRequest = true;
}
_firstBackOfficeRequestLocker.Release();
}
}
// TODO: remove this (used for testing BackOfficeAuthorizationInitializationMiddleware until it can be added to the existing UseBackOffice extension)
// public static class UmbracoApplicationBuilderExtensions
// {
// public static IUmbracoApplicationBuilderContext UseNewBackOffice(this IUmbracoApplicationBuilderContext builder)
// {
// builder.AppBuilder.UseMiddleware<BackOfficeAuthorizationInitializationMiddleware>();
// return builder;
// }
// }

View File

@@ -816,6 +816,46 @@
}
}
},
"/umbraco/management/api/v1/security/back-office/authorize": {
"get": {
"tags": [
"Security"
],
"operationId": "BackOffice_AuthorizeGET",
"responses": {
"200": {
"description": "",
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
},
"post": {
"tags": [
"Security"
],
"operationId": "BackOffice_AuthorizePOST",
"responses": {
"200": {
"description": "",
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
},
"/umbraco/management/api/v1/search/index/{indexName}": {
"get": {
"tags": [
@@ -5246,8 +5286,26 @@
}
}
}
},
"securitySchemes": {
"Bearer": {
"type": "oauth2",
"description": "Umbraco Authentication",
"name": "Umbraco",
"flows": {
"authorizationCode": {
"authorizationUrl": "/umbraco/management/api/v1.0/security/back-office/authorize",
"tokenUrl": "/umbraco/management/api/v1.0/security/back-office/token"
}
}
}
}
},
"security": [
{
"Bearer": []
}
],
"tags": [
{
"name": "Culture"
@@ -5312,6 +5370,9 @@
{
"name": "Search"
},
{
"name": "Security"
},
{
"name": "Server"
},

View File

@@ -0,0 +1,138 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using Umbraco.New.Cms.Core;
using Umbraco.New.Cms.Core.Models.Configuration;
using Umbraco.New.Cms.Infrastructure.Security;
namespace Umbraco.Cms.ManagementApi.Security;
public class BackOfficeApplicationManager : IBackOfficeApplicationManager
{
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly IWebHostEnvironment _webHostEnvironment;
private readonly IClientSecretManager _clientSecretManager;
private readonly Uri? _backOfficeHost;
public BackOfficeApplicationManager(
IOpenIddictApplicationManager applicationManager,
IWebHostEnvironment webHostEnvironment,
IClientSecretManager clientSecretManager,
IOptionsMonitor<NewBackOfficeSettings> securitySettingsMonitor)
{
_applicationManager = applicationManager;
_webHostEnvironment = webHostEnvironment;
_clientSecretManager = clientSecretManager;
_backOfficeHost = securitySettingsMonitor.CurrentValue.BackOfficeHost;
}
public async Task EnsureBackOfficeApplicationAsync(Uri backOfficeUrl, CancellationToken cancellationToken = default)
{
if (backOfficeUrl.IsAbsoluteUri is false)
{
throw new ArgumentException($"Expected an absolute URL, got: {backOfficeUrl}", nameof(backOfficeUrl));
}
await CreateOrUpdate(
new OpenIddictApplicationDescriptor
{
DisplayName = "Umbraco back-office access",
ClientId = Constants.OauthClientIds.BackOffice,
RedirectUris =
{
CallbackUrlFor(_backOfficeHost ?? backOfficeUrl, "/umbraco/login/callback/")
},
Type = OpenIddictConstants.ClientTypes.Public,
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.GrantTypes.RefreshToken,
OpenIddictConstants.Permissions.ResponseTypes.Code
}
},
cancellationToken);
if (_webHostEnvironment.IsProduction())
{
await Delete(Constants.OauthClientIds.Swagger, cancellationToken);
await Delete(Constants.OauthClientIds.Postman, cancellationToken);
}
else
{
await CreateOrUpdate(
new OpenIddictApplicationDescriptor
{
DisplayName = "Umbraco Swagger access",
ClientId = Constants.OauthClientIds.Swagger,
// TODO: investigate the necessity of client secrets for Swagger
// this is necessary with NSwag - or maybe it's a SwaggerUI3 requirement? investigate if client
// secrets are even necessary if we switch to Swashbuckle
ClientSecret = _clientSecretManager.Get(Constants.OauthClientIds.Swagger),
RedirectUris =
{
CallbackUrlFor(backOfficeUrl, "/umbraco/swagger/oauth2-redirect.html")
},
Type = OpenIddictConstants.ClientTypes.Confidential,
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.ResponseTypes.Code
}
},
cancellationToken);
await CreateOrUpdate(
new OpenIddictApplicationDescriptor
{
DisplayName = "Umbraco Postman access",
ClientId = Constants.OauthClientIds.Postman,
RedirectUris =
{
new Uri("https://oauth.pstmn.io/v1/callback")
},
Type = OpenIddictConstants.ClientTypes.Public,
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.ResponseTypes.Code
}
},
cancellationToken);
}
}
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)}");
}

View File

@@ -0,0 +1,16 @@
namespace Umbraco.Cms.ManagementApi.Security;
public class ClientSecretManager : IClientSecretManager
{
private Dictionary<string, string> _secretsByClientId = new();
public string Get(string clientId)
{
if (_secretsByClientId.ContainsKey(clientId) == false)
{
_secretsByClientId[clientId] = Guid.NewGuid().ToString("N");
}
return _secretsByClientId[clientId];
}
}

View File

@@ -0,0 +1,6 @@
namespace Umbraco.Cms.ManagementApi.Security;
public interface IClientSecretManager
{
string Get(string clientId);
}

View File

@@ -10,7 +10,10 @@
<PackageReference Include="JsonPatch.Net" Version="2.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="5.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="7.0.0-rc.2.22472.11" />
<PackageReference Include="NSwag.AspNetCore" Version="13.17.0" />
<PackageReference Include="OpenIddict.AspNetCore" Version="3.1.1" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="3.1.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -14,7 +14,7 @@ namespace Umbraco.Cms.Core.DependencyInjection;
/// </summary>
public static partial class UmbracoBuilderExtensions
{
private static IUmbracoBuilder AddUmbracoOptions<TOptions>(this IUmbracoBuilder builder, Action<OptionsBuilder<TOptions>>? configure = null)
internal static IUmbracoBuilder AddUmbracoOptions<TOptions>(this IUmbracoBuilder builder, Action<OptionsBuilder<TOptions>>? configure = null)
where TOptions : class
{
UmbracoOptionsAttribute? umbracoOptionsAttribute = typeof(TOptions).GetCustomAttribute<UmbracoOptionsAttribute>();

View File

@@ -1,6 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Web.Common.DependencyInjection;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Routing;
@@ -19,11 +21,18 @@ public class UmbracoRequestPaths
private readonly string _mvcArea;
private readonly string _previewMvcPath;
private readonly string _surfaceMvcPath;
private readonly IOptions<UmbracoRequestPathsOptions> _umbracoRequestPathsOptions;
[Obsolete("Use constructor that takes IOptions<UmbracoRequestPathsOptions> - Will be removed in Umbraco 13")]
public UmbracoRequestPaths(IOptions<GlobalSettings> globalSettings, IHostingEnvironment hostingEnvironment)
: this(globalSettings, hostingEnvironment, StaticServiceProvider.Instance.GetRequiredService<IOptions<UmbracoRequestPathsOptions>>())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="UmbracoRequestPaths" /> class.
/// </summary>
public UmbracoRequestPaths(IOptions<GlobalSettings> globalSettings, IHostingEnvironment hostingEnvironment)
public UmbracoRequestPaths(IOptions<GlobalSettings> globalSettings, IHostingEnvironment hostingEnvironment, IOptions<UmbracoRequestPathsOptions> umbracoRequestPathsOptions)
{
var applicationPath = hostingEnvironment.ApplicationVirtualPath;
_appPath = applicationPath.TrimStart(Constants.CharArrays.ForwardSlash);
@@ -38,6 +47,7 @@ public class UmbracoRequestPaths
_surfaceMvcPath = "/" + _mvcArea + "/Surface/";
_apiMvcPath = "/" + _mvcArea + "/Api/";
_installPath = hostingEnvironment.ToAbsolute(Constants.SystemDirectories.Install);
_umbracoRequestPathsOptions = umbracoRequestPathsOptions;
}
/// <summary>
@@ -99,6 +109,11 @@ public class UmbracoRequestPaths
return false;
}
if (_umbracoRequestPathsOptions.Value.IsBackOfficeRequest(urlPath))
{
return true;
}
// if its none of the above, we will have to try to detect if it's a PluginController route, we can detect this by
// checking how many parts the route has, for example, all PluginController routes will be routed like
// Umbraco/MYPLUGINAREA/MYCONTROLLERNAME/{action}/{id}

View File

@@ -0,0 +1,10 @@
namespace Umbraco.Cms.Core.Routing;
public class UmbracoRequestPathsOptions
{
/// <summary>
/// Gets the delegate that allows us to handle additional URLs as back-office requests.
/// This returns false by default and can be overwritten in Startup.cs.
/// </summary>
public Func<string, bool> IsBackOfficeRequest { get; set; } = _ => false;
}

View File

@@ -47,6 +47,10 @@
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>DynamicProxyGenAssembly2</_Parameter1>
</AssemblyAttribute>
<!-- TODO: remove this when new backoffice config etc. can be moved to core -->
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Cms.ManagementApi</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,23 @@
namespace Umbraco.New.Cms.Core;
// TODO: move this class to Umbraco.Cms.Core as a partial class
public static class Constants
{
public static partial class OauthClientIds
{
/// <summary>
/// Client ID used for default back-office access
/// </summary>
public const string BackOffice = "umbraco-back-office";
/// <summary>
/// Client ID used for Swagger API access
/// </summary>
public const string Swagger = "umbraco-swagger";
/// <summary>
/// Client ID used for Postman API access
/// </summary>
public const string Postman = "umbraco-postman";
}
}

View File

@@ -0,0 +1,10 @@
using Umbraco.Cms.Core.Configuration.Models;
namespace Umbraco.New.Cms.Core.Models.Configuration;
// TODO: merge this class with relevant existing settings from Core and clean up
[UmbracoOptions($"{Umbraco.Cms.Core.Constants.Configuration.ConfigPrefix}NewBackOffice")]
public class NewBackOfficeSettings
{
public Uri? BackOfficeHost { get; set; } = null;
}

View File

@@ -0,0 +1,25 @@
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models.Validation;
namespace Umbraco.New.Cms.Core.Models.Configuration;
// TODO: merge this class with relevant existing settings validators from Core and clean up
public class NewBackOfficeSettingsValidator : ConfigurationValidatorBase, IValidateOptions<NewBackOfficeSettings>
{
public ValidateOptionsResult Validate(string? name, NewBackOfficeSettings options)
{
if (options.BackOfficeHost != null)
{
if (options.BackOfficeHost.IsAbsoluteUri == false)
{
return ValidateOptionsResult.Fail($"{nameof(NewBackOfficeSettings.BackOfficeHost)} must be an absolute URL");
}
if (options.BackOfficeHost.PathAndQuery != "/")
{
return ValidateOptionsResult.Fail($"{nameof(NewBackOfficeSettings.BackOfficeHost)} must not have any path or query");
}
}
return ValidateOptionsResult.Success;
}
}

View File

@@ -0,0 +1,56 @@
using System.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using Umbraco.Cms.Infrastructure.HostedServices;
namespace Umbraco.New.Cms.Infrastructure.HostedServices;
// port of the OpenIddict Quartz job for cleaning up - see https://github.com/openiddict/openiddict-core/tree/dev/src/OpenIddict.Quartz
public class OpenIddictCleanup : RecurringHostedServiceBase
{
// keep tokens and authorizations in the database for 7 days
// - NOTE: this is NOT the same as access token lifetime, which is likely very short
private const int LifespanInSeconds = 7 * 24 * 60 * 60;
private readonly ILogger<OpenIddictCleanup> _logger;
private readonly IServiceProvider _provider;
public OpenIddictCleanup(
ILogger<OpenIddictCleanup> logger, IServiceProvider provider)
: base(logger, TimeSpan.FromHours(1), TimeSpan.FromMinutes(5))
{
_logger = logger;
_provider = provider;
}
public override async Task PerformExecuteAsync(object? state)
{
// hosted services are registered as singletons, but this particular one consumes scoped services... so
// we have to fetch the service dependencies manually using a new scope per invocation.
IServiceScope scope = _provider.CreateScope();
DateTimeOffset threshold = DateTimeOffset.UtcNow - TimeSpan.FromSeconds(LifespanInSeconds);
try
{
IOpenIddictTokenManager tokenManager = scope.ServiceProvider.GetService<IOpenIddictTokenManager>()
?? throw new ConfigurationErrorsException($"Could not retrieve an {nameof(IOpenIddictTokenManager)} service from the current scope");
await tokenManager.PruneAsync(threshold);
}
catch (Exception exception)
{
_logger.LogError(exception, "Unable to prune OpenIddict tokens");
}
try
{
IOpenIddictAuthorizationManager authorizationManager = scope.ServiceProvider.GetService<IOpenIddictAuthorizationManager>()
?? throw new ConfigurationErrorsException($"Could not retrieve an {nameof(IOpenIddictAuthorizationManager)} service from the current scope");
await authorizationManager.PruneAsync(threshold);
}
catch (Exception exception)
{
_logger.LogError(exception, "Unable to prune OpenIddict authorizations");
}
}
}

View File

@@ -0,0 +1,6 @@
namespace Umbraco.New.Cms.Infrastructure.Security;
public interface IBackOfficeApplicationManager
{
Task EnsureBackOfficeApplicationAsync(Uri backOfficeUrl, CancellationToken cancellationToken = default);
}

View File

@@ -10,4 +10,8 @@
<ProjectReference Include="..\Umbraco.New.Cms.Core\Umbraco.New.Cms.Core.csproj" />
<ProjectReference Include="..\Umbraco.Infrastructure\Umbraco.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="OpenIddict.Abstractions" Version="3.1.1" />
</ItemGroup>
</Project>

View File

@@ -11,7 +11,11 @@ angular.module('umbraco').controller("Umbraco.LoginController", function (events
//check if there's a returnPath query string, if so redirect to it
var locationObj = $location.search();
if (locationObj.returnPath) {
path = decodeURIComponent(locationObj.returnPath);
// decodeURIComponent(...) does not play nice with OAuth redirect URLs, so until we have a
// dedicated login screen for the new back-office, we need to hardcode this exception
path = locationObj.returnPath.indexOf("/security/back-office/authorize") > 0
? locationObj.returnPath
: decodeURIComponent(locationObj.returnPath);
}
// Ensure path is not absolute

View File

@@ -23,7 +23,8 @@ public class TestUmbracoContextFactory
GlobalSettings globalSettings = null,
IUmbracoContextAccessor umbracoContextAccessor = null,
IHttpContextAccessor httpContextAccessor = null,
IPublishedUrlProvider publishedUrlProvider = null)
IPublishedUrlProvider publishedUrlProvider = null,
UmbracoRequestPathsOptions umbracoRequestPathsOptions = null)
{
if (globalSettings == null)
{
@@ -45,6 +46,11 @@ public class TestUmbracoContextFactory
publishedUrlProvider = Mock.Of<IPublishedUrlProvider>();
}
if (umbracoRequestPathsOptions == null)
{
umbracoRequestPathsOptions = new UmbracoRequestPathsOptions();
}
var contentCache = new Mock<IPublishedContentCache>();
var mediaCache = new Mock<IPublishedMediaCache>();
var snapshot = new Mock<IPublishedSnapshot>();
@@ -58,7 +64,7 @@ public class TestUmbracoContextFactory
var umbracoContextFactory = new UmbracoContextFactory(
umbracoContextAccessor,
snapshotService.Object,
new UmbracoRequestPaths(Options.Create(globalSettings), hostingEnvironment),
new UmbracoRequestPaths(Options.Create(globalSettings), hostingEnvironment, Options.Create(umbracoRequestPathsOptions)),
hostingEnvironment,
new UriUtility(hostingEnvironment),
new AspNetCoreCookieManager(httpContextAccessor),

View File

@@ -18,10 +18,12 @@ public class UmbracoRequestPathsTests
{
_hostEnvironment = Mock.Of<IWebHostEnvironment>();
_globalSettings = new GlobalSettings();
_umbracoRequestPathsOptions = new UmbracoRequestPathsOptions();
}
private IWebHostEnvironment _hostEnvironment;
private GlobalSettings _globalSettings;
private UmbracoRequestPathsOptions _umbracoRequestPathsOptions;
private IHostingEnvironment CreateHostingEnvironment(string virtualPath = "")
{
@@ -49,7 +51,7 @@ public class UmbracoRequestPathsTests
public void Is_Client_Side_Request(string url, bool assert)
{
var hostingEnvironment = CreateHostingEnvironment();
var umbracoRequestPaths = new UmbracoRequestPaths(Options.Create(_globalSettings), hostingEnvironment);
var umbracoRequestPaths = new UmbracoRequestPaths(Options.Create(_globalSettings), hostingEnvironment, Options.Create(_umbracoRequestPathsOptions));
var uri = new Uri("http://test.com" + url);
var result = umbracoRequestPaths.IsClientSideRequest(uri.AbsolutePath);
@@ -60,7 +62,7 @@ public class UmbracoRequestPathsTests
public void Is_Client_Side_Request_InvalidPath_ReturnFalse()
{
var hostingEnvironment = CreateHostingEnvironment();
var umbracoRequestPaths = new UmbracoRequestPaths(Options.Create(_globalSettings), hostingEnvironment);
var umbracoRequestPaths = new UmbracoRequestPaths(Options.Create(_globalSettings), hostingEnvironment, Options.Create(_umbracoRequestPathsOptions));
// This URL is invalid. Default to false when the extension cannot be determined
var uri = new Uri("http://test.com/installing-modules+foobar+\"yipee\"");
@@ -85,11 +87,13 @@ public class UmbracoRequestPathsTests
[TestCase("http://www.domain.com/myvdir/umbraco/api/blah", "myvdir", false)]
[TestCase("http://www.domain.com/MyVdir/umbraco/api/blah", "/myvdir", false)]
[TestCase("http://www.domain.com/MyVdir/Umbraco/", "myvdir", true)]
// NOTE: this test case is false for now - will be true once the IsBackOfficeRequest tweak from the new management API is put into UmbracoRequestPaths
[TestCase("http://www.domain.com/umbraco/management/api/v1.0/my/controller/action/", "", false)]
public void Is_Back_Office_Request(string input, string virtualPath, bool expected)
{
var source = new Uri(input);
var hostingEnvironment = CreateHostingEnvironment(virtualPath);
var umbracoRequestPaths = new UmbracoRequestPaths(Options.Create(_globalSettings), hostingEnvironment);
var umbracoRequestPaths = new UmbracoRequestPaths(Options.Create(_globalSettings), hostingEnvironment, Options.Create(_umbracoRequestPathsOptions));
Assert.AreEqual(expected, umbracoRequestPaths.IsBackOfficeRequest(source.AbsolutePath));
}
@@ -106,7 +110,21 @@ public class UmbracoRequestPathsTests
{
var source = new Uri(input);
var hostingEnvironment = CreateHostingEnvironment();
var umbracoRequestPaths = new UmbracoRequestPaths(Options.Create(_globalSettings), hostingEnvironment);
var umbracoRequestPaths = new UmbracoRequestPaths(Options.Create(_globalSettings), hostingEnvironment, Options.Create(_umbracoRequestPathsOptions));
Assert.AreEqual(expected, umbracoRequestPaths.IsInstallerRequest(source.AbsolutePath));
}
[TestCase("http://www.domain.com/some/path", false)]
[TestCase("http://www.domain.com/umbraco/surface/blah", false)]
[TestCase("http://www.domain.com/umbraco/api/blah", false)]
[TestCase("http://www.domain.com/umbraco/management/api/v1.0/my/controller/action/", true)]
public void Force_Back_Office_Request_With_Request_Paths_Options(string input, bool expected)
{
var source = new Uri(input);
var hostingEnvironment = CreateHostingEnvironment();
var umbracoRequestPathsOptions = new UmbracoRequestPathsOptions();
umbracoRequestPathsOptions.IsBackOfficeRequest = _ => true;
var umbracoRequestPaths = new UmbracoRequestPaths(Options.Create(_globalSettings), hostingEnvironment, Options.Create(umbracoRequestPathsOptions));
Assert.AreEqual(expected, umbracoRequestPaths.IsBackOfficeRequest(source.AbsolutePath));
}
}

View File

@@ -24,12 +24,13 @@ public class BackOfficeCookieManagerTests
public void ShouldAuthenticateRequest_When_Not_Configured()
{
var globalSettings = new GlobalSettings();
var umbracoRequestPathsOptions = new UmbracoRequestPathsOptions();
var runtime = Mock.Of<IRuntimeState>(x => x.Level == RuntimeLevel.Install);
var mgr = new BackOfficeCookieManager(
Mock.Of<IUmbracoContextAccessor>(),
runtime,
new UmbracoRequestPaths(Options.Create(globalSettings), TestHelper.GetHostingEnvironment()),
new UmbracoRequestPaths(Options.Create(globalSettings), TestHelper.GetHostingEnvironment(), Options.Create(umbracoRequestPathsOptions)),
Mock.Of<IBasicAuthService>());
var result = mgr.ShouldAuthenticateRequest("/umbraco");
@@ -41,6 +42,7 @@ public class BackOfficeCookieManagerTests
public void ShouldAuthenticateRequest_When_Configured()
{
var globalSettings = new GlobalSettings();
var umbracoRequestPathsOptions = new UmbracoRequestPathsOptions();
var runtime = Mock.Of<IRuntimeState>(x => x.Level == RuntimeLevel.Run);
var mgr = new BackOfficeCookieManager(
@@ -49,7 +51,8 @@ public class BackOfficeCookieManagerTests
new UmbracoRequestPaths(
Options.Create(globalSettings),
Mock.Of<IHostingEnvironment>(x =>
x.ApplicationVirtualPath == "/" && x.ToAbsolute(globalSettings.UmbracoPath) == "/umbraco")),
x.ApplicationVirtualPath == "/" && x.ToAbsolute(globalSettings.UmbracoPath) == "/umbraco"),
Options.Create(umbracoRequestPathsOptions)),
Mock.Of<IBasicAuthService>());
var result = mgr.ShouldAuthenticateRequest("/umbraco");
@@ -61,6 +64,7 @@ public class BackOfficeCookieManagerTests
public void ShouldAuthenticateRequest_Is_Back_Office()
{
var globalSettings = new GlobalSettings();
var umbracoRequestPathsOptions = new UmbracoRequestPathsOptions();
var runtime = Mock.Of<IRuntimeState>(x => x.Level == RuntimeLevel.Run);
@@ -73,7 +77,8 @@ public class BackOfficeCookieManagerTests
Options.Create(globalSettings),
Mock.Of<IHostingEnvironment>(x =>
x.ApplicationVirtualPath == "/" && x.ToAbsolute(globalSettings.UmbracoPath) == "/umbraco" &&
x.ToAbsolute(Constants.SystemDirectories.Install) == "/install")),
x.ToAbsolute(Constants.SystemDirectories.Install) == "/install"),
Options.Create(umbracoRequestPathsOptions)),
Mock.Of<IBasicAuthService>());
var result = mgr.ShouldAuthenticateRequest(remainingTimeoutSecondsPath);
@@ -87,6 +92,7 @@ public class BackOfficeCookieManagerTests
public void ShouldAuthenticateRequest_Not_Back_Office()
{
var globalSettings = new GlobalSettings();
var umbracoRequestPathsOptions = new UmbracoRequestPathsOptions();
var runtime = Mock.Of<IRuntimeState>(x => x.Level == RuntimeLevel.Run);
@@ -97,7 +103,8 @@ public class BackOfficeCookieManagerTests
Options.Create(globalSettings),
Mock.Of<IHostingEnvironment>(x =>
x.ApplicationVirtualPath == "/" && x.ToAbsolute(globalSettings.UmbracoPath) == "/umbraco" &&
x.ToAbsolute(Constants.SystemDirectories.Install) == "/install")),
x.ToAbsolute(Constants.SystemDirectories.Install) == "/install"),
Options.Create(umbracoRequestPathsOptions)),
Mock.Of<IBasicAuthService>());
var result = mgr.ShouldAuthenticateRequest("/notbackoffice");