diff --git a/Directory.Build.props b/Directory.Build.props index f9fb83457d..0df4f75e8a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - net8.0 + net9.0 Umbraco HQ Umbraco Copyright © Umbraco $([System.DateTime]::Today.ToString('yyyy')) @@ -30,7 +30,7 @@ false - true + false 14.0.0 true true diff --git a/Directory.Packages.props b/Directory.Packages.props index 47df4cf510..e60b2c718a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,35 +5,35 @@ - + - - + - - - + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + @@ -47,7 +47,7 @@ - + @@ -58,24 +58,24 @@ - - - - + + + + - - - + + + - - + + - - - + + + - + @@ -84,10 +84,15 @@ - + - + + + + + + \ No newline at end of file diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index 9e7207bf07..b7fadde6d5 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -287,8 +287,8 @@ stages: displayName: Integration Tests (SQLite) strategy: matrix: - Windows: - vmImage: 'windows-latest' +# Windows: +# vmImage: 'windows-latest' Linux: vmImage: 'ubuntu-latest' macOS: diff --git a/global.json b/global.json index e972eb192a..9c2a135743 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,7 @@ { "sdk": { - "version": "8.0.300", - "rollForward": "latestFeature" + "version": "9.0.100-rc.1.24452.12", + "rollForward": "latestFeature", + "allowPrerelease": true } } diff --git a/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs index 98068791af..f34fc2dde9 100644 --- a/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs +++ b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs @@ -54,10 +54,14 @@ public static class UmbracoBuilderAuthExtensions .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 diff --git a/src/Umbraco.Cms.Api.Common/OpenApi/SchemaIdHandler.cs b/src/Umbraco.Cms.Api.Common/OpenApi/SchemaIdHandler.cs index c08e0be19a..6e96cbed42 100644 --- a/src/Umbraco.Cms.Api.Common/OpenApi/SchemaIdHandler.cs +++ b/src/Umbraco.Cms.Api.Common/OpenApi/SchemaIdHandler.cs @@ -36,7 +36,7 @@ public class SchemaIdHandler : ISchemaIdHandler // first grab the "non-generic" part of any generic type name (i.e. "PagedViewModel`1" becomes "PagedViewModel") .Split('`').First() // then remove the "ViewModel" postfix from type names - .TrimEnd("ViewModel"); + .TrimEndExact("ViewModel"); private string HandleGenerics(string name, Type type) { diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Security/MemberController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Security/MemberController.cs index 80b42611b8..a5b085073b 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Security/MemberController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Security/MemberController.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authentication; 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; @@ -12,6 +13,7 @@ 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; @@ -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 _logger; + + [Obsolete("Please use the non-obsolete constructor. Will be removed in V16.")] public MemberController( IHttpContextAccessor httpContextAccessor, IMemberSignInManager memberSignInManager, IMemberManager memberManager, IOptions deliveryApiSettings, ILogger logger) + : this(memberSignInManager, memberManager, StaticServiceProvider.Instance.GetRequiredService(), 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, + ILogger logger) + : this(memberSignInManager, memberManager, memberClientCredentialsManager, deliveryApiSettings, logger) + { + } + + [ActivatorUtilitiesConstructor] + public MemberController( + IMemberSignInManager memberSignInManager, + IMemberManager memberManager, + IMemberClientCredentialsManager memberClientCredentialsManager, + IOptions deliveryApiSettings, + ILogger logger) { - _httpContextAccessor = httpContextAccessor; _memberSignInManager = memberSignInManager; _memberManager = memberManager; + _memberClientCredentialsManager = memberClientCredentialsManager; _logger = logger; _deliveryApiSettings = deliveryApiSettings.Value; } @@ -49,25 +76,31 @@ public class MemberController : DeliveryApiControllerBase [MapToApiVersion("1.0")] public async Task 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."); + return BadRequest(new OpenIddictResponse + { + Error = "Not allowed", ErrorDescription = "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."); + return BadRequest(new OpenIddictResponse + { + Error = "No context found", ErrorDescription = "Unable to obtain context from the current request." + }); } // make sure this endpoint ONLY handles member authentication if (request.ClientId is not Constants.OAuthClientIds.Member) { - return BadRequest("The specified client ID cannot be used here."); + return BadRequest(new OpenIddictResponse + { + Error = "Invalid 'client ID'", ErrorDescription = "The specified 'client_id' is not valid." + }); } return request.IdentityProvider.IsNullOrWhiteSpace() @@ -75,6 +108,50 @@ public class MemberController : DeliveryApiControllerBase : await AuthorizeExternal(request); } + [HttpPost("token")] + [MapToApiVersion("1.0")] + public async Task Token() + { + OpenIddictRequest? request = HttpContext.GetOpenIddictServerRequest(); + if (request is null) + { + return BadRequest(new OpenIddictResponse + { + Error = "No context found", ErrorDescription = "Unable to obtain context 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(new OpenIddictResponse + { + Error = "Authorization failed", ErrorDescription = "The supplied authorization could 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(new OpenIddictResponse + { + Error = "Authorization failed", ErrorDescription = "Invalid 'client_id' or client configuration." + }); + } + + throw new InvalidOperationException("The requested grant type is not supported."); + } + [HttpGet("signout")] [MapToApiVersion("1.0")] public async Task Signout() @@ -100,7 +177,10 @@ public class MemberController : DeliveryApiControllerBase if (member is null) { _logger.LogError("The member with username {userName} was successfully authorized, but could not be retrieved by the member manager", userName); - return BadRequest("The member could not be found."); + return BadRequest(new OpenIddictResponse + { + Error = "Authorization failed", ErrorDescription = "The member associated with the supplied 'client_id' could not be found." + }); } return await SignInMember(member, request); @@ -128,7 +208,10 @@ public class MemberController : DeliveryApiControllerBase if (member is null) { _logger.LogError("A member was successfully authorized using external authentication, but could not be retrieved by the member manager"); - return BadRequest("The member could not be found."); + return BadRequest(new OpenIddictResponse + { + Error = "Authorization failed", ErrorDescription = "The member associated with the supplied 'client_id' could not be found." + }); } // update member authentication tokens if succeeded diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs index 5ecc856f3b..34f8b058e6 100644 --- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -21,6 +21,7 @@ using Umbraco.Cms.Core.Configuration.Models; 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; @@ -60,6 +61,7 @@ public static class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddScoped(); builder.Services.ConfigureOptions(); builder.AddUmbracoApiOpenApiUI(); diff --git a/src/Umbraco.Cms.Api.Delivery/Handlers/InitializeMemberApplicationNotificationHandler.cs b/src/Umbraco.Cms.Api.Delivery/Handlers/InitializeMemberApplicationNotificationHandler.cs index 659a1ba835..43653239b3 100644 --- a/src/Umbraco.Cms.Api.Delivery/Handlers/InitializeMemberApplicationNotificationHandler.cs +++ b/src/Umbraco.Cms.Api.Delivery/Handlers/InitializeMemberApplicationNotificationHandler.cs @@ -5,6 +5,7 @@ using Umbraco.Cms.Core; 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; @@ -16,16 +17,19 @@ internal sealed class InitializeMemberApplicationNotificationHandler : INotifica private readonly ILogger _logger; private readonly DeliveryApiSettings _deliveryApiSettings; private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly IMemberClientCredentialsManager _memberClientCredentialsManager; public InitializeMemberApplicationNotificationHandler( IRuntimeState runtimeState, IOptions deliveryApiSettings, ILogger logger, - IServiceScopeFactory serviceScopeFactory) + IServiceScopeFactory serviceScopeFactory, + IMemberClientCredentialsManager memberClientCredentialsManager) { _runtimeState = runtimeState; _logger = logger; _serviceScopeFactory = serviceScopeFactory; + _memberClientCredentialsManager = memberClientCredentialsManager; _deliveryApiSettings = deliveryApiSettings.Value; } @@ -41,6 +45,12 @@ internal sealed class InitializeMemberApplicationNotificationHandler : INotifica using IServiceScope scope = _serviceScopeFactory.CreateScope(); IMemberApplicationManager memberApplicationManager = scope.ServiceProvider.GetRequiredService(); + 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); @@ -66,6 +76,21 @@ internal sealed class InitializeMemberApplicationNotificationHandler : INotifica cancellationToken); } + private async Task HandleMemberClientCredentialsApplication(IMemberApplicationManager memberApplicationManager, CancellationToken cancellationToken) + { + if (_deliveryApiSettings.MemberAuthorization?.ClientCredentialsFlow?.Enabled is not true) + { + // disabled + return; + } + + IEnumerable 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) diff --git a/src/Umbraco.Cms.Api.Delivery/Security/MemberApplicationManager.cs b/src/Umbraco.Cms.Api.Delivery/Security/MemberApplicationManager.cs index 92ca409194..67cfb4b7cf 100644 --- a/src/Umbraco.Cms.Api.Delivery/Security/MemberApplicationManager.cs +++ b/src/Umbraco.Cms.Api.Delivery/Security/MemberApplicationManager.cs @@ -64,4 +64,26 @@ public class MemberApplicationManager : OpenIdDictApplicationManagerBase, IMembe 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); } diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs index 5969e0a788..8a078d7f0d 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs @@ -85,7 +85,7 @@ internal sealed class ApiMediaQueryService : IApiMediaQueryService return null; } - var childrenOf = fetch.TrimStart(childrenOfParameter); + var childrenOf = fetch.TrimStartExact(childrenOfParameter); if (childrenOf.IsNullOrWhiteSpace()) { // this mirrors the current behavior of the Content Delivery API :-) diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs b/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs index 882525c8d0..4b9efc03a5 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs @@ -66,7 +66,7 @@ internal sealed class RequestRedirectService : RoutingServiceBase, IRequestRedir } // important: redirect URLs are always tracked without trailing slashes - IRedirectUrl? redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath.TrimEnd("/"), culture); + IRedirectUrl? redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath.TrimEndExact("/"), culture); IPublishedContent? content = redirectUrl != null ? _apiPublishedContentCache.GetById(redirectUrl.ContentKey) : null; diff --git a/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj b/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj index 4479bc92e2..3e0075acfd 100644 --- a/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj +++ b/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj @@ -13,6 +13,10 @@ + + + false + <_Parameter1>Umbraco.Tests.UnitTests diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentCollectionControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentCollectionControllerBase.cs index 95f4bb5498..8012da4669 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentCollectionControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentCollectionControllerBase.cs @@ -14,7 +14,7 @@ namespace Umbraco.Cms.Api.Management.Controllers.Content; public abstract class ContentCollectionControllerBase : ManagementApiControllerBase where TContent : class, IContentBase where TCollectionResponseModel : ContentResponseModelBase - where TValueResponseModelBase : ValueModelBase + where TValueResponseModelBase : ValueResponseModelBase where TVariantResponseModel : VariantResponseModelBase { private readonly IUmbracoMapper _mapper; diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/Collection/DocumentCollectionControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/Collection/DocumentCollectionControllerBase.cs index 71dfc75fb1..b4ac5a86da 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/Collection/DocumentCollectionControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/Collection/DocumentCollectionControllerBase.cs @@ -15,7 +15,7 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document.Collection; [VersionedApiBackOfficeRoute($"{Constants.Web.RoutePath.Collection}/{Constants.UdiEntityType.Document}")] [ApiExplorerSettings(GroupName = nameof(Constants.UdiEntityType.Document))] [Authorize(Policy = AuthorizationPolicies.TreeAccessDocuments)] -public abstract class DocumentCollectionControllerBase : ContentCollectionControllerBase +public abstract class DocumentCollectionControllerBase : ContentCollectionControllerBase { protected DocumentCollectionControllerBase(IUmbracoMapper mapper) : base(mapper) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs index f80203d135..05f029f582 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs @@ -12,6 +12,7 @@ using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.Document; [ApiVersion("1.0")] +[ApiVersion("1.1")] public class ValidateUpdateDocumentController : UpdateDocumentControllerBase { private readonly IContentEditingService _contentEditingService; @@ -32,10 +33,35 @@ public class ValidateUpdateDocumentController : UpdateDocumentControllerBase [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [Obsolete("Please use version 1.1 of this API. Will be removed in V16.")] public async Task Validate(CancellationToken cancellationToken, Guid id, UpdateDocumentRequestModel requestModel) => await HandleRequest(id, requestModel, async () => { - ContentUpdateModel model = _documentEditingPresentationFactory.MapUpdateModel(requestModel); + var validateUpdateDocumentRequestModel = new ValidateUpdateDocumentRequestModel + { + Values = requestModel.Values, + Variants = requestModel.Variants, + Template = requestModel.Template, + Cultures = null + }; + + ValidateContentUpdateModel model = _documentEditingPresentationFactory.MapValidateUpdateModel(validateUpdateDocumentRequestModel); + Attempt result = await _contentEditingService.ValidateUpdateAsync(id, model); + + return result.Success + ? Ok() + : DocumentEditingOperationStatusResult(result.Status, requestModel, result.Result); + }); + + [HttpPut("{id:guid}/validate")] + [MapToApiVersion("1.1")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ValidateV1_1(CancellationToken cancellationToken, Guid id, ValidateUpdateDocumentRequestModel requestModel) + => await HandleRequest(id, requestModel, async () => + { + ValidateContentUpdateModel model = _documentEditingPresentationFactory.MapValidateUpdateModel(requestModel); Attempt result = await _contentEditingService.ValidateUpdateAsync(id, model); return result.Success diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Indexer/AllIndexerController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Indexer/AllIndexerController.cs index 768389b56d..6ce4d3b12b 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Indexer/AllIndexerController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Indexer/AllIndexerController.cs @@ -37,7 +37,7 @@ public class AllIndexerController : IndexerControllerBase { IndexResponseModel[] indexes = _examineManager.Indexes .Select(_indexPresentationFactory.Create) - .OrderBy(indexModel => indexModel.Name.TrimEnd("Indexer")).ToArray(); + .OrderBy(indexModel => indexModel.Name.TrimEndExact("Indexer")).ToArray(); var viewModel = new PagedViewModel { Items = indexes.Skip(skip).Take(take), Total = indexes.Length }; return Task.FromResult(viewModel); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/Collection/MediaCollectionControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/Collection/MediaCollectionControllerBase.cs index c6bf7eadda..cf6b05d8b9 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Media/Collection/MediaCollectionControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/Collection/MediaCollectionControllerBase.cs @@ -15,7 +15,7 @@ namespace Umbraco.Cms.Api.Management.Controllers.Media.Collection; [VersionedApiBackOfficeRoute($"{Constants.Web.RoutePath.Collection}/{Constants.UdiEntityType.Media}")] [ApiExplorerSettings(GroupName = nameof(Constants.UdiEntityType.Media))] [Authorize(Policy = AuthorizationPolicies.SectionAccessMedia)] -public abstract class MediaCollectionControllerBase : ContentCollectionControllerBase +public abstract class MediaCollectionControllerBase : ContentCollectionControllerBase { protected MediaCollectionControllerBase(IUmbracoMapper mapper) : base(mapper) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Searcher/AllSearcherController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Searcher/AllSearcherController.cs index 76cd2f639b..4c5189dcaa 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Searcher/AllSearcherController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Searcher/AllSearcherController.cs @@ -30,7 +30,7 @@ public class AllSearcherController : SearcherControllerBase var searchers = new List( _examineManager.RegisteredSearchers.Select(searcher => new SearcherResponse { Name = searcher.Name }) .OrderBy(x => - x.Name.TrimEnd("Searcher"))); // order by name , but strip the "Searcher" from the end if it exists + x.Name.TrimEndExact("Searcher"))); // order by name , but strip the "Searcher" from the end if it exists var viewModel = new PagedViewModel { Items = searchers.Skip(skip).Take(take), diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs index 03504a5099..fd6f67927c 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs @@ -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"; @@ -55,7 +56,8 @@ public class BackOfficeController : SecurityControllerBase ILogger logger, IBackOfficeTwoFactorOptions backOfficeTwoFactorOptions, IUserTwoFactorLoginService userTwoFactorLoginService, - IBackOfficeExternalLoginService externalLoginService) + IBackOfficeExternalLoginService externalLoginService, + IBackOfficeUserClientCredentialsManager backOfficeUserClientCredentialsManager) { _httpContextAccessor = httpContextAccessor; _backOfficeSignInManager = backOfficeSignInManager; @@ -65,6 +67,7 @@ public class BackOfficeController : SecurityControllerBase _backOfficeTwoFactorOptions = backOfficeTwoFactorOptions; _userTwoFactorLoginService = userTwoFactorLoginService; _externalLoginService = externalLoginService; + _backOfficeUserClientCredentialsManager = backOfficeUserClientCredentialsManager; } [HttpPost("login")] @@ -166,13 +169,19 @@ public class BackOfficeController : SecurityControllerBase OpenIddictRequest? request = context.GetOpenIddictServerRequest(); if (request == null) { - return BadRequest("Unable to obtain OpenID data from the current request"); + return BadRequest(new OpenIddictResponse + { + Error = "No context found", ErrorDescription = "Unable to obtain context from the current request." + }); } // make sure we keep member authentication away from this endpoint if (request.ClientId is Constants.OAuthClientIds.Member) { - return BadRequest("The specified client ID cannot be used here."); + return BadRequest(new OpenIddictResponse + { + Error = "Invalid 'client ID'", ErrorDescription = "The specified 'client_id' is not valid." + }); } return request.IdentityProvider.IsNullOrWhiteSpace() @@ -180,6 +189,61 @@ public class BackOfficeController : SecurityControllerBase : await AuthorizeExternal(request); } + [AllowAnonymous] + [HttpPost("token")] + [MapToApiVersion("1.0")] + public async Task Token() + { + HttpContext context = _httpContextAccessor.GetRequiredHttpContext(); + OpenIddictRequest? request = context.GetOpenIddictServerRequest(); + if (request == null) + { + return BadRequest(new OpenIddictResponse + { + Error = "No context found", ErrorDescription = "Unable to obtain context 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(new OpenIddictResponse + { + Error = "Authorization failed", ErrorDescription = "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(new OpenIddictResponse + { + Error = "Authorization failed", ErrorDescription = "The user associated with the supplied 'client_id' could not be found." + }); + } + + throw new InvalidOperationException("The requested grant type is not supported."); + } + [AllowAnonymous] [HttpGet("signout")] [MapToApiVersion("1.0")] diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/ClientCredentials/ClientCredentialsUserControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/ClientCredentials/ClientCredentialsUserControllerBase.cs new file mode 100644 index 0000000000..9a30da3811 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/ClientCredentials/ClientCredentialsUserControllerBase.cs @@ -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()), + }); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/ClientCredentials/CreateClientCredentialsUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/ClientCredentials/CreateClientCredentialsUserController.cs new file mode 100644 index 0000000000..d9212b18cd --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/ClientCredentials/CreateClientCredentialsUserController.cs @@ -0,0 +1,49 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.User.ClientCredentials; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Security.Authorization; +using Umbraco.Cms.Core.Security.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.User.ClientCredentials; + +[ApiVersion("1.0")] +public class CreateClientCredentialsUserController : ClientCredentialsUserControllerBase +{ + private readonly IBackOfficeUserClientCredentialsManager _backOfficeUserClientCredentialsManager; + private readonly IAuthorizationService _authorizationService; + + public CreateClientCredentialsUserController( + IBackOfficeUserClientCredentialsManager backOfficeUserClientCredentialsManager, + IAuthorizationService authorizationService) + { + _backOfficeUserClientCredentialsManager = backOfficeUserClientCredentialsManager; + _authorizationService = authorizationService; + } + + [HttpPost("{id:guid}/client-credentials")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task Create(CancellationToken cancellationToken, Guid id, CreateUserClientCredentialsRequestModel model) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + UserPermissionResource.WithKeys(id), + AuthorizationPolicies.UserPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt result = await _backOfficeUserClientCredentialsManager.SaveAsync(id, model.ClientId, model.ClientSecret); + return result.Success + ? Ok() + : BackOfficeUserClientCredentialsOperationStatusResult(result.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/ClientCredentials/DeleteClientCredentialsUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/ClientCredentials/DeleteClientCredentialsUserController.cs new file mode 100644 index 0000000000..5ad6e4ab1e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/ClientCredentials/DeleteClientCredentialsUserController.cs @@ -0,0 +1,48 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Security.Authorization; +using Umbraco.Cms.Core.Security.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.User.ClientCredentials; + +[ApiVersion("1.0")] +public class DeleteClientCredentialsUserController : ClientCredentialsUserControllerBase +{ + private readonly IBackOfficeUserClientCredentialsManager _backOfficeUserClientCredentialsManager; + private readonly IAuthorizationService _authorizationService; + + public DeleteClientCredentialsUserController( + IBackOfficeUserClientCredentialsManager backOfficeUserClientCredentialsManager, + IAuthorizationService authorizationService) + { + _backOfficeUserClientCredentialsManager = backOfficeUserClientCredentialsManager; + _authorizationService = authorizationService; + } + + [HttpDelete("{id:guid}/client-credentials/{clientId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task Delete(CancellationToken cancellationToken, Guid id, string clientId) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + UserPermissionResource.WithKeys(id), + AuthorizationPolicies.UserPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt result = await _backOfficeUserClientCredentialsManager.DeleteAsync(id, clientId); + return result.Success + ? Ok() + : BackOfficeUserClientCredentialsOperationStatusResult(result.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/ClientCredentials/GetAllClientCredentialsUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/ClientCredentials/GetAllClientCredentialsUserController.cs new file mode 100644 index 0000000000..e1e4d46f66 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/ClientCredentials/GetAllClientCredentialsUserController.cs @@ -0,0 +1,43 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Security.Authorization; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.User.ClientCredentials; + +[ApiVersion("1.0")] +public class GetAllClientCredentialsUserController : ClientCredentialsUserControllerBase +{ + private readonly IBackOfficeUserClientCredentialsManager _backOfficeUserClientCredentialsManager; + private readonly IAuthorizationService _authorizationService; + + public GetAllClientCredentialsUserController( + IBackOfficeUserClientCredentialsManager backOfficeUserClientCredentialsManager, + IAuthorizationService authorizationService) + { + _backOfficeUserClientCredentialsManager = backOfficeUserClientCredentialsManager; + _authorizationService = authorizationService; + } + + [HttpGet("{id:guid}/client-credentials")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetAll(CancellationToken cancellationToken, Guid id) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + UserPermissionResource.WithKeys(id), + AuthorizationPolicies.UserPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + IEnumerable clientIds = await _backOfficeUserClientCredentialsManager.GetClientIdsAsync(id); + return Ok(clientIds); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs index 7e5488a20c..65490a9c21 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs @@ -128,6 +128,10 @@ public abstract class UserOrCurrentUserControllerBase : ManagementApiControllerB .WithTitle("Self password reset not allowed") .WithDetail("It is not allowed to reset the password for the account you are logged in to.") .Build()), + UserOperationStatus.InvalidUserType => BadRequest(problemDetailsBuilder + .WithTitle("Invalid user type") + .WithDetail("The target user type does not support this operation.") + .Build()), UserOperationStatus.Forbidden => Forbidden(), _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder .WithTitle("Unknown user operation status.") diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/MemberBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/MemberBuilderExtensions.cs index 90ea476c18..b5a5bb842c 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/MemberBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/MemberBuilderExtensions.cs @@ -10,7 +10,7 @@ internal static class MemberBuilderExtensions { internal static IUmbracoBuilder AddMember(this IUmbracoBuilder builder) { - builder.Services.AddTransient(); + builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.WithCollectionBuilder().Add(); diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs index 32ae43b1d1..c2b0b4d720 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs @@ -35,6 +35,7 @@ public static partial class UmbracoBuilderExtensions .AddWebServer() .AddRecurringBackgroundJobs() .AddNuCache() + .AddUmbracoHybridCache() .AddDistributedCache() .AddCoreNotifications() .AddExamine() diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs index baf8c2ebcb..15dd835fa0 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs @@ -65,6 +65,7 @@ public static partial class UmbracoBuilderExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs index 4f96cdb099..31b1a9a66f 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs @@ -17,8 +17,20 @@ internal sealed class DocumentEditingPresentationFactory : ContentEditingPresent } public ContentUpdateModel MapUpdateModel(UpdateDocumentRequestModel requestModel) + => MapUpdateContentModel(requestModel); + + public ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel) { - ContentUpdateModel model = MapContentEditingModel(requestModel); + ValidateContentUpdateModel model = MapUpdateContentModel(requestModel); + model.Cultures = requestModel.Cultures; + + return model; + } + + private TUpdateModel MapUpdateContentModel(UpdateDocumentRequestModel requestModel) + where TUpdateModel : ContentUpdateModel, new() + { + TUpdateModel model = MapContentEditingModel(requestModel); model.TemplateKey = requestModel.Template?.Id; return model; diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs index dbc8538563..52978698d4 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs @@ -8,4 +8,6 @@ public interface IDocumentEditingPresentationFactory ContentCreateModel MapCreateModel(CreateDocumentRequestModel requestModel); ContentUpdateModel MapUpdateModel(UpdateDocumentRequestModel requestModel); + + ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel); } diff --git a/src/Umbraco.Cms.Api.Management/Factories/MemberPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/MemberPresentationFactory.cs index 7e53676c5f..aaa4240caa 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/MemberPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/MemberPresentationFactory.cs @@ -1,7 +1,9 @@ -using Umbraco.Cms.Api.Management.ViewModels.Content; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Api.Management.ViewModels.Content; using Umbraco.Cms.Api.Management.ViewModels.Member; using Umbraco.Cms.Api.Management.ViewModels.Member.Item; using Umbraco.Cms.Api.Management.ViewModels.MemberType; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Extensions; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; @@ -19,19 +21,23 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory private readonly IMemberTypeService _memberTypeService; private readonly ITwoFactorLoginService _twoFactorLoginService; private readonly IMemberGroupService _memberGroupService; + private readonly DeliveryApiSettings _deliveryApiSettings; + private IEnumerable? _clientCredentialsMemberKeys; public MemberPresentationFactory( IUmbracoMapper umbracoMapper, IMemberService memberService, IMemberTypeService memberTypeService, ITwoFactorLoginService twoFactorLoginService, - IMemberGroupService memberGroupService) + IMemberGroupService memberGroupService, + IOptions deliveryApiSettings) { _umbracoMapper = umbracoMapper; _memberService = memberService; _memberTypeService = memberTypeService; _twoFactorLoginService = twoFactorLoginService; _memberGroupService = memberGroupService; + _deliveryApiSettings = deliveryApiSettings.Value; } public async Task CreateResponseModelAsync(IMember member, IUser currentUser) @@ -39,6 +45,7 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory MemberResponseModel responseModel = _umbracoMapper.Map(member)!; responseModel.IsTwoFactorEnabled = await _twoFactorLoginService.IsTwoFactorEnabledAsync(member.Key); + responseModel.Kind = GetMemberKind(member.Key); IEnumerable roles = _memberService.GetAllRoles(member.Username); // Get the member groups per role, so we can return the group keys @@ -71,7 +78,8 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory { Id = entity.Key, MemberType = _umbracoMapper.Map(entity)!, - Variants = CreateVariantsItemResponseModels(entity) + Variants = CreateVariantsItemResponseModels(entity), + Kind = GetMemberKind(entity.Key) }; private static IEnumerable CreateVariantsItemResponseModels(ITreeEntity entity) @@ -108,4 +116,24 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory return responseModel; } + + private MemberKind GetMemberKind(Guid key) + { + if (_clientCredentialsMemberKeys is null) + { + IEnumerable clientCredentialsMemberUserNames = _deliveryApiSettings + .MemberAuthorization? + .ClientCredentialsFlow? + .AssociatedMembers + .Select(m => m.UserName).ToArray() + ?? []; + + _clientCredentialsMemberKeys = clientCredentialsMemberUserNames + .Select(_memberService.GetByUsername) + .WhereNotNull() + .Select(m => m.Key).ToArray(); + } + + return _clientCredentialsMemberKeys.Contains(key) ? MemberKind.Api : MemberKind.Default; + } } diff --git a/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs index 59e94b5d43..7ba1229416 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs @@ -80,6 +80,7 @@ public class UserPresentationFactory : IUserPresentationFactory LastLockoutDate = user.LastLockoutDate, LastPasswordChangeDate = user.LastPasswordChangeDate, IsAdmin = user.IsAdmin(), + Kind = user.Kind }; return responseModel; @@ -92,6 +93,7 @@ public class UserPresentationFactory : IUserPresentationFactory Name = user.Name ?? user.Username, AvatarUrls = user.GetUserAvatarUrls(_appCaches.RuntimeCache, _mediaFileManager, _imageUrlGenerator) .Select(url => _absoluteUrlBuilder.ToAbsoluteUrl(url).ToString()), + Kind = user.Kind }; public async Task CreateCreationModelAsync(CreateUserRequestModel requestModel) @@ -103,6 +105,7 @@ public class UserPresentationFactory : IUserPresentationFactory Name = requestModel.Name, UserName = requestModel.UserName, UserGroupKeys = requestModel.UserGroupIds.Select(x => x.Id).ToHashSet(), + Kind = requestModel.Kind }; return await Task.FromResult(createModel); diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Content/ContentMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Content/ContentMapDefinition.cs index bba23ea98d..038e3fbf8b 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Content/ContentMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Content/ContentMapDefinition.cs @@ -8,7 +8,7 @@ namespace Umbraco.Cms.Api.Management.Mapping.Content; public abstract class ContentMapDefinition where TContent : IContentBase - where TValueViewModel : ValueModelBase, new() + where TValueViewModel : ValueResponseModelBase, new() where TVariantViewModel : VariantResponseModelBase, new() { private readonly PropertyEditorCollection _propertyEditorCollection; @@ -36,7 +36,8 @@ public abstract class ContentMapDefinition, IMapDefinition +public class DocumentMapDefinition : ContentMapDefinition, IMapDefinition { private readonly CommonMapper _commonMapper; diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentVersionMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentVersionMapDefinition.cs index 40359b08c5..5e12e245bd 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentVersionMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentVersionMapDefinition.cs @@ -9,7 +9,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Mapping.Document; -public class DocumentVersionMapDefinition : ContentMapDefinition, IMapDefinition +public class DocumentVersionMapDefinition : ContentMapDefinition, IMapDefinition { public DocumentVersionMapDefinition(PropertyEditorCollection propertyEditorCollection) : base(propertyEditorCollection) diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Media/MediaMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Media/MediaMapDefinition.cs index d4760abe2d..df1743d235 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Media/MediaMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Media/MediaMapDefinition.cs @@ -10,7 +10,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Mapping.Media; -public class MediaMapDefinition : ContentMapDefinition, IMapDefinition +public class MediaMapDefinition : ContentMapDefinition, IMapDefinition { private readonly CommonMapper _commonMapper; diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Member/MemberMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Member/MemberMapDefinition.cs index 2310da80fa..fedc8e4eca 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Member/MemberMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Member/MemberMapDefinition.cs @@ -7,7 +7,7 @@ using Umbraco.Cms.Core.PropertyEditors; namespace Umbraco.Cms.Api.Management.Mapping.Member; -public class MemberMapDefinition : ContentMapDefinition, IMapDefinition +public class MemberMapDefinition : ContentMapDefinition, IMapDefinition { public MemberMapDefinition(PropertyEditorCollection propertyEditorCollection) : base(propertyEditorCollection) @@ -17,7 +17,7 @@ public class MemberMapDefinition : ContentMapDefinition mapper.Define((_, _) => new MemberResponseModel(), Map); - // Umbraco.Code.MapAll -IsTwoFactorEnabled -Groups + // Umbraco.Code.MapAll -IsTwoFactorEnabled -Groups -Kind private void Map(IMember source, MemberResponseModel target, MapperContext context) { target.Id = source.Key; diff --git a/src/Umbraco.Cms.Api.Management/Mapping/PartialView/PartialViewViewModelsMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/PartialView/PartialViewViewModelsMapDefinition.cs index 97e241feff..53c1efeb3f 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/PartialView/PartialViewViewModelsMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/PartialView/PartialViewViewModelsMapDefinition.cs @@ -64,7 +64,7 @@ public class PartialViewViewModelsMapDefinition : IMapDefinition { target.Path = source.Path.SystemPathToVirtualPath(); target.Name = source.Name; - target.Parent = source.ParentPath.IsNullOrEmpty() + target.Parent = string.IsNullOrEmpty(source.ParentPath) ? null : new FileSystemFolderModel { diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Script/ScriptViewModelsMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Script/ScriptViewModelsMapDefinition.cs index 75096091c3..dae8bcf177 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Script/ScriptViewModelsMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Script/ScriptViewModelsMapDefinition.cs @@ -59,7 +59,7 @@ public class ScriptViewModelsMapDefinition : IMapDefinition { target.Path = source.Path.SystemPathToVirtualPath(); target.Name = source.Name; - target.Parent = source.ParentPath.IsNullOrEmpty() + target.Parent = string.IsNullOrEmpty(source.ParentPath) ? null : new FileSystemFolderModel { diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Stylesheet/StylesheetviewModelsMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Stylesheet/StylesheetviewModelsMapDefinition.cs index 30aa1651ec..65c1fe0be9 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Stylesheet/StylesheetviewModelsMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Stylesheet/StylesheetviewModelsMapDefinition.cs @@ -59,7 +59,7 @@ public class StylesheetViewModelsMapDefinition : IMapDefinition { target.Path = source.Path.SystemPathToVirtualPath(); target.Name = source.Name; - target.Parent = source.ParentPath.IsNullOrEmpty() + target.Parent = string.IsNullOrEmpty(source.ParentPath) ? null : new FileSystemFolderModel { diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 6482df8801..29acbdd16a 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -9372,6 +9372,149 @@ } } }, + "deprecated": true, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1.1/document/{id}/validate": { + "put": { + "tags": [ + "Document" + ], + "operationId": "PutUmbracoManagementApiV1.1DocumentByIdValidate1.1", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ValidateUpdateDocumentRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ValidateUpdateDocumentRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ValidateUpdateDocumentRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user do not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, "security": [ { "Backoffice User": [ ] @@ -30621,6 +30764,258 @@ ] } }, + "/umbraco/management/api/v1/user/{id}/client-credentials": { + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserByIdClientCredentials", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateUserClientCredentialsRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateUserClientCredentialsRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateUserClientCredentialsRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user do not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + }, + "get": { + "tags": [ + "User" + ], + "operationId": "GetUserByIdClientCredentials", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user do not have access to this resource" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/{id}/client-credentials/{clientId}": { + "delete": { + "tags": [ + "User" + ], + "operationId": "DeleteUserByIdClientCredentialsByClientId", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "clientId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user do not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/user/{id}/reset-password": { "post": { "tags": [ @@ -35158,6 +35553,22 @@ }, "additionalProperties": false }, + "CreateUserClientCredentialsRequestModel": { + "required": [ + "clientId", + "clientSecret" + ], + "type": "object", + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + } + }, + "additionalProperties": false + }, "CreateUserDataRequestModel": { "required": [ "group", @@ -35276,6 +35687,7 @@ "CreateUserRequestModel": { "required": [ "email", + "kind", "name", "userGroupIds", "userName" @@ -35306,6 +35718,9 @@ "type": "string", "format": "uuid", "nullable": true + }, + "kind": { + "$ref": "#/components/schemas/UserKindModel" } }, "additionalProperties": false @@ -36093,7 +36508,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/DocumentValueModel" + "$ref": "#/components/schemas/DocumentValueResponseModel" } ] } @@ -36178,7 +36593,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/DocumentValueModel" + "$ref": "#/components/schemas/DocumentValueResponseModel" } ] } @@ -36435,7 +36850,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/DocumentValueModel" + "$ref": "#/components/schemas/DocumentValueResponseModel" } ] } @@ -37117,6 +37532,35 @@ }, "additionalProperties": false }, + "DocumentValueResponseModel": { + "required": [ + "alias", + "editorAlias" + ], + "type": "object", + "properties": { + "culture": { + "type": "string", + "nullable": true + }, + "segment": { + "type": "string", + "nullable": true + }, + "alias": { + "minLength": 1, + "type": "string" + }, + "value": { + "nullable": true + }, + "editorAlias": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + }, "DocumentVariantItemResponseModel": { "required": [ "name", @@ -37275,7 +37719,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/DocumentValueModel" + "$ref": "#/components/schemas/DocumentValueResponseModel" } ] } @@ -38319,7 +38763,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/MediaValueModel" + "$ref": "#/components/schemas/MediaValueResponseModel" } ] } @@ -38502,7 +38946,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/MediaValueModel" + "$ref": "#/components/schemas/MediaValueResponseModel" } ] } @@ -39090,6 +39534,35 @@ }, "additionalProperties": false }, + "MediaValueResponseModel": { + "required": [ + "alias", + "editorAlias" + ], + "type": "object", + "properties": { + "culture": { + "type": "string", + "nullable": true + }, + "segment": { + "type": "string", + "nullable": true + }, + "alias": { + "minLength": 1, + "type": "string" + }, + "value": { + "nullable": true + }, + "editorAlias": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + }, "MediaVariantRequestModel": { "required": [ "name" @@ -39196,6 +39669,7 @@ "MemberItemResponseModel": { "required": [ "id", + "kind", "memberType", "variants" ], @@ -39221,10 +39695,20 @@ } ] } + }, + "kind": { + "$ref": "#/components/schemas/MemberKindModel" } }, "additionalProperties": false }, + "MemberKindModel": { + "enum": [ + "Default", + "Api" + ], + "type": "string" + }, "MemberResponseModel": { "required": [ "email", @@ -39234,6 +39718,7 @@ "isApproved", "isLockedOut", "isTwoFactorEnabled", + "kind", "memberType", "username", "values", @@ -39246,7 +39731,7 @@ "items": { "oneOf": [ { - "$ref": "#/components/schemas/MemberValueModel" + "$ref": "#/components/schemas/MemberValueResponseModel" } ] } @@ -39312,6 +39797,9 @@ "type": "string", "format": "uuid" } + }, + "kind": { + "$ref": "#/components/schemas/MemberKindModel" } }, "additionalProperties": false @@ -39728,6 +40216,35 @@ }, "additionalProperties": false }, + "MemberValueResponseModel": { + "required": [ + "alias", + "editorAlias" + ], + "type": "object", + "properties": { + "culture": { + "type": "string", + "nullable": true + }, + "segment": { + "type": "string", + "nullable": true + }, + "alias": { + "minLength": 1, + "type": "string" + }, + "value": { + "nullable": true + }, + "editorAlias": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + }, "MemberVariantRequestModel": { "required": [ "name" @@ -44888,6 +45405,7 @@ "required": [ "avatarUrls", "id", + "kind", "name" ], "type": "object", @@ -44904,10 +45422,20 @@ "items": { "type": "string" } + }, + "kind": { + "$ref": "#/components/schemas/UserKindModel" } }, "additionalProperties": false }, + "UserKindModel": { + "enum": [ + "Default", + "Api" + ], + "type": "string" + }, "UserOrderModel": { "enum": [ "UserName", @@ -44974,6 +45502,7 @@ "hasMediaRootAccess", "id", "isAdmin", + "kind", "mediaStartNodeIds", "name", "state", @@ -45077,6 +45606,9 @@ }, "isAdmin": { "type": "boolean" + }, + "kind": { + "$ref": "#/components/schemas/UserKindModel" } }, "additionalProperties": false @@ -45137,6 +45669,52 @@ }, "additionalProperties": false }, + "ValidateUpdateDocumentRequestModel": { + "required": [ + "values", + "variants" + ], + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentValueModel" + } + ] + } + }, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVariantRequestModel" + } + ] + } + }, + "template": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "cultures": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + }, + "additionalProperties": false + }, "VariantItemResponseModel": { "required": [ "name" diff --git a/src/Umbraco.Cms.Api.Management/Security/BackOfficeApplicationManager.cs b/src/Umbraco.Cms.Api.Management/Security/BackOfficeApplicationManager.cs index d8779e6299..36fe1b0acc 100644 --- a/src/Umbraco.Cms.Api.Management/Security/BackOfficeApplicationManager.cs +++ b/src/Umbraco.Cms.Api.Management/Security/BackOfficeApplicationManager.cs @@ -141,5 +141,27 @@ public class BackOfficeApplicationManager : OpenIdDictApplicationManagerBase, IB }; } + public async Task EnsureBackOfficeClientCredentialsApplicationAsync(string clientId, string clientSecret, CancellationToken cancellationToken = default) + { + var applicationDescriptor = new OpenIddictApplicationDescriptor + { + DisplayName = $"Umbraco client credentials back-office access: {clientId}", + ClientId = clientId, + ClientSecret = clientSecret, + ClientType = OpenIddictConstants.ClientTypes.Confidential, + Permissions = + { + OpenIddictConstants.Permissions.Endpoints.Token, + OpenIddictConstants.Permissions.Endpoints.Revocation, + OpenIddictConstants.Permissions.GrantTypes.ClientCredentials + } + }; + + await CreateOrUpdate(applicationDescriptor, cancellationToken); + } + + public async Task DeleteBackOfficeClientCredentialsApplicationAsync(string clientId, CancellationToken cancellationToken = default) + => await Delete(clientId, cancellationToken); + private static Uri CallbackUrlFor(Uri url, string relativePath) => new Uri($"{url.GetLeftPart(UriPartial.Authority)}/{relativePath.TrimStart(Constants.CharArrays.ForwardSlash)}"); } diff --git a/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj b/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj index e2060938c6..2664abe675 100644 --- a/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj +++ b/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj @@ -24,6 +24,7 @@ + diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Content/ContentCollectionResponseModelBase.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Content/ContentCollectionResponseModelBase.cs index 735e448fb2..2426abbecd 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Content/ContentCollectionResponseModelBase.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Content/ContentCollectionResponseModelBase.cs @@ -4,7 +4,7 @@ namespace Umbraco.Cms.Api.Management.ViewModels.Content; public abstract class ContentCollectionResponseModelBase : ContentResponseModelBase - where TValueResponseModelBase : ValueModelBase + where TValueResponseModelBase : ValueResponseModelBase where TVariantResponseModel : VariantResponseModelBase { public string? Creator { get; set; } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/Collection/DocumentCollectionResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/Collection/DocumentCollectionResponseModel.cs index 453f2ee72a..b67f4088ce 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/Collection/DocumentCollectionResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/Collection/DocumentCollectionResponseModel.cs @@ -3,7 +3,7 @@ using Umbraco.Cms.Api.Management.ViewModels.DocumentType; namespace Umbraco.Cms.Api.Management.ViewModels.Document.Collection; -public class DocumentCollectionResponseModel : ContentCollectionResponseModelBase +public class DocumentCollectionResponseModel : ContentCollectionResponseModelBase { public DocumentTypeCollectionReferenceResponseModel DocumentType { get; set; } = new(); diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentResponseModel.cs index d2eeb2e6af..2854a53320 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentResponseModel.cs @@ -1,6 +1,6 @@ namespace Umbraco.Cms.Api.Management.ViewModels.Document; -public class DocumentResponseModel : DocumentResponseModelBase +public class DocumentResponseModel : DocumentResponseModelBase { public IEnumerable Urls { get; set; } = Enumerable.Empty(); diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentValueResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentValueResponseModel.cs new file mode 100644 index 0000000000..c112da394c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentValueResponseModel.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +public class DocumentValueResponseModel : ValueResponseModelBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVersionResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVersionResponseModel.cs index 486bee306f..5068ab3f40 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVersionResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVersionResponseModel.cs @@ -1,6 +1,6 @@ namespace Umbraco.Cms.Api.Management.ViewModels.Document; -public class DocumentVersionResponseModel : DocumentResponseModelBase +public class DocumentVersionResponseModel : DocumentResponseModelBase { public ReferenceByIdModel? Document { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/ValidateUpdateDocumentRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/ValidateUpdateDocumentRequestModel.cs new file mode 100644 index 0000000000..806c733cc2 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/ValidateUpdateDocumentRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +public class ValidateUpdateDocumentRequestModel : UpdateDocumentRequestModel +{ + public ISet? Cultures { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/DocumentBlueprint/DocumentBlueprintResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/DocumentBlueprint/DocumentBlueprintResponseModel.cs index e23cb10f41..7b8a60b6e2 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/DocumentBlueprint/DocumentBlueprintResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/DocumentBlueprint/DocumentBlueprintResponseModel.cs @@ -2,6 +2,6 @@ using Umbraco.Cms.Api.Management.ViewModels.Document; namespace Umbraco.Cms.Api.Management.ViewModels.DocumentBlueprint; -public class DocumentBlueprintResponseModel : DocumentResponseModelBase +public class DocumentBlueprintResponseModel : DocumentResponseModelBase { } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Media/Collection/MediaCollectionResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Media/Collection/MediaCollectionResponseModel.cs index fb772f858f..72891a9bd1 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Media/Collection/MediaCollectionResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Media/Collection/MediaCollectionResponseModel.cs @@ -3,7 +3,7 @@ using Umbraco.Cms.Api.Management.ViewModels.MediaType; namespace Umbraco.Cms.Api.Management.ViewModels.Media.Collection; -public class MediaCollectionResponseModel : ContentCollectionResponseModelBase +public class MediaCollectionResponseModel : ContentCollectionResponseModelBase { public MediaTypeCollectionReferenceResponseModel MediaType { get; set; } = new(); } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Media/MediaResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Media/MediaResponseModel.cs index dc6bc6da40..8b78b98f2c 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Media/MediaResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Media/MediaResponseModel.cs @@ -3,7 +3,7 @@ using Umbraco.Cms.Api.Management.ViewModels.MediaType; namespace Umbraco.Cms.Api.Management.ViewModels.Media; -public class MediaResponseModel : ContentResponseModelBase +public class MediaResponseModel : ContentResponseModelBase { public IEnumerable Urls { get; set; } = Enumerable.Empty(); diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Media/MediaValueResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Media/MediaValueResponseModel.cs new file mode 100644 index 0000000000..10ffd04bd0 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Media/MediaValueResponseModel.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Api.Management.ViewModels.Media; + +public class MediaValueResponseModel : ValueResponseModelBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Member/Item/MemberItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Member/Item/MemberItemResponseModel.cs index f959a37a90..e55ad04572 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Member/Item/MemberItemResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Member/Item/MemberItemResponseModel.cs @@ -1,6 +1,7 @@ using Umbraco.Cms.Api.Management.ViewModels.Content; using Umbraco.Cms.Api.Management.ViewModels.Item; using Umbraco.Cms.Api.Management.ViewModels.MemberType; +using Umbraco.Cms.Core.Models.Membership; namespace Umbraco.Cms.Api.Management.ViewModels.Member.Item; @@ -9,4 +10,6 @@ public class MemberItemResponseModel : ItemResponseModelBase public MemberTypeReferenceResponseModel MemberType { get; set; } = new(); public IEnumerable Variants { get; set; } = Enumerable.Empty(); + + public MemberKind Kind { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Member/MemberResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Member/MemberResponseModel.cs index 0125d85523..626d5e6b8e 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Member/MemberResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Member/MemberResponseModel.cs @@ -1,9 +1,10 @@ using Umbraco.Cms.Api.Management.ViewModels.Content; using Umbraco.Cms.Api.Management.ViewModels.MemberType; +using Umbraco.Cms.Core.Models.Membership; namespace Umbraco.Cms.Api.Management.ViewModels.Member; -public class MemberResponseModel : ContentResponseModelBase +public class MemberResponseModel : ContentResponseModelBase { public string Email { get; set; } = string.Empty; @@ -26,4 +27,6 @@ public class MemberResponseModel : ContentResponseModelBase Groups { get; set; } = []; + + public MemberKind Kind { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Member/MemberValueResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Member/MemberValueResponseModel.cs new file mode 100644 index 0000000000..1db8190ecb --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Member/MemberValueResponseModel.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Api.Management.ViewModels.Member; + +public class MemberValueResponseModel : ValueResponseModelBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/User/ClientCredentials/CreateUserClientCredentialsRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/ClientCredentials/CreateUserClientCredentialsRequestModel.cs new file mode 100644 index 0000000000..56340b10ab --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/ClientCredentials/CreateUserClientCredentialsRequestModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.User.ClientCredentials; + +public sealed class CreateUserClientCredentialsRequestModel +{ + public required string ClientId { get; set; } + + public required string ClientSecret { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/User/CreateUserRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/CreateUserRequestModel.cs index fa9b1b856a..3ce44784b9 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/User/CreateUserRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/CreateUserRequestModel.cs @@ -1,6 +1,8 @@ -namespace Umbraco.Cms.Api.Management.ViewModels.User; +using Umbraco.Cms.Core.Models.Membership; -public class CreateUserRequestModel : UserPresentationBase +namespace Umbraco.Cms.Api.Management.ViewModels.User; + +public class CreateUserRequestModel : CreateUserRequestModelBase { - public Guid? Id { get; set; } + public UserKind Kind { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/User/CreateUserRequestModelBase.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/CreateUserRequestModelBase.cs new file mode 100644 index 0000000000..35ccf445ac --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/CreateUserRequestModelBase.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.User; + +public class CreateUserRequestModelBase : UserPresentationBase +{ + public Guid? Id { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/User/InviteUserRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/InviteUserRequestModel.cs index 9f73d12dea..e41c4d5487 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/User/InviteUserRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/InviteUserRequestModel.cs @@ -1,6 +1,6 @@ namespace Umbraco.Cms.Api.Management.ViewModels.User; -public class InviteUserRequestModel : CreateUserRequestModel +public class InviteUserRequestModel : CreateUserRequestModelBase { public string? Message { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/User/Item/UserItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/Item/UserItemResponseModel.cs index 8fd2618784..4dd8139ba1 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/User/Item/UserItemResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/Item/UserItemResponseModel.cs @@ -1,8 +1,11 @@ using Umbraco.Cms.Api.Management.ViewModels.Item; +using Umbraco.Cms.Core.Models.Membership; namespace Umbraco.Cms.Api.Management.ViewModels.User.Item; public class UserItemResponseModel : NamedItemResponseModelBase { public IEnumerable AvatarUrls { get; set; } = Enumerable.Empty(); + + public UserKind Kind { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/User/UserResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/UserResponseModel.cs index bc3fc92c22..8177b02d1e 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/User/UserResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/UserResponseModel.cs @@ -31,5 +31,8 @@ public class UserResponseModel : UserPresentationBase public DateTimeOffset? LastLockoutDate { get; set; } public DateTimeOffset? LastPasswordChangeDate { get; set; } + public bool IsAdmin { get; set; } + + public UserKind Kind { get; set; } } diff --git a/src/Umbraco.Cms.Imaging.ImageSharp/ConfigureImageSharpMiddlewareOptions.cs b/src/Umbraco.Cms.Imaging.ImageSharp/ConfigureImageSharpMiddlewareOptions.cs index 9a1ecead89..0b761e125d 100644 --- a/src/Umbraco.Cms.Imaging.ImageSharp/ConfigureImageSharpMiddlewareOptions.cs +++ b/src/Umbraco.Cms.Imaging.ImageSharp/ConfigureImageSharpMiddlewareOptions.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Headers; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; +using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Web.Commands; using SixLabors.ImageSharp.Web.Middleware; using SixLabors.ImageSharp.Web.Processors; @@ -85,5 +86,14 @@ public sealed class ConfigureImageSharpMiddlewareOptions : IConfigureOptions + + + + + + + diff --git a/src/Umbraco.Cms.Persistence.SqlServer/Umbraco.Cms.Persistence.SqlServer.csproj b/src/Umbraco.Cms.Persistence.SqlServer/Umbraco.Cms.Persistence.SqlServer.csproj index b6a342b7b8..d5edbbee4c 100644 --- a/src/Umbraco.Cms.Persistence.SqlServer/Umbraco.Cms.Persistence.SqlServer.csproj +++ b/src/Umbraco.Cms.Persistence.SqlServer/Umbraco.Cms.Persistence.SqlServer.csproj @@ -21,6 +21,12 @@ + + + + + + diff --git a/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoLogin/Index.cshtml b/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoLogin/Index.cshtml index 856796cd25..716521066c 100644 --- a/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoLogin/Index.cshtml +++ b/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoLogin/Index.cshtml @@ -31,7 +31,6 @@ var allowPasswordReset = SecuritySettings.Value.AllowPasswordReset && EmailSender.CanSendRequiredEmail(); var disableLocalLogin = ExternalLogins.HasDenyLocalLogin(); } -@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @@ -45,7 +44,7 @@ Umbraco - + @await Html.BackOfficeImportMapScriptAsync(JsonSerializer, BackOfficePathGenerator, PackageManifestService) - + diff --git a/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Maintenance.cshtml b/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Maintenance.cshtml index 94de5f3c52..5c41abf6fa 100644 --- a/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Maintenance.cshtml +++ b/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Maintenance.cshtml @@ -17,7 +17,7 @@ Website is Under Maintainance - +