Skip to content

Commit

Permalink
API access with client credentials (core functionality) (#16817)
Browse files Browse the repository at this point in the history
* First stab

* Delivery API client credentials + a little refactor to ensure unique client IDs

* Introduce user type

* Support user type in the Management API

* Clean up TODOs

* Update API user last login date when issuing a token

* Better error reporting for mismatched user types

* Do not allow password change or reset for API users

* Update OpenApi.json

* Revert change

* Remove obsolete comment

* Make applicable classes abstract or sealed

* Review changes

* Add endpoint for retrieving all user client IDs
  • Loading branch information
kjac authored Jul 29, 2024
1 parent 0eef280 commit 68db079
Show file tree
Hide file tree
Showing 53 changed files with 1,444 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,14 @@ private static void ConfigureOpenIddict(IUmbracoBuilder builder)
.RequireProofKeyForCodeExchange()
.AllowRefreshTokenFlow();
// Enable the client credentials flow.
options.AllowClientCredentialsFlow();
// Register the ASP.NET Core host and configure for custom authentication endpoint.
options
.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableTokenEndpointPassthrough()
.EnableLogoutEndpointPassthrough();
// Enable reference tokens
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using Umbraco.Cms.Api.Delivery.Routing;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Web.Common.Security;
using Umbraco.Extensions;
Expand All @@ -25,22 +27,47 @@ namespace Umbraco.Cms.Api.Delivery.Controllers.Security;
[ApiExplorerSettings(IgnoreApi = true)]
public class MemberController : DeliveryApiControllerBase
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IMemberSignInManager _memberSignInManager;
private readonly IMemberManager _memberManager;
private readonly IMemberClientCredentialsManager _memberClientCredentialsManager;
private readonly DeliveryApiSettings _deliveryApiSettings;
private readonly ILogger<MemberController> _logger;


[Obsolete("Please use the non-obsolete constructor. Will be removed in V16.")]
public MemberController(
IHttpContextAccessor httpContextAccessor,
IMemberSignInManager memberSignInManager,
IMemberManager memberManager,
IOptions<DeliveryApiSettings> deliveryApiSettings,
ILogger<MemberController> logger)
: this(memberSignInManager, memberManager, StaticServiceProvider.Instance.GetRequiredService<IMemberClientCredentialsManager>(), deliveryApiSettings, logger)
{
}

[Obsolete("Please use the non-obsolete constructor. Will be removed in V16.")]
public MemberController(
IHttpContextAccessor httpContextAccessor,
IMemberSignInManager memberSignInManager,
IMemberManager memberManager,
IMemberClientCredentialsManager memberClientCredentialsManager,
IOptions<DeliveryApiSettings> deliveryApiSettings,
ILogger<MemberController> logger)
: this(memberSignInManager, memberManager, memberClientCredentialsManager, deliveryApiSettings, logger)
{
}

[ActivatorUtilitiesConstructor]
public MemberController(
IMemberSignInManager memberSignInManager,
IMemberManager memberManager,
IMemberClientCredentialsManager memberClientCredentialsManager,
IOptions<DeliveryApiSettings> deliveryApiSettings,
ILogger<MemberController> logger)
{
_httpContextAccessor = httpContextAccessor;
_memberSignInManager = memberSignInManager;
_memberManager = memberManager;
_memberClientCredentialsManager = memberClientCredentialsManager;
_logger = logger;
_deliveryApiSettings = deliveryApiSettings.Value;
}
Expand All @@ -49,16 +76,13 @@ public MemberController(
[MapToApiVersion("1.0")]
public async Task<IActionResult> Authorize()
{
// in principle this is not necessary for now, since the member application has been removed, thus making
// the member client ID invalid for the authentication code flow. However, if we ever add additional flows
// to the API, we should perform this check, so we might as well include it upfront.
if (_deliveryApiSettings.MemberAuthorizationIsEnabled() is false)
// the Authorize endpoint is not allowed unless authorization code flow is enabled.
if (_deliveryApiSettings.MemberAuthorization?.AuthorizationCodeFlow?.Enabled is not true)
{
return BadRequest("Member authorization is not allowed.");
}

HttpContext context = _httpContextAccessor.GetRequiredHttpContext();
OpenIddictRequest? request = context.GetOpenIddictServerRequest();
OpenIddictRequest? request = HttpContext.GetOpenIddictServerRequest();
if (request is null)
{
return BadRequest("Unable to obtain OpenID data from the current request.");
Expand All @@ -75,6 +99,41 @@ public async Task<IActionResult> Authorize()
: await AuthorizeExternal(request);
}

[HttpPost("token")]
[MapToApiVersion("1.0")]
public async Task<IActionResult> Token()
{
OpenIddictRequest? request = HttpContext.GetOpenIddictServerRequest();
if (request is null)
{
return BadRequest("Unable to obtain OpenID data from the current request.");
}

// authorization code flow or refresh token flow?
if ((request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType()) && _deliveryApiSettings.MemberAuthorization?.AuthorizationCodeFlow?.Enabled is true)
{
// attempt to authorize against the supplied the authorization code
AuthenticateResult authenticateResult = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);

return authenticateResult is { Succeeded: true, Principal: not null }
? new SignInResult(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, authenticateResult.Principal)
: BadRequest("The supplied authorization was not be verified.");
}

// client credentials flow?
if (request.IsClientCredentialsGrantType() && _deliveryApiSettings.MemberAuthorization?.ClientCredentialsFlow?.Enabled is true)
{
// if we get here, the client ID and secret are valid (verified by OpenIddict)

MemberIdentityUser? member = await _memberClientCredentialsManager.FindMemberAsync(request.ClientId!);
return member is not null
? await SignInMember(member, request)
: BadRequest("Invalid client or client configuration.");
}

throw new InvalidOperationException("The requested grant type is not supported.");
}

[HttpGet("signout")]
[MapToApiVersion("1.0")]
public async Task<IActionResult> Signout()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Infrastructure.Security;
using Umbraco.Cms.Web.Common.ApplicationBuilder;

Expand Down Expand Up @@ -60,6 +61,7 @@ public static IUmbracoBuilder AddDeliveryApi(this IUmbracoBuilder builder)
builder.Services.AddSingleton<IApiMediaQueryService, ApiMediaQueryService>();
builder.Services.AddTransient<IMemberApplicationManager, MemberApplicationManager>();
builder.Services.AddTransient<IRequestMemberAccessService, RequestMemberAccessService>();
builder.Services.AddScoped<IMemberClientCredentialsManager, MemberClientCredentialsManager>();

builder.Services.ConfigureOptions<ConfigureUmbracoDeliveryApiSwaggerGenOptions>();
builder.AddUmbracoApiOpenApiUI();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Security;

Expand All @@ -16,16 +17,19 @@ internal sealed class InitializeMemberApplicationNotificationHandler : INotifica
private readonly ILogger<InitializeMemberApplicationNotificationHandler> _logger;
private readonly DeliveryApiSettings _deliveryApiSettings;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly IMemberClientCredentialsManager _memberClientCredentialsManager;

public InitializeMemberApplicationNotificationHandler(
IRuntimeState runtimeState,
IOptions<DeliveryApiSettings> deliveryApiSettings,
ILogger<InitializeMemberApplicationNotificationHandler> logger,
IServiceScopeFactory serviceScopeFactory)
IServiceScopeFactory serviceScopeFactory,
IMemberClientCredentialsManager memberClientCredentialsManager)
{
_runtimeState = runtimeState;
_logger = logger;
_serviceScopeFactory = serviceScopeFactory;
_memberClientCredentialsManager = memberClientCredentialsManager;
_deliveryApiSettings = deliveryApiSettings.Value;
}

Expand All @@ -41,6 +45,12 @@ public async Task HandleAsync(UmbracoApplicationStartingNotification notificatio
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMemberApplicationManager memberApplicationManager = scope.ServiceProvider.GetRequiredService<IMemberApplicationManager>();

await HandleMemberApplication(memberApplicationManager, cancellationToken);
await HandleMemberClientCredentialsApplication(memberApplicationManager, cancellationToken);
}

private async Task HandleMemberApplication(IMemberApplicationManager memberApplicationManager, CancellationToken cancellationToken)
{
if (_deliveryApiSettings.MemberAuthorization?.AuthorizationCodeFlow?.Enabled is not true)
{
await memberApplicationManager.DeleteMemberApplicationAsync(cancellationToken);
Expand All @@ -66,6 +76,21 @@ await memberApplicationManager.EnsureMemberApplicationAsync(
cancellationToken);
}

private async Task HandleMemberClientCredentialsApplication(IMemberApplicationManager memberApplicationManager, CancellationToken cancellationToken)
{
if (_deliveryApiSettings.MemberAuthorization?.ClientCredentialsFlow?.Enabled is not true)
{
// disabled
return;
}

IEnumerable<MemberClientCredentials> memberClientCredentials = await _memberClientCredentialsManager.GetAllAsync();
foreach (MemberClientCredentials memberClientCredential in memberClientCredentials)
{
await memberApplicationManager.EnsureMemberClientCredentialsApplicationAsync(memberClientCredential.ClientId, memberClientCredential.ClientSecret, cancellationToken);
}
}

private bool ValidateRedirectUrls(Uri[] redirectUrls)
{
if (redirectUrls.Any() is false)
Expand Down
22 changes: 22 additions & 0 deletions src/Umbraco.Cms.Api.Delivery/Security/MemberApplicationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,26 @@ public async Task EnsureMemberApplicationAsync(IEnumerable<Uri> loginRedirectUrl

public async Task DeleteMemberApplicationAsync(CancellationToken cancellationToken = default)
=> await Delete(Constants.OAuthClientIds.Member, cancellationToken);

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

await CreateOrUpdate(applicationDescriptor, cancellationToken);
}

public async Task DeleteMemberClientCredentialsApplicationAsync(string clientId, CancellationToken cancellationToken = default)
=> await Delete(clientId, cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public class BackOfficeController : SecurityControllerBase
private readonly IBackOfficeTwoFactorOptions _backOfficeTwoFactorOptions;
private readonly IUserTwoFactorLoginService _userTwoFactorLoginService;
private readonly IBackOfficeExternalLoginService _externalLoginService;
private readonly IBackOfficeUserClientCredentialsManager _backOfficeUserClientCredentialsManager;

private const string RedirectFlowParameter = "flow";
private const string RedirectStatusParameter = "status";
Expand All @@ -55,7 +56,8 @@ public BackOfficeController(
ILogger<BackOfficeController> logger,
IBackOfficeTwoFactorOptions backOfficeTwoFactorOptions,
IUserTwoFactorLoginService userTwoFactorLoginService,
IBackOfficeExternalLoginService externalLoginService)
IBackOfficeExternalLoginService externalLoginService,
IBackOfficeUserClientCredentialsManager backOfficeUserClientCredentialsManager)
{
_httpContextAccessor = httpContextAccessor;
_backOfficeSignInManager = backOfficeSignInManager;
Expand All @@ -65,6 +67,7 @@ public BackOfficeController(
_backOfficeTwoFactorOptions = backOfficeTwoFactorOptions;
_userTwoFactorLoginService = userTwoFactorLoginService;
_externalLoginService = externalLoginService;
_backOfficeUserClientCredentialsManager = backOfficeUserClientCredentialsManager;
}

[HttpPost("login")]
Expand Down Expand Up @@ -180,6 +183,52 @@ public async Task<IActionResult> Authorize(CancellationToken cancellationToken)
: await AuthorizeExternal(request);
}

[AllowAnonymous]
[HttpPost("token")]
[MapToApiVersion("1.0")]
public async Task<IActionResult> Token()
{
HttpContext context = _httpContextAccessor.GetRequiredHttpContext();
OpenIddictRequest? request = context.GetOpenIddictServerRequest();
if (request == null)
{
return BadRequest("Unable to obtain OpenID data from the current request");
}

if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType())
{
// attempt to authorize against the supplied the authorization code
AuthenticateResult authenticateResult = await context.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);

return authenticateResult is { Succeeded: true, Principal: not null }
? new SignInResult(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, authenticateResult.Principal)
: BadRequest("The supplied authorization could not be verified.");
}

if (request.IsClientCredentialsGrantType())
{
// if we get here, the client ID and secret are valid (verified by OpenIddict)

// grab the user associated with the client ID
BackOfficeIdentityUser? associatedUser = await _backOfficeUserClientCredentialsManager.FindUserAsync(request.ClientId!);

if (associatedUser is not null)
{
// log current datetime as last login (this also ensures that the user is not flagged as inactive)
associatedUser.LastLoginDateUtc = DateTime.UtcNow;
await _backOfficeUserManager.UpdateAsync(associatedUser);

return await SignInBackOfficeUser(associatedUser, request);
}

// if this happens, the OpenIddict applications have somehow gone out of sync with the Umbraco users
_logger.LogError("The user associated with the client ID ({clientId}) could not be found", request.ClientId);
return BadRequest("The user associated with the client ID could not be found");
}

throw new InvalidOperationException("The requested grant type is not supported.");
}

[AllowAnonymous]
[HttpGet("signout")]
[MapToApiVersion("1.0")]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core.Security.OperationStatus;

namespace Umbraco.Cms.Api.Management.Controllers.User.ClientCredentials;

[ApiExplorerSettings(GroupName = "User")]
public abstract class ClientCredentialsUserControllerBase : UserControllerBase
{
protected IActionResult BackOfficeUserClientCredentialsOperationStatusResult(BackOfficeUserClientCredentialsOperationStatus status) =>
OperationStatusResult(status, problemDetailsBuilder => status switch
{
BackOfficeUserClientCredentialsOperationStatus.InvalidUser => BadRequest(problemDetailsBuilder
.WithTitle("Invalid user")
.WithDetail("The specified user does not support this operation. Possibly caused by a mismatched client ID or an inapplicable user type.")
.Build()),
BackOfficeUserClientCredentialsOperationStatus.DuplicateClientId => BadRequest(problemDetailsBuilder
.WithTitle("Duplicate client ID")
.WithDetail("The specified client ID is already in use. Choose another client ID.")
.Build()),
_ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder
.WithTitle("Unknown client credentials operation status.")
.Build()),
});
}
Loading

0 comments on commit 68db079

Please sign in to comment.