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.Delivery/Controllers/Security/MemberController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Security/MemberController.cs index d1c58971c8..ed767947cd 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,16 +76,13 @@ 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."); } - 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."); @@ -75,6 +99,41 @@ 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("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 Signout() 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.Management/Controllers/Security/BackOfficeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs index fd6d9116c5..217890a539 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")] @@ -180,6 +183,52 @@ 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("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")] 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/UmbracoBuilder.BackOfficeIdentity.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs index a445268db7..6705bc216e 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs @@ -66,6 +66,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/UserPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs index 59e94b5d43..ccb12a579a 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(), + Type = user.Type }; 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()), + Type = user.Type }; 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(), + Type = requestModel.Type }; return await Task.FromResult(createModel); diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 5d2d532ebc..06c4f45d24 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -30570,6 +30570,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": [ @@ -35107,6 +35359,22 @@ }, "additionalProperties": false }, + "CreateUserClientCredentialsRequestModel": { + "required": [ + "clientId", + "clientSecret" + ], + "type": "object", + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + } + }, + "additionalProperties": false + }, "CreateUserDataRequestModel": { "required": [ "group", @@ -35226,6 +35494,7 @@ "required": [ "email", "name", + "type", "userGroupIds", "userName" ], @@ -35255,6 +35524,9 @@ "type": "string", "format": "uuid", "nullable": true + }, + "type": { + "$ref": "#/components/schemas/UserTypeModel" } }, "additionalProperties": false @@ -37972,6 +38244,7 @@ "required": [ "email", "name", + "type", "userGroupIds", "userName" ], @@ -38002,6 +38275,9 @@ "format": "uuid", "nullable": true }, + "type": { + "$ref": "#/components/schemas/UserTypeModel" + }, "message": { "type": "string", "nullable": true @@ -44806,7 +45082,8 @@ "required": [ "avatarUrls", "id", - "name" + "name", + "type" ], "type": "object", "properties": { @@ -44822,6 +45099,9 @@ "items": { "type": "string" } + }, + "type": { + "$ref": "#/components/schemas/UserTypeModel" } }, "additionalProperties": false @@ -44895,6 +45175,7 @@ "mediaStartNodeIds", "name", "state", + "type", "updateDate", "userGroupIds", "userName" @@ -44995,6 +45276,9 @@ }, "isAdmin": { "type": "boolean" + }, + "type": { + "$ref": "#/components/schemas/UserTypeModel" } }, "additionalProperties": false @@ -45055,6 +45339,13 @@ }, "additionalProperties": false }, + "UserTypeModel": { + "enum": [ + "Default", + "Api" + ], + "type": "string" + }, "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/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..dff46bf2fc 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/User/CreateUserRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/CreateUserRequestModel.cs @@ -1,6 +1,10 @@ -namespace Umbraco.Cms.Api.Management.ViewModels.User; +using Umbraco.Cms.Core.Models.Membership; + +namespace Umbraco.Cms.Api.Management.ViewModels.User; public class CreateUserRequestModel : UserPresentationBase { public Guid? Id { get; set; } + + public UserType Type { 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..b65b3302df 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 UserType Type { 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..52c37da7a3 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 UserType Type { get; set; } } diff --git a/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs b/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs index 1f75d47055..07dc943c24 100644 --- a/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs @@ -70,7 +70,8 @@ public class DeliveryApiSettings /// /// This method is intended for future extension - see remark in . /// - public bool MemberAuthorizationIsEnabled() => MemberAuthorization?.AuthorizationCodeFlow?.Enabled is true; + public bool MemberAuthorizationIsEnabled() => MemberAuthorization?.AuthorizationCodeFlow?.Enabled is true + || MemberAuthorization?.ClientCredentialsFlow?.Enabled is true; /// /// Typed configuration options for the Media APIs of the Delivery API. @@ -116,6 +117,11 @@ public class DeliveryApiSettings /// Gets or sets the Authorization Code Flow configuration for the Delivery API. /// public AuthorizationCodeFlowSettings? AuthorizationCodeFlow { get; set; } = null; + + /// + /// Gets or sets the Client Credentials Flow configuration for the Delivery API. + /// + public ClientCredentialsFlowSettings? ClientCredentialsFlow { get; set; } = null; } /// @@ -181,4 +187,40 @@ public class DeliveryApiSettings [DefaultValue(StaticDuration)] public TimeSpan MediaDuration { get; set; } = TimeSpan.Parse(StaticDuration); } + + /// + /// Typed configuration options for the Client Credentials Flow settings for the Delivery API. + /// + public class ClientCredentialsFlowSettings + { + /// + /// Gets or sets a value indicating whether Client Credentials Flow should be enabled for the Delivery API. + /// + /// true if Client Credentials Flow should be enabled; otherwise, false. + [DefaultValue(StaticEnabled)] + public bool Enabled { get; set; } = StaticEnabled; + + public IEnumerable AssociatedMembers { get; set; } = []; + } + + public class ClientCredentialsFlowMemberSettings + { + /// + /// Gets or sets the user name of the member to associate with the session after a successful login. + /// + /// The user name of the member. + public string UserName { get; set; } = string.Empty; + + /// + /// Gets or sets the client ID that allows for a successful login. + /// + /// The client ID. + public string ClientId { get; set; } = string.Empty; + + /// + /// Gets or sets the client secret that allows for a successful login. + /// + /// The client secret. + public string ClientSecret { get; set; } = string.Empty; + } } diff --git a/src/Umbraco.Core/Models/Membership/IUser.cs b/src/Umbraco.Core/Models/Membership/IUser.cs index f40c1c5c8a..3d499f835f 100644 --- a/src/Umbraco.Core/Models/Membership/IUser.cs +++ b/src/Umbraco.Core/Models/Membership/IUser.cs @@ -39,6 +39,11 @@ public interface IUser : IMembershipUser, IRememberBeingDirty /// string? Avatar { get; set; } + /// + /// The type of user. + /// + UserType Type { get; set; } + void RemoveGroup(string group); void ClearGroups(); diff --git a/src/Umbraco.Core/Models/Membership/User.cs b/src/Umbraco.Core/Models/Membership/User.cs index acc871d43b..52f91afb8a 100644 --- a/src/Umbraco.Core/Models/Membership/User.cs +++ b/src/Umbraco.Core/Models/Membership/User.cs @@ -41,6 +41,7 @@ public class User : EntityBase, IUser, IProfile private HashSet _userGroups; private string _username; + private UserType _type; /// /// Constructor for creating a new/empty user @@ -357,6 +358,13 @@ public class User : EntityBase, IUser, IProfile set => SetPropertyValueAndDetectChanges(value, ref _language, nameof(Language)); } + [DataMember] + public UserType Type + { + get => _type; + set => SetPropertyValueAndDetectChanges(value, ref _type, nameof(Type)); + } + /// /// Gets the groups that user is part of /// diff --git a/src/Umbraco.Core/Models/Membership/UserType.cs b/src/Umbraco.Core/Models/Membership/UserType.cs new file mode 100644 index 0000000000..beccd157fd --- /dev/null +++ b/src/Umbraco.Core/Models/Membership/UserType.cs @@ -0,0 +1,7 @@ +namespace Umbraco.Cms.Core.Models.Membership; + +public enum UserType +{ + Default = 0, + Api +} diff --git a/src/Umbraco.Core/Models/UserCreateModel.cs b/src/Umbraco.Core/Models/UserCreateModel.cs index 2df55df358..556fad5b15 100644 --- a/src/Umbraco.Core/Models/UserCreateModel.cs +++ b/src/Umbraco.Core/Models/UserCreateModel.cs @@ -12,5 +12,7 @@ public class UserCreateModel public string Name { get; set; } = string.Empty; + public UserType Type { get; set; } + public ISet UserGroupKeys { get; set; } = new HashSet(); } diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index 145c84a0d9..24e1e62894 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -52,6 +52,7 @@ public static partial class Constants public const string UserStartNode = TableNamePrefix + "UserStartNode"; public const string User2UserGroup = TableNamePrefix + "User2UserGroup"; public const string User2NodeNotify = TableNamePrefix + "User2NodeNotify"; + public const string User2ClientId = TableNamePrefix + "User2ClientId"; public const string UserGroup2App = TableNamePrefix + "UserGroup2App"; public const string UserData = TableNamePrefix + "UserData"; diff --git a/src/Umbraco.Core/Persistence/Repositories/IUserRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IUserRepository.cs index f0713f00be..0f9cc14f79 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IUserRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IUserRepository.cs @@ -150,4 +150,14 @@ public interface IUserRepository : IReadWriteQueryRepository int ClearLoginSessions(TimeSpan timespan); void ClearLoginSession(Guid sessionId); + + IEnumerable GetAllClientIds(); + + IEnumerable GetClientIds(int id); + + void AddClientId(int id, string clientId); + + bool RemoveClientId(int id, string clientId); + + IUser? GetByClientId(string clientId); } diff --git a/src/Umbraco.Core/Services/IUserService.cs b/src/Umbraco.Core/Services/IUserService.cs index d386800f21..cd600a083d 100644 --- a/src/Umbraco.Core/Services/IUserService.cs +++ b/src/Umbraco.Core/Services/IUserService.cs @@ -406,4 +406,12 @@ public interface IUserService : IMembershipUserService Task> ResendInvitationAsync(Guid performingUserKey, UserResendInviteModel model); Task> ResetPasswordAsync(Guid performingUserKey, Guid userKey); + + Task AddClientIdAsync(Guid userKey, string clientId); + + Task RemoveClientIdAsync(Guid userKey, string clientId); + + Task FindByClientIdAsync(string clientId); + + Task> GetClientIdsAsync(Guid userKey); } diff --git a/src/Umbraco.Core/Services/OperationStatus/UserClientCredentialsOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/UserClientCredentialsOperationStatus.cs new file mode 100644 index 0000000000..c33fd829b4 --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/UserClientCredentialsOperationStatus.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum UserClientCredentialsOperationStatus +{ + Success, + DuplicateClientId, + InvalidUser +} diff --git a/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs index d3bac8615a..ef16cd1471 100644 --- a/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs @@ -40,4 +40,5 @@ public enum UserOperationStatus NotInInviteState, SelfPasswordResetNotAllowed, DuplicateId, + InvalidUserType, } diff --git a/src/Umbraco.Core/Services/UserService.cs b/src/Umbraco.Core/Services/UserService.cs index dd25791d13..6397e96dd0 100644 --- a/src/Umbraco.Core/Services/UserService.cs +++ b/src/Umbraco.Core/Services/UserService.cs @@ -1189,6 +1189,11 @@ internal class UserService : RepositoryService, IUserService return Attempt.FailWithStatus(UserOperationStatus.UserNotFound, new PasswordChangedModel()); } + if (user.Type != UserType.Default) + { + return Attempt.FailWithStatus(UserOperationStatus.InvalidUserType, new PasswordChangedModel()); + } + IUser? performingUser = await userStore.GetAsync(performingUserKey); if (performingUser is null) { @@ -2478,6 +2483,51 @@ internal class UserService : RepositoryService, IUserService return CalculatePermissionsForPathForUser(groupPermissions, nodeIds); } + public async Task AddClientIdAsync(Guid userKey, string clientId) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + + IEnumerable currentClientIds = _userRepository.GetAllClientIds(); + if (currentClientIds.InvariantContains(clientId)) + { + return UserClientCredentialsOperationStatus.DuplicateClientId; + } + + IUser? user = await GetAsync(userKey); + if (user is null || user.Type != UserType.Api) + { + return UserClientCredentialsOperationStatus.InvalidUser; + } + + _userRepository.AddClientId(user.Id, clientId); + + return UserClientCredentialsOperationStatus.Success; + } + + public async Task RemoveClientIdAsync(Guid userKey, string clientId) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + + var userId = await _userIdKeyResolver.GetAsync(userKey); + return _userRepository.RemoveClientId(userId, clientId); + } + + public Task FindByClientIdAsync(string clientId) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + + IUser? user = _userRepository.GetByClientId(clientId); + return Task.FromResult(user?.Type == UserType.Api ? user : null); + } + + public async Task> GetClientIdsAsync(Guid userKey) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + + var userId = await _userIdKeyResolver.GetAsync(userKey); + return _userRepository.GetClientIds(userId); + } + /// /// This performs the calculations for inherited nodes based on this /// http://issues.umbraco.org/issue/U4-10075#comment=67-40085 diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs index a16e16f535..b118b7f84c 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs @@ -53,6 +53,7 @@ public class DatabaseSchemaCreator typeof(ContentType2ContentTypeDto), typeof(ContentTypeAllowedContentTypeDto), typeof(User2NodeNotifyDto), + typeof(User2ClientIdDto), typeof(ServerRegistrationDto), typeof(AccessDto), typeof(AccessRuleDto), diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 8502e4c529..e8fdac3925 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -88,5 +88,9 @@ public class UmbracoPlan : MigrationPlan // To 14.1.0 To("{FEF2DAF4-5408-4636-BB0E-B8798DF8F095}"); To("{A385C5DF-48DC-46B4-A742-D5BB846483BC}"); + + // To 15.0.0 + To("{7F4F31D8-DD71-4F0D-93FC-2690A924D84B}"); + To("{1A8835EF-F8AB-4472-B4D8-D75B7C164022}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/AddTypeToUser.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/AddTypeToUser.cs new file mode 100644 index 0000000000..4882813ce7 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/AddTypeToUser.cs @@ -0,0 +1,219 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseModelDefinitions; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Scoping; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0; + +[Obsolete("Remove in Umbraco 18.")] +public class AddTypeToUser : UnscopedMigrationBase +{ + private const string NewColumnName = "type"; + private readonly IScopeProvider _scopeProvider; + + public AddTypeToUser(IMigrationContext context, IScopeProvider scopeProvider) + : base(context) + => _scopeProvider = scopeProvider; + + protected override void Migrate() + { + InvalidateBackofficeUserAccess = true; + + using IScope scope = _scopeProvider.CreateScope(); + using IDisposable notificationSuppression = scope.Notifications.Suppress(); + ScopeDatabase(scope); + + // SQL server can simply add the column, but for SQLite this won't work, + // so we'll have to create a new table and copy over data. + if (DatabaseType != DatabaseType.SQLite) + { + MigrateSqlServer(); + } + else + { + MigrateSqlite(); + } + + Context.Complete(); + scope.Complete(); + } + + private void MigrateSqlServer() + { + var columns = SqlSyntax.GetColumnsInSchema(Context.Database).ToList(); + AddColumnIfNotExists(columns, NewColumnName); + } + + private void MigrateSqlite() + { + // If the new column already exists we'll do nothing. + if (ColumnExists(Constants.DatabaseSchema.Tables.User, NewColumnName)) + { + return; + } + + /* + * We commit the initial transaction started by the scope. This is required in order to disable the foreign keys. + * We then begin a new transaction, this transaction will be committed or rolled back by the scope, like normal. + * We don't have to worry about re-enabling the foreign keys, since these are enabled by default every time a connection is established. + * + * Ideally we'd want to do this with the unscoped database we get, however, this cannot be done, + * since our scoped database cannot share a connection with the unscoped database, so a new one will be created, which enables the foreign keys. + * Similarly we cannot use Database.CompleteTransaction(); since this also closes the connection, + * so starting a new transaction would re-enable foreign keys. + */ + Database.Execute("COMMIT;"); + Database.Execute("PRAGMA foreign_keys=off;"); + Database.Execute("BEGIN TRANSACTION;"); + + IEnumerable users = Database.Fetch().Select(x => new UserDto + { + Id = x.Id, + Key = x.Key, + Disabled = x.Disabled, + NoConsole = x.NoConsole, + UserName = x.UserName, + Login = x.Login, + Password = x.Password, + PasswordConfig = x.PasswordConfig, + Email = x.Email, + UserLanguage = x.UserLanguage, + SecurityStampToken = x.SecurityStampToken, + FailedLoginAttempts = x.FailedLoginAttempts, + LastLockoutDate = x.LastLockoutDate, + LastPasswordChangeDate = x.LastPasswordChangeDate, + LastLoginDate = x.LastLoginDate, + EmailConfirmedDate = x.EmailConfirmedDate, + InvitedDate = x.InvitedDate, + CreateDate = x.CreateDate, + UpdateDate = x.UpdateDate, + Avatar = x.Avatar, + Type = 0 + }); + + Delete.Table(Constants.DatabaseSchema.Tables.User).Do(); + Create.Table().Do(); + + foreach (UserDto user in users) + { + Database.Insert(Constants.DatabaseSchema.Tables.User, "id", false, user); + } + } + + [TableName(TableName)] + [PrimaryKey("id", AutoIncrement = true)] + [ExplicitColumns] + public class OldUserDto + { + public const string TableName = Constants.DatabaseSchema.Tables.User; + + public OldUserDto() + { + UserGroupDtos = new List(); + UserStartNodeDtos = new HashSet(); + } + + [Column("id")] + [PrimaryKeyColumn(Name = "PK_user")] + public int Id { get; set; } + + [Column("userDisabled")] + [Constraint(Default = "0")] + public bool Disabled { get; set; } + + [Column("key")] + [NullSetting(NullSetting = NullSettings.NotNull)] + [Constraint(Default = SystemMethods.NewGuid)] + [Index(IndexTypes.UniqueNonClustered, Name = "IX_umbracoUser_userKey")] + public Guid Key { get; set; } + + [Column("userNoConsole")] + [Constraint(Default = "0")] + public bool NoConsole { get; set; } + + [Column("userName")] + public string UserName { get; set; } = null!; + + [Column("userLogin")] + [Length(125)] + [Index(IndexTypes.NonClustered)] + public string? Login { get; set; } + + [Column("userPassword")] + [Length(500)] + public string? Password { get; set; } + + /// + /// This will represent a JSON structure of how the password has been created (i.e hash algorithm, iterations) + /// + [Column("passwordConfig")] + [NullSetting(NullSetting = NullSettings.Null)] + [Length(500)] + public string? PasswordConfig { get; set; } + + [Column("userEmail")] + public string Email { get; set; } = null!; + + [Column("userLanguage")] + [NullSetting(NullSetting = NullSettings.Null)] + [Length(10)] + public string? UserLanguage { get; set; } + + [Column("securityStampToken")] + [NullSetting(NullSetting = NullSettings.Null)] + [Length(255)] + public string? SecurityStampToken { get; set; } + + [Column("failedLoginAttempts")] + [NullSetting(NullSetting = NullSettings.Null)] + public int? FailedLoginAttempts { get; set; } + + [Column("lastLockoutDate")] + [NullSetting(NullSetting = NullSettings.Null)] + public DateTime? LastLockoutDate { get; set; } + + [Column("lastPasswordChangeDate")] + [NullSetting(NullSetting = NullSettings.Null)] + public DateTime? LastPasswordChangeDate { get; set; } + + [Column("lastLoginDate")] + [NullSetting(NullSetting = NullSettings.Null)] + public DateTime? LastLoginDate { get; set; } + + [Column("emailConfirmedDate")] + [NullSetting(NullSetting = NullSettings.Null)] + public DateTime? EmailConfirmedDate { get; set; } + + [Column("invitedDate")] + [NullSetting(NullSetting = NullSettings.Null)] + public DateTime? InvitedDate { get; set; } + + [Column("createDate")] + [NullSetting(NullSetting = NullSettings.NotNull)] + [Constraint(Default = SystemMethods.CurrentDateTime)] + public DateTime CreateDate { get; set; } = DateTime.Now; + + [Column("updateDate")] + [NullSetting(NullSetting = NullSettings.NotNull)] + [Constraint(Default = SystemMethods.CurrentDateTime)] + public DateTime UpdateDate { get; set; } = DateTime.Now; + + /// + /// Will hold the media file system relative path of the users custom avatar if they uploaded one + /// + [Column("avatar")] + [NullSetting(NullSetting = NullSettings.Null)] + [Length(500)] + public string? Avatar { get; set; } + + [ResultColumn] + [Reference(ReferenceType.Many, ReferenceMemberName = "UserId")] + public List UserGroupDtos { get; set; } + + [ResultColumn] + [Reference(ReferenceType.Many, ReferenceMemberName = "UserId")] + public HashSet UserStartNodeDtos { get; set; } + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/AddUserClientId.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/AddUserClientId.cs new file mode 100644 index 0000000000..12322dd651 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/AddUserClientId.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Infrastructure.Persistence.Dtos; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0; + +[Obsolete("Remove in Umbraco 18.")] +public class AddUserClientId : MigrationBase +{ + public AddUserClientId(IMigrationContext context) + : base(context) + { + } + + protected override void Migrate() + => Create.Table().Do(); +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/User2ClientIdDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/User2ClientIdDto.cs new file mode 100644 index 0000000000..dc2399dc6a --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/User2ClientIdDto.cs @@ -0,0 +1,20 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(Constants.DatabaseSchema.Tables.User2ClientId)] +[PrimaryKey("userId", AutoIncrement = false)] +[ExplicitColumns] +public class User2ClientIdDto +{ + [Column("userId")] + [PrimaryKeyColumn(AutoIncrement = false, Name = "PK_umbracoUser2ClientId", OnColumns = "userId, clientId")] + [ForeignKey(typeof(UserDto))] + public int UserId { get; set; } + + [Column("clientId")] + [Length(255)] + public string? ClientId { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/UserDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/UserDto.cs index 012e446162..ea892d8d47 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/UserDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/UserDto.cs @@ -103,6 +103,11 @@ public class UserDto [Constraint(Default = SystemMethods.CurrentDateTime)] public DateTime UpdateDate { get; set; } = DateTime.Now; + [Column("type")] + [NullSetting(NullSetting = NullSettings.NotNull)] + [Constraint(Default = 0)] + public short Type { get; set; } + /// /// Will hold the media file system relative path of the users custom avatar if they uploaded one /// diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/UserFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/UserFactory.cs index 969775da10..662b2f2e3f 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/UserFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/UserFactory.cs @@ -47,6 +47,7 @@ internal static class UserFactory user.Avatar = dto.Avatar; user.EmailConfirmedDate = dto.EmailConfirmedDate; user.InvitedDate = dto.InvitedDate; + user.Type = (UserType)dto.Type; // reset dirty initial properties (U4-1946) user.ResetDirtyProperties(false); @@ -82,6 +83,7 @@ internal static class UserFactory Avatar = entity.Avatar, EmailConfirmedDate = entity.EmailConfirmedDate, InvitedDate = entity.InvitedDate, + Type = (short)entity.Type }; if (entity.StartContentIds is not null) diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs index 9faf068d48..806a30bfbd 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs @@ -667,6 +667,7 @@ SELECT 4 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userDisabled = 0 $"DELETE FROM {Constants.DatabaseSchema.Tables.UserLogin} WHERE userId = @id", $"DELETE FROM {Constants.DatabaseSchema.Tables.User2UserGroup} WHERE userId = @id", $"DELETE FROM {Constants.DatabaseSchema.Tables.User2NodeNotify} WHERE userId = @id", + $"DELETE FROM {Constants.DatabaseSchema.Tables.User2ClientId} WHERE userId = @id", $"DELETE FROM {Constants.DatabaseSchema.Tables.UserStartNode} WHERE userId = @id", $"DELETE FROM {Constants.DatabaseSchema.Tables.ExternalLoginToken} WHERE externalLoginId = (SELECT id FROM {Constants.DatabaseSchema.Tables.ExternalLogin} WHERE userOrMemberKey = @key)", $"DELETE FROM {Constants.DatabaseSchema.Tables.ExternalLogin} WHERE userOrMemberKey = @key", @@ -1168,6 +1169,39 @@ SELECT 4 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userDisabled = 0 return pagedResult.Items.Select(x => UserFactory.BuildEntity(_globalSettings, x, _permissionMappers)); } + public IEnumerable GetAllClientIds() + => Database.Fetch(SqlContext.Sql() + .Select(d => d.ClientId) + .From()); + + public IEnumerable GetClientIds(int id) + => Database.Fetch(SqlContext.Sql() + .Select(d => d.ClientId) + .From() + .Where(d => d.UserId == id)); + + public void AddClientId(int id, string clientId) + => Database.Insert(new User2ClientIdDto { UserId = id, ClientId = clientId }); + + public bool RemoveClientId(int id, string clientId) + => Database.Delete(SqlContext.Sql() + .Where(d => d.UserId == id && d.ClientId == clientId)) > 0; + + public IUser? GetByClientId(string clientId) + { + var userId = Database.ExecuteScalar( + SqlContext.Sql() + .Select(d => d.UserId) + .From() + .Where(d => d.ClientId == clientId)); + if (userId == 0) + { + return null; + } + + return Get(userId); + } + private Sql ApplyFilter(Sql sql, Sql? filterSql, bool hasWhereClause) { if (filterSql == null) diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs b/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs index 4c7943c69c..061195e187 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs @@ -21,6 +21,7 @@ public class BackOfficeIdentityUser : UmbracoIdentityUser private DateTime? _inviteDateUtc; private int[] _startContentIds; private int[] _startMediaIds; + private UserType _type; /// /// Initializes a new instance of the class. @@ -115,6 +116,12 @@ public class BackOfficeIdentityUser : UmbracoIdentityUser } } + public UserType Type + { + get => _type; + set => BeingDirty.SetPropertyValueAndDetectChanges(value, ref _type, nameof(Type)); + } + /// /// Used to construct a new instance without an identity /// @@ -123,7 +130,7 @@ public class BackOfficeIdentityUser : UmbracoIdentityUser /// This is allowed to be null (but would need to be filled in if trying to persist this instance) /// /// - public static BackOfficeIdentityUser CreateNew(GlobalSettings globalSettings, string? username, string email, string culture, string? name = null, Guid? id = null) + public static BackOfficeIdentityUser CreateNew(GlobalSettings globalSettings, string? username, string email, string culture, string? name = null, Guid? id = null, UserType type = UserType.Default) { if (string.IsNullOrWhiteSpace(username)) { @@ -149,6 +156,7 @@ public class BackOfficeIdentityUser : UmbracoIdentityUser user.HasIdentity = false; user._culture = culture; user.Name = name; + user.Type = type; user.EnableChangeTracking(); return user; } diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeUserClientCredentialsManager.cs b/src/Umbraco.Infrastructure/Security/BackOfficeUserClientCredentialsManager.cs new file mode 100644 index 0000000000..7036fa0e88 --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/BackOfficeUserClientCredentialsManager.cs @@ -0,0 +1,70 @@ +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security.OperationStatus; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Infrastructure.Security; + +namespace Umbraco.Cms.Core.Security; + +public sealed class BackOfficeUserClientCredentialsManager : ClientCredentialsManagerBase, IBackOfficeUserClientCredentialsManager +{ + private readonly IBackOfficeUserManager _backOfficeUserManager; + private readonly IBackOfficeApplicationManager _backOfficeApplicationManager; + private readonly IUserService _userService; + + protected override string ClientIdPrefix => Constants.OAuthClientIds.BackOffice; + + public BackOfficeUserClientCredentialsManager( + IBackOfficeUserManager backOfficeUserManager, + IBackOfficeApplicationManager backOfficeApplicationManager, + IUserService userService) + { + _backOfficeUserManager = backOfficeUserManager; + _userService = userService; + _backOfficeApplicationManager = backOfficeApplicationManager; + } + + public async Task> SaveAsync(Guid userKey, string clientId, string clientSecret) + { + clientId = SafeClientId(clientId); + UserClientCredentialsOperationStatus result = await _userService.AddClientIdAsync(userKey, clientId); + + if (result != UserClientCredentialsOperationStatus.Success) + { + return result switch + { + UserClientCredentialsOperationStatus.InvalidUser => Attempt.Fail(BackOfficeUserClientCredentialsOperationStatus.InvalidUser), + UserClientCredentialsOperationStatus.DuplicateClientId => Attempt.Fail(BackOfficeUserClientCredentialsOperationStatus.DuplicateClientId), + _ => throw new ArgumentOutOfRangeException($"Unsupported client ID operation status: {result}") + }; + } + + await _backOfficeApplicationManager.EnsureBackOfficeClientCredentialsApplicationAsync(clientId, clientSecret); + + return Attempt.Succeed(BackOfficeUserClientCredentialsOperationStatus.Success); + } + + public async Task> DeleteAsync(Guid userKey, string clientId) + { + clientId = SafeClientId(clientId); + + await _backOfficeApplicationManager.DeleteBackOfficeClientCredentialsApplicationAsync(clientId); + await _userService.RemoveClientIdAsync(userKey, clientId); + + return Attempt.Succeed(BackOfficeUserClientCredentialsOperationStatus.Success); + } + + public async Task FindUserAsync(string clientId) + { + IUser? user = await _userService.FindByClientIdAsync(SafeClientId(clientId)); + if (user is null || user.IsApproved is false) + { + return null; + } + + return await _backOfficeUserManager.FindByNameAsync(user.Username); + } + + public async Task> GetClientIdsAsync(Guid userKey) + => await _userService.GetClientIdsAsync(userKey); +} diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs index c24a982e00..b68b3f2189 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs @@ -141,6 +141,7 @@ public class BackOfficeUserStore : StartMediaIds = user.StartMediaIds ?? new int[] { }, IsLockedOut = user.IsLockedOut, Key = user.Key, + Type = user.Type }; diff --git a/src/Umbraco.Infrastructure/Security/ClientCredentialsManagerBase.cs b/src/Umbraco.Infrastructure/Security/ClientCredentialsManagerBase.cs new file mode 100644 index 0000000000..2beea0de4d --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/ClientCredentialsManagerBase.cs @@ -0,0 +1,10 @@ +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Security; + +public abstract class ClientCredentialsManagerBase +{ + protected abstract string ClientIdPrefix { get; } + + protected string SafeClientId(string clientId) => clientId.EnsureStartsWith($"{ClientIdPrefix}-"); +} diff --git a/src/Umbraco.Infrastructure/Security/IBackOfficeApplicationManager.cs b/src/Umbraco.Infrastructure/Security/IBackOfficeApplicationManager.cs index 0e6bf8b354..b423273cff 100644 --- a/src/Umbraco.Infrastructure/Security/IBackOfficeApplicationManager.cs +++ b/src/Umbraco.Infrastructure/Security/IBackOfficeApplicationManager.cs @@ -3,4 +3,8 @@ public interface IBackOfficeApplicationManager { Task EnsureBackOfficeApplicationAsync(Uri backOfficeUrl, CancellationToken cancellationToken = default); + + Task EnsureBackOfficeClientCredentialsApplicationAsync(string clientId, string clientSecret, CancellationToken cancellationToken = default); + + Task DeleteBackOfficeClientCredentialsApplicationAsync(string clientId, CancellationToken cancellationToken = default); } diff --git a/src/Umbraco.Infrastructure/Security/IBackOfficeUserClientCredentialsManager.cs b/src/Umbraco.Infrastructure/Security/IBackOfficeUserClientCredentialsManager.cs new file mode 100644 index 0000000000..ecdb232098 --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/IBackOfficeUserClientCredentialsManager.cs @@ -0,0 +1,14 @@ +using Umbraco.Cms.Core.Security.OperationStatus; + +namespace Umbraco.Cms.Core.Security; + +public interface IBackOfficeUserClientCredentialsManager +{ + Task FindUserAsync(string clientId); + + Task> GetClientIdsAsync(Guid userKey); + + Task> SaveAsync(Guid userKey, string clientId, string clientSecret); + + Task> DeleteAsync(Guid userKey, string clientId); +} diff --git a/src/Umbraco.Infrastructure/Security/IMemberApplicationManager.cs b/src/Umbraco.Infrastructure/Security/IMemberApplicationManager.cs index f1533ced62..b2771305c8 100644 --- a/src/Umbraco.Infrastructure/Security/IMemberApplicationManager.cs +++ b/src/Umbraco.Infrastructure/Security/IMemberApplicationManager.cs @@ -5,4 +5,10 @@ public interface IMemberApplicationManager Task EnsureMemberApplicationAsync(IEnumerable loginRedirectUrls, IEnumerable logoutRedirectUrls, CancellationToken cancellationToken = default); Task DeleteMemberApplicationAsync(CancellationToken cancellationToken = default); + + Task EnsureMemberClientCredentialsApplicationAsync(string clientId, string clientSecret, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + Task DeleteMemberClientCredentialsApplicationAsync(string clientId, CancellationToken cancellationToken = default) + => Task.CompletedTask; } diff --git a/src/Umbraco.Infrastructure/Security/IMemberClientCredentialsManager.cs b/src/Umbraco.Infrastructure/Security/IMemberClientCredentialsManager.cs new file mode 100644 index 0000000000..323d0b0c6c --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/IMemberClientCredentialsManager.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Security; + +public interface IMemberClientCredentialsManager +{ + Task> GetAllAsync(); + + Task FindMemberAsync(string clientId); +} diff --git a/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs b/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs index c19053240d..a321875571 100644 --- a/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs +++ b/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs @@ -96,6 +96,7 @@ public class IdentityMapDefinition : IMapDefinition target.SecurityStamp = source.SecurityStamp; DateTime? lockedOutUntil = source.LastLockoutDate?.AddMinutes(_securitySettings.UserDefaultLockoutTimeInMinutes); target.LockoutEnd = source.IsLockedOut ? (lockedOutUntil ?? DateTime.MaxValue).ToUniversalTime() : null; + target.Type = source.Type; } // Umbraco.Code.MapAll -Id -LockoutEnabled -PhoneNumber -PhoneNumberConfirmed -ConcurrencyStamp -NormalizedEmail -NormalizedUserName -Roles diff --git a/src/Umbraco.Infrastructure/Security/MemberClientCredentials.cs b/src/Umbraco.Infrastructure/Security/MemberClientCredentials.cs new file mode 100644 index 0000000000..01c0cdaeda --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/MemberClientCredentials.cs @@ -0,0 +1,22 @@ +namespace Umbraco.Cms.Core.Security; + +public class MemberClientCredentials +{ + /// + /// Gets or sets the user name of the member to associate with the session after a successful login. + /// + /// The user name of the member. + public required string UserName { get; set; } + + /// + /// Gets or sets the client ID that allows for a successful login. + /// + /// The client ID. + public required string ClientId { get; set; } + + /// + /// Gets or sets the client secret that allows for a successful login. + /// + /// The client secret. + public required string ClientSecret { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Security/MemberClientCredentialsManager.cs b/src/Umbraco.Infrastructure/Security/MemberClientCredentialsManager.cs new file mode 100644 index 0000000000..5c83687ad1 --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/MemberClientCredentialsManager.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; + +namespace Umbraco.Cms.Core.Security; + +public sealed class MemberClientCredentialsManager : ClientCredentialsManagerBase, IMemberClientCredentialsManager +{ + private readonly DeliveryApiSettings _deliveryApiSettings; + private readonly IMemberManager _memberManager; + private readonly ILogger _logger; + + public MemberClientCredentialsManager( + IOptions deliveryApiSettings, + IMemberManager memberManager, + ILogger logger) + { + _deliveryApiSettings = deliveryApiSettings.Value; + _memberManager = memberManager; + _logger = logger; + } + + protected override string ClientIdPrefix => Constants.OAuthClientIds.Member; + + public Task> GetAllAsync() + { + IEnumerable result = IsDisabled() + ? Enumerable.Empty() + : _deliveryApiSettings + .MemberAuthorization! + .ClientCredentialsFlow! + .AssociatedMembers + .Select(m => new MemberClientCredentials + { + ClientId = SafeClientId(m.ClientId), + ClientSecret = m.ClientSecret, + UserName = m.UserName + }) + .ToArray(); + + return Task.FromResult(result); + } + + public async Task FindMemberAsync(string clientId) + { + clientId = SafeClientId(clientId); + var userName = IsDisabled() + ? null + : _deliveryApiSettings + .MemberAuthorization? + .ClientCredentialsFlow? + .AssociatedMembers + .FirstOrDefault(m => SafeClientId(m.ClientId) == clientId)? + .UserName; + + if (userName is null) + { + return null; + } + + MemberIdentityUser? user = await _memberManager.FindByNameAsync(userName); + if (user is null) + { + _logger.LogWarning("The member with username {userName} could not be retrieved by the member manager", userName); + } + + return user; + } + + private bool IsDisabled() => _deliveryApiSettings.MemberAuthorization?.ClientCredentialsFlow?.Enabled is not true + || _deliveryApiSettings.MemberAuthorization.ClientCredentialsFlow.AssociatedMembers.Any() is false; +} diff --git a/src/Umbraco.Infrastructure/Security/OperationStatus/BackOfficeUserClientCredentialsOperationStatus.cs b/src/Umbraco.Infrastructure/Security/OperationStatus/BackOfficeUserClientCredentialsOperationStatus.cs new file mode 100644 index 0000000000..fd86ce68cb --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/OperationStatus/BackOfficeUserClientCredentialsOperationStatus.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Security.OperationStatus; + +public enum BackOfficeUserClientCredentialsOperationStatus +{ + Success, + DuplicateClientId, + InvalidUser +} diff --git a/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs b/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs index ecffa6b130..31b3963bb9 100644 --- a/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs +++ b/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs @@ -298,7 +298,8 @@ public class BackOfficeUserManager : UmbracoUserManager { userGroup.Key } + }; + + var userKey = (await userService.CreateAsync(Constants.Security.SuperUserKey, creationModel, true)).Result.CreatedUser!.Key; + + var result = await userService.ResetPasswordAsync(Constants.Security.SuperUserKey, userKey); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(UserOperationStatus.Success, result.Status); + Assert.IsNotNull(result.Result.ResetPassword); + }); + } + + [Test] + public async Task Cannot_Reset_Password_For_Api_User() + { + var securitySettings = new SecuritySettings(); + var userService = CreateUserService(securitySettings); + + var userGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + var creationModel = new UserCreateModel + { + UserName = "some@one", + Email = "some@one", + Name = "Some One", + UserGroupKeys = new HashSet { userGroup.Key }, + Type = UserType.Api + }; + + var userKey = (await userService.CreateAsync(Constants.Security.SuperUserKey, creationModel, true)).Result.CreatedUser!.Key; + + var result = await userService.ResetPasswordAsync(Constants.Security.SuperUserKey, userKey); + Assert.Multiple(() => + { + Assert.IsFalse(result.Success); + Assert.AreEqual(UserOperationStatus.InvalidUserType, result.Status); + Assert.IsNull(result.Result.ResetPassword); + Assert.IsNull(result.Exception); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Create.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Create.cs index 133e169928..aaa2f619ec 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Create.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Create.cs @@ -50,6 +50,37 @@ public partial class UserServiceCrudTests Assert.IsNotNull(createdUser); Assert.AreEqual(username, createdUser.Username); Assert.AreEqual(email, createdUser.Email); + Assert.AreEqual(UserType.Default, createdUser.Type); + } + + [TestCase(UserType.Default)] + [TestCase(UserType.Api)] + public async Task Can_Create_All_User_Types(UserType type) + { + var securitySettings = new SecuritySettings(); + var userService = CreateUserService(securitySettings); + + var userGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + var creationModel = new UserCreateModel + { + UserName = "api@local", + Email = "api@local", + Name = "API user", + UserGroupKeys = new HashSet { userGroup.Key }, + Type = type + }; + + var result = await userService.CreateAsync(Constants.Security.SuperUserKey, creationModel, true); + + Assert.IsTrue(result.Success); + Assert.AreEqual(UserOperationStatus.Success, result.Status); + var createdUser = result.Result.CreatedUser; + Assert.IsNotNull(createdUser); + Assert.AreEqual(type, createdUser.Type); + + var user = await userService.GetAsync(createdUser.Key); + Assert.NotNull(user); + Assert.AreEqual(type, user.Type); } [Test]