Skip to content

Commit

Permalink
OpenId Connect authentication for new management API (#13318)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
kjac and Zeegaan authored Nov 1, 2022
1 parent 746ab4b commit dc9d415
Show file tree
Hide file tree
Showing 25 changed files with 763 additions and 17 deletions.
Original file line number Diff line number Diff line change
@@ -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 });
}
}
12 changes: 12 additions & 0 deletions src/Umbraco.Cms.ManagementApi/Controllers/Security/Paths.cs
Original file line number Diff line number Diff line change
@@ -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}";
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -17,6 +19,12 @@ internal static IUmbracoBuilder AddServices(this IUmbracoBuilder builder)
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;
}
}
37 changes: 33 additions & 4 deletions src/Umbraco.Cms.ManagementApi/ManagementApiComposer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,18 @@
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;
Expand All @@ -44,7 +45,8 @@ public void Compose(IUmbracoBuilder builder)
.AddTrees()
.AddFactories()
.AddServices()
.AddMappers();
.AddMappers()
.AddBackOfficeAuthentication();

services.AddApiVersioning(options =>
{
Expand All @@ -65,6 +67,20 @@ public void Compose(IUmbracoBuilder builder)
{
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 =>
Expand All @@ -78,6 +94,10 @@ public void Compose(IUmbracoBuilder builder)
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(
Expand Down Expand Up @@ -125,6 +145,7 @@ public void Compose(IUmbracoBuilder builder)
{
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 =>
Expand All @@ -141,6 +162,14 @@ public void Compose(IUmbracoBuilder builder)
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)
};
});
}
},
Expand Down
Loading

0 comments on commit dc9d415

Please sign in to comment.