From dc9d4155a35459f9a33a9c9da852f194a23ea019 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Tue, 1 Nov 2022 11:15:31 +0100 Subject: [PATCH] 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> --- .../Security/BackOfficeController.cs | 77 +++++++++ .../Controllers/Security/Paths.cs | 12 ++ .../BackOfficeAuthBuilderExtensions.cs | 146 ++++++++++++++++++ .../ServicesBuilderExtensions.cs | 8 + .../ManagementApiComposer.cs | 37 ++++- ...ceAuthorizationInitializationMiddleware.cs | 62 ++++++++ src/Umbraco.Cms.ManagementApi/OpenApi.json | 61 ++++++++ .../Security/BackOfficeApplicationManager.cs | 138 +++++++++++++++++ .../Security/ClientSecretManager.cs | 16 ++ .../Security/IClientSecretManager.cs | 6 + .../Umbraco.Cms.ManagementApi.csproj | 3 + .../UmbracoBuilder.Configuration.cs | 2 +- .../Routing/UmbracoRequestPaths.cs | 17 +- .../Routing/UmbracoRequestPathsOptions.cs | 10 ++ src/Umbraco.Core/Umbraco.Core.csproj | 4 + .../Constants-OauthClientIds.cs | 23 +++ .../Configuration/NewBackOfficeSettings.cs | 10 ++ .../NewBackOfficeSettingsValidator.cs | 25 +++ .../HostedServices/OpenIddictCleanup.cs | 56 +++++++ .../Security/IBackOfficeApplicationManager.cs | 6 + .../Umbraco.New.Cms.Infrastructure.csproj | 4 + .../src/views/common/login.controller.js | 6 +- .../Objects/TestUmbracoContextFactory.cs | 10 +- .../Routing/UmbracoRequestPathsTests.cs | 26 +++- .../Security/BackOfficeCookieManagerTests.cs | 15 +- 25 files changed, 763 insertions(+), 17 deletions(-) create mode 100644 src/Umbraco.Cms.ManagementApi/Controllers/Security/BackOfficeController.cs create mode 100644 src/Umbraco.Cms.ManagementApi/Controllers/Security/Paths.cs create mode 100644 src/Umbraco.Cms.ManagementApi/DependencyInjection/BackOfficeAuthBuilderExtensions.cs create mode 100644 src/Umbraco.Cms.ManagementApi/Middleware/BackOfficeAuthorizationInitializationMiddleware.cs create mode 100644 src/Umbraco.Cms.ManagementApi/Security/BackOfficeApplicationManager.cs create mode 100644 src/Umbraco.Cms.ManagementApi/Security/ClientSecretManager.cs create mode 100644 src/Umbraco.Cms.ManagementApi/Security/IClientSecretManager.cs create mode 100644 src/Umbraco.Core/Routing/UmbracoRequestPathsOptions.cs create mode 100644 src/Umbraco.New.Cms.Core/Constants-OauthClientIds.cs create mode 100644 src/Umbraco.New.Cms.Core/Models/Configuration/NewBackOfficeSettings.cs create mode 100644 src/Umbraco.New.Cms.Core/Models/Configuration/NewBackOfficeSettingsValidator.cs create mode 100644 src/Umbraco.New.Cms.Infrastructure/HostedServices/OpenIddictCleanup.cs create mode 100644 src/Umbraco.New.Cms.Infrastructure/Security/IBackOfficeApplicationManager.cs diff --git a/src/Umbraco.Cms.ManagementApi/Controllers/Security/BackOfficeController.cs b/src/Umbraco.Cms.ManagementApi/Controllers/Security/BackOfficeController.cs new file mode 100644 index 0000000000..efdeede8f5 --- /dev/null +++ b/src/Umbraco.Cms.ManagementApi/Controllers/Security/BackOfficeController.cs @@ -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 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 }); + } +} diff --git a/src/Umbraco.Cms.ManagementApi/Controllers/Security/Paths.cs b/src/Umbraco.Cms.ManagementApi/Controllers/Security/Paths.cs new file mode 100644 index 0000000000..3ce1b7c3c6 --- /dev/null +++ b/src/Umbraco.Cms.ManagementApi/Controllers/Security/Paths.cs @@ -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}"; +} diff --git a/src/Umbraco.Cms.ManagementApi/DependencyInjection/BackOfficeAuthBuilderExtensions.cs b/src/Umbraco.Cms.ManagementApi/DependencyInjection/BackOfficeAuthBuilderExtensions.cs new file mode 100644 index 0000000000..ab132cbef2 --- /dev/null +++ b/src/Umbraco.Cms.ManagementApi/DependencyInjection/BackOfficeAuthBuilderExtensions.cs @@ -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(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(); + }) + + // 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(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + + 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(); + 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(); + 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); + } +} diff --git a/src/Umbraco.Cms.ManagementApi/DependencyInjection/ServicesBuilderExtensions.cs b/src/Umbraco.Cms.ManagementApi/DependencyInjection/ServicesBuilderExtensions.cs index cb739478c5..9a05c17bfe 100644 --- a/src/Umbraco.Cms.ManagementApi/DependencyInjection/ServicesBuilderExtensions.cs +++ b/src/Umbraco.Cms.ManagementApi/DependencyInjection/ServicesBuilderExtensions.cs @@ -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(); builder.Services.AddTransient(); + // TODO: handle new management API path in core UmbracoRequestPaths (it's a behavioural breaking change so it goes here for now) + builder.Services.Configure(options => + { + options.IsBackOfficeRequest = urlPath => urlPath.InvariantStartsWith($"/umbraco/management/api/"); + }); + return builder; } } diff --git a/src/Umbraco.Cms.ManagementApi/ManagementApiComposer.cs b/src/Umbraco.Cms.ManagementApi/ManagementApiComposer.cs index 071b6de63a..8140a7906a 100644 --- a/src/Umbraco.Cms.ManagementApi/ManagementApiComposer.cs +++ b/src/Umbraco.Cms.ManagementApi/ManagementApiComposer.cs @@ -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(), 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(), + }); + // 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(); + // TODO: when this is moved to core, make the AddUmbracoOptions extension private again and remove core InternalsVisibleTo for Umbraco.Cms.ManagementApi + builder.AddUmbracoOptions(); + builder.Services.AddSingleton, NewBackOfficeSettingsValidator>(); + builder.Services.Configure(options => { options.AddFilter(new UmbracoPipelineFilter( @@ -125,6 +145,7 @@ public class ManagementApiComposer : IComposer { GlobalSettings? settings = provider.GetRequiredService>().Value; IHostingEnvironment hostingEnvironment = provider.GetRequiredService(); + IClientSecretManager clientSecretManager = provider.GetRequiredService(); 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) + }; }); } }, diff --git a/src/Umbraco.Cms.ManagementApi/Middleware/BackOfficeAuthorizationInitializationMiddleware.cs b/src/Umbraco.Cms.ManagementApi/Middleware/BackOfficeAuthorizationInitializationMiddleware.cs new file mode 100644 index 0000000000..6ecebb3362 --- /dev/null +++ b/src/Umbraco.Cms.ManagementApi/Middleware/BackOfficeAuthorizationInitializationMiddleware.cs @@ -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(); + 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(); +// return builder; +// } +// } diff --git a/src/Umbraco.Cms.ManagementApi/OpenApi.json b/src/Umbraco.Cms.ManagementApi/OpenApi.json index e352c4d7da..d71ee73460 100644 --- a/src/Umbraco.Cms.ManagementApi/OpenApi.json +++ b/src/Umbraco.Cms.ManagementApi/OpenApi.json @@ -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" }, diff --git a/src/Umbraco.Cms.ManagementApi/Security/BackOfficeApplicationManager.cs b/src/Umbraco.Cms.ManagementApi/Security/BackOfficeApplicationManager.cs new file mode 100644 index 0000000000..1c0ea342e6 --- /dev/null +++ b/src/Umbraco.Cms.ManagementApi/Security/BackOfficeApplicationManager.cs @@ -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 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)}"); +} diff --git a/src/Umbraco.Cms.ManagementApi/Security/ClientSecretManager.cs b/src/Umbraco.Cms.ManagementApi/Security/ClientSecretManager.cs new file mode 100644 index 0000000000..cbea254ed4 --- /dev/null +++ b/src/Umbraco.Cms.ManagementApi/Security/ClientSecretManager.cs @@ -0,0 +1,16 @@ +namespace Umbraco.Cms.ManagementApi.Security; + +public class ClientSecretManager : IClientSecretManager +{ + private Dictionary _secretsByClientId = new(); + + public string Get(string clientId) + { + if (_secretsByClientId.ContainsKey(clientId) == false) + { + _secretsByClientId[clientId] = Guid.NewGuid().ToString("N"); + } + + return _secretsByClientId[clientId]; + } +} diff --git a/src/Umbraco.Cms.ManagementApi/Security/IClientSecretManager.cs b/src/Umbraco.Cms.ManagementApi/Security/IClientSecretManager.cs new file mode 100644 index 0000000000..7744169b7a --- /dev/null +++ b/src/Umbraco.Cms.ManagementApi/Security/IClientSecretManager.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.ManagementApi.Security; + +public interface IClientSecretManager +{ + string Get(string clientId); +} diff --git a/src/Umbraco.Cms.ManagementApi/Umbraco.Cms.ManagementApi.csproj b/src/Umbraco.Cms.ManagementApi/Umbraco.Cms.ManagementApi.csproj index 592f328ec2..372aeb1995 100644 --- a/src/Umbraco.Cms.ManagementApi/Umbraco.Cms.ManagementApi.csproj +++ b/src/Umbraco.Cms.ManagementApi/Umbraco.Cms.ManagementApi.csproj @@ -10,7 +10,10 @@ + + + diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index 31ef06c400..3be3815afa 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -14,7 +14,7 @@ namespace Umbraco.Cms.Core.DependencyInjection; /// public static partial class UmbracoBuilderExtensions { - private static IUmbracoBuilder AddUmbracoOptions(this IUmbracoBuilder builder, Action>? configure = null) + internal static IUmbracoBuilder AddUmbracoOptions(this IUmbracoBuilder builder, Action>? configure = null) where TOptions : class { UmbracoOptionsAttribute? umbracoOptionsAttribute = typeof(TOptions).GetCustomAttribute(); diff --git a/src/Umbraco.Core/Routing/UmbracoRequestPaths.cs b/src/Umbraco.Core/Routing/UmbracoRequestPaths.cs index fe1e83d254..02b13cf986 100644 --- a/src/Umbraco.Core/Routing/UmbracoRequestPaths.cs +++ b/src/Umbraco.Core/Routing/UmbracoRequestPaths.cs @@ -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; + + [Obsolete("Use constructor that takes IOptions - Will be removed in Umbraco 13")] + public UmbracoRequestPaths(IOptions globalSettings, IHostingEnvironment hostingEnvironment) + : this(globalSettings, hostingEnvironment, StaticServiceProvider.Instance.GetRequiredService>()) + { + } /// /// Initializes a new instance of the class. /// - public UmbracoRequestPaths(IOptions globalSettings, IHostingEnvironment hostingEnvironment) + public UmbracoRequestPaths(IOptions globalSettings, IHostingEnvironment hostingEnvironment, IOptions 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; } /// @@ -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} diff --git a/src/Umbraco.Core/Routing/UmbracoRequestPathsOptions.cs b/src/Umbraco.Core/Routing/UmbracoRequestPathsOptions.cs new file mode 100644 index 0000000000..91f13eab3b --- /dev/null +++ b/src/Umbraco.Core/Routing/UmbracoRequestPathsOptions.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Core.Routing; + +public class UmbracoRequestPathsOptions +{ + /// + /// 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. + /// + public Func IsBackOfficeRequest { get; set; } = _ => false; +} diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 03a292c880..dc480b160f 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -47,6 +47,10 @@ <_Parameter1>DynamicProxyGenAssembly2 + + + <_Parameter1>Umbraco.Cms.ManagementApi + diff --git a/src/Umbraco.New.Cms.Core/Constants-OauthClientIds.cs b/src/Umbraco.New.Cms.Core/Constants-OauthClientIds.cs new file mode 100644 index 0000000000..2fdc54e011 --- /dev/null +++ b/src/Umbraco.New.Cms.Core/Constants-OauthClientIds.cs @@ -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 + { + /// + /// Client ID used for default back-office access + /// + public const string BackOffice = "umbraco-back-office"; + + /// + /// Client ID used for Swagger API access + /// + public const string Swagger = "umbraco-swagger"; + + /// + /// Client ID used for Postman API access + /// + public const string Postman = "umbraco-postman"; + } +} diff --git a/src/Umbraco.New.Cms.Core/Models/Configuration/NewBackOfficeSettings.cs b/src/Umbraco.New.Cms.Core/Models/Configuration/NewBackOfficeSettings.cs new file mode 100644 index 0000000000..cad7b8868d --- /dev/null +++ b/src/Umbraco.New.Cms.Core/Models/Configuration/NewBackOfficeSettings.cs @@ -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; +} diff --git a/src/Umbraco.New.Cms.Core/Models/Configuration/NewBackOfficeSettingsValidator.cs b/src/Umbraco.New.Cms.Core/Models/Configuration/NewBackOfficeSettingsValidator.cs new file mode 100644 index 0000000000..bb1a2eda3d --- /dev/null +++ b/src/Umbraco.New.Cms.Core/Models/Configuration/NewBackOfficeSettingsValidator.cs @@ -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 +{ + 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; + } +} diff --git a/src/Umbraco.New.Cms.Infrastructure/HostedServices/OpenIddictCleanup.cs b/src/Umbraco.New.Cms.Infrastructure/HostedServices/OpenIddictCleanup.cs new file mode 100644 index 0000000000..37a6e4caa6 --- /dev/null +++ b/src/Umbraco.New.Cms.Infrastructure/HostedServices/OpenIddictCleanup.cs @@ -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 _logger; + private readonly IServiceProvider _provider; + + public OpenIddictCleanup( + ILogger 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() + ?? 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() + ?? 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"); + } + } +} diff --git a/src/Umbraco.New.Cms.Infrastructure/Security/IBackOfficeApplicationManager.cs b/src/Umbraco.New.Cms.Infrastructure/Security/IBackOfficeApplicationManager.cs new file mode 100644 index 0000000000..068f5df472 --- /dev/null +++ b/src/Umbraco.New.Cms.Infrastructure/Security/IBackOfficeApplicationManager.cs @@ -0,0 +1,6 @@ +namespace Umbraco.New.Cms.Infrastructure.Security; + +public interface IBackOfficeApplicationManager +{ + Task EnsureBackOfficeApplicationAsync(Uri backOfficeUrl, CancellationToken cancellationToken = default); +} diff --git a/src/Umbraco.New.Cms.Infrastructure/Umbraco.New.Cms.Infrastructure.csproj b/src/Umbraco.New.Cms.Infrastructure/Umbraco.New.Cms.Infrastructure.csproj index 8dc5d3cc00..82159079a4 100644 --- a/src/Umbraco.New.Cms.Infrastructure/Umbraco.New.Cms.Infrastructure.csproj +++ b/src/Umbraco.New.Cms.Infrastructure/Umbraco.New.Cms.Infrastructure.csproj @@ -10,4 +10,8 @@ + + + + diff --git a/src/Umbraco.Web.UI.Client/src/views/common/login.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/login.controller.js index 5827f7e530..13ca4cb193 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/login.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/login.controller.js @@ -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 diff --git a/tests/Umbraco.Tests.UnitTests/TestHelpers/Objects/TestUmbracoContextFactory.cs b/tests/Umbraco.Tests.UnitTests/TestHelpers/Objects/TestUmbracoContextFactory.cs index 94a64b31c9..2099d1d537 100644 --- a/tests/Umbraco.Tests.UnitTests/TestHelpers/Objects/TestUmbracoContextFactory.cs +++ b/tests/Umbraco.Tests.UnitTests/TestHelpers/Objects/TestUmbracoContextFactory.cs @@ -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(); } + if (umbracoRequestPathsOptions == null) + { + umbracoRequestPathsOptions = new UmbracoRequestPathsOptions(); + } + var contentCache = new Mock(); var mediaCache = new Mock(); var snapshot = new Mock(); @@ -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), diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/UmbracoRequestPathsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/UmbracoRequestPathsTests.cs index 744febae67..4ed6ce842c 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/UmbracoRequestPathsTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/UmbracoRequestPathsTests.cs @@ -18,10 +18,12 @@ public class UmbracoRequestPathsTests { _hostEnvironment = Mock.Of(); _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)); + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Security/BackOfficeCookieManagerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Security/BackOfficeCookieManagerTests.cs index b1881c132e..c59f9db68f 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Security/BackOfficeCookieManagerTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Security/BackOfficeCookieManagerTests.cs @@ -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(x => x.Level == RuntimeLevel.Install); var mgr = new BackOfficeCookieManager( Mock.Of(), runtime, - new UmbracoRequestPaths(Options.Create(globalSettings), TestHelper.GetHostingEnvironment()), + new UmbracoRequestPaths(Options.Create(globalSettings), TestHelper.GetHostingEnvironment(), Options.Create(umbracoRequestPathsOptions)), Mock.Of()); 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(x => x.Level == RuntimeLevel.Run); var mgr = new BackOfficeCookieManager( @@ -49,7 +51,8 @@ public class BackOfficeCookieManagerTests new UmbracoRequestPaths( Options.Create(globalSettings), Mock.Of(x => - x.ApplicationVirtualPath == "/" && x.ToAbsolute(globalSettings.UmbracoPath) == "/umbraco")), + x.ApplicationVirtualPath == "/" && x.ToAbsolute(globalSettings.UmbracoPath) == "/umbraco"), + Options.Create(umbracoRequestPathsOptions)), Mock.Of()); 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(x => x.Level == RuntimeLevel.Run); @@ -73,7 +77,8 @@ public class BackOfficeCookieManagerTests Options.Create(globalSettings), Mock.Of(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()); 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(x => x.Level == RuntimeLevel.Run); @@ -97,7 +103,8 @@ public class BackOfficeCookieManagerTests Options.Create(globalSettings), Mock.Of(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()); var result = mgr.ShouldAuthenticateRequest("/notbackoffice");