From 9b626d02c83d531dc96e470fd4a5483469a58afb Mon Sep 17 00:00:00 2001 From: Mole Date: Wed, 29 Mar 2023 08:14:47 +0200 Subject: [PATCH] New backoffice: User controller (#13947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add UserResponseModel * Add factory to created UserResponseModel * Add GetByKey controller * Add GetAllUsers endpoint * User proper response model * Make naming consistent * Order by username in GetAll * Add user filter endpoint * Fix includer user states * Remove gravatar from the backend * Send user avatars in response * Add create user model * start working on create * Validate the create model * Add authorization to create * Use UserRepository instead of UserService to ValidateSessíonId * Create IBackofficeUserStore interface This is essentially a core-friendly version of the BackOfficeUserStore, additionally it contains basic methods for managing users, I.E. Get users, save users, create users, etc. * Remove more usages of user service * Remove usages of IUserService in BackofficeUserStore * Add documentation * Fix tests and DI * add IBackOfficeUserStoreAccessor to resolve it in singleton services * Resolve circular dependency * Remove obsolete constructor * Add core friendly user manager * Finish createasync in user service * Add WIP create endpoint * Save newly creates users user groups * Use service scope for user service * Remove now unnecessary accessors * Add response types * Add update user endpoint * Add EmailUserInviteSender * Add technology free way of creating confirmation token * Add invite uri provider * Add invite user to user service * Add invite user controller * Add delete endpoint * Add operation status responses * Add operation status responses * Added temporary file uploads including a repository implementation using local temp folder. * Add Disable users endpoint * missing files * Fixed copy paste error * Fix create users return type * Updated OpenApi.json * Updated OpenApi.json * Handle if created failed in identity * Add enable user * Make users plural in enable/disable We're doing the operation on multiple entities * Added file extension check * Add unlock user endpoint * Clean up. Removed old TemporaryFileService and UploadFileService and updated dictionary items to use this new items * Clean up * Add reset password * Add UpdateUserGroupsOnUsers method * Add UpdateUserGroups * Get rid of stream directly on TemporaryFileModel, and use delegate to open stream instead. * Fix post merge * Use keys instead of IDs * Add ClearAvatar endpoint * Review changes * Moved models to their own files * Reverted launch settings * Move enlist extension to its own namespace * Create set avatar endpoint * Add reponse types * Remove infrastructure extension after merge * Add Cmapatibility suppressions * Add test suppression * Add integration tests * Fix issue found in tests * Add invited user to UserInvitationResult * Add more tests * Add update tests * Hide different tests under parent * Return DuplicatUserName user operation status if username matches an email * Add update tests * Change sorted set to HashSet It doesn't work if it's not IComparable * Change ID to Key when checking super * Add get tests * Add more GetAllTests * Move tests to the right namespace * Add filter test * Fix including disabled users bug found by test * Add test to ensure invited user state * Add test case for UserState.All * Add more filter tests * Add enable disable tests * Add resolver for keys and ids * Replace usages of IUserService with IUserIdKeyResolver * Add CompatibilitySuppressions * Add UserIdKeyResolverTests * Fix UserIdKeyResolver * Add missing user operation results * Updates from review * ID not key * Post instead of patch * Use set instead of params for enable/disable * Don't call to array * Use sets for usergroup keys and user keys instead * LanguageIsoCode instead of Language * Update CompatibilitySuppressions after changin enumerable to set --------- Co-authored-by: Bjarke Berg Co-authored-by: kjac --- .../Controllers/Users/ByKeyUsersController.cs | 39 + .../Users/ChangePasswordUsersController.cs | 60 ++ .../Users/ClearAvatarUsersController.cs | 22 + .../Users/CreateUsersController.cs | 49 + .../Users/DeleteUsersController.cs | 22 + .../Users/DisableUsersController.cs | 29 + .../Users/EnableUsersController.cs | 29 + .../Users/FilterUsersController.cs | 76 ++ .../Users/GetAllUsersController.cs | 59 ++ .../Users/InviteUsersController.cs | 46 + .../Users/SetAvatarUsersController.cs | 37 + .../Users/UnlockUsersController.cs | 35 + .../Users/UpdateUserGroupsUsersController.cs | 25 + .../Users/UpdateUsersController.cs | 47 + .../Controllers/Users/UsersControllerBase.cs | 80 ++ .../UsersBuilderExtensions.cs | 14 + .../Factories/IUserPresentationFactory.cs | 18 + .../Factories/UserPresentationFactory.cs | 126 +++ .../ManagementApiComposer.cs | 1 + .../Users/ChangePasswordUserRequestModel.cs | 14 + .../Users/ChangePasswordUserResponseModel.cs | 6 + .../Users/CreateUserRequestModel.cs | 6 + .../Users/CreateUserResponseModel.cs | 8 + .../Users/DisableUserRequestModel.cs | 6 + .../Users/EnableUserRequestModel.cs | 6 + .../Users/InviteUserRequestModel.cs | 6 + .../ViewModels/Users/SetAvatarRequestModel.cs | 6 + .../Users/UnlockUsersRequestModel.cs | 6 + .../UpdateUserGroupsOnUserRequestModel.cs | 8 + .../Users/UpdateUserRequestModel.cs | 10 + .../ViewModels/Users/UserPresentationBase.cs | 12 + .../ViewModels/Users/UserResponseModel.cs | 30 + .../CompatibilitySuppressions.xml | 84 ++ .../TemporaryFileServiceExtensions.cs | 4 +- .../ChangeBackofficeUserPasswordModel.cs | 16 + .../Models/Membership/IErrorMessageResult.cs | 6 + .../Membership/IdentityCreationResult.cs | 13 + .../Models/Membership/UserCreationResult.cs | 10 + .../Models/Membership/UserFilter.cs | 52 + .../Models/Membership/UserInvitationResult.cs | 8 + .../Models/Membership/UserOrder.cs | 15 + .../Models/Membership/UserUnlockResult.cs | 6 + src/Umbraco.Core/Models/UserCreateModel.cs | 14 + src/Umbraco.Core/Models/UserExtensions.cs | 46 +- .../Models/UserInvitationMessage.cs | 14 + src/Umbraco.Core/Models/UserInviteModel.cs | 6 + src/Umbraco.Core/Models/UserUpdateModel.cs | 22 + .../Security/IBackofficePasswordChanger.cs | 9 + .../Security/IBackofficeUserStore.cs | 73 ++ .../Security/ICoreBackofficeUserManager.cs | 21 + .../Security/IInviteUriProvider.cs | 10 + .../Security/ILocalLoginSettingProvider.cs | 12 + .../Security/IUserInviteSender.cs | 10 + .../Services/ContentEditingService.cs | 12 +- .../Services/DataTypeContainerService.cs | 10 +- src/Umbraco.Core/Services/DataTypeService.cs | 27 +- .../Services/DictionaryItemService.cs | 12 +- src/Umbraco.Core/Services/FileService.cs | 16 +- .../Services/IUserGroupService.cs | 10 + .../Services/IUserIdKeyResolver.cs | 8 + src/Umbraco.Core/Services/IUserService.cs | 50 + src/Umbraco.Core/Services/LanguageService.cs | 10 +- .../Services/LocalizationService.cs | 18 +- .../Services/MediaEditingService.cs | 14 +- .../OperationStatus/UserOperationStatus.cs | 24 + src/Umbraco.Core/Services/RelationService.cs | 12 +- src/Umbraco.Core/Services/TemplateService.cs | 14 +- src/Umbraco.Core/Services/UserGroupService.cs | 50 +- src/Umbraco.Core/Services/UserService.cs | 969 +++++++++++++++--- .../CompatibilitySuppressions.xml | 8 +- .../UmbracoBuilder.CoreServices.cs | 3 + .../UmbracoBuilder.Services.cs | 1 + .../Security/BackOfficeUserStore.cs | 293 +++++- .../Security/EmailUserInviteSender.cs | 73 ++ .../Services/Implement/UserIdKeyResolver.cs | 51 + .../UmbracoBuilder.BackOfficeAuth.cs | 1 + .../UmbracoBuilder.BackOfficeIdentity.cs | 20 +- .../BackOfficeExternalLoginProviders.cs | 3 +- .../Security/BackofficePasswordChanger.cs | 34 + .../Security/InviteUriProvider.cs | 59 ++ .../CompatibilitySuppressions.xml | 8 +- .../Security/BackOfficeUserManager.cs | 87 +- .../CompatibilitySuppressions.xml | 1 - .../CompatibilitySuppressions.xml | 1 - .../Services/UserServiceCrudTests.Create.cs | 143 +++ .../Services/UserServiceCrudTests.Delete.cs | 70 ++ .../Services/UserServiceCrudTests.Filter.cs | 245 +++++ .../Services/UserServiceCrudTests.Get.cs | 162 +++ .../Services/UserServiceCrudTests.Invite.cs | 164 +++ .../UserServiceCrudTests.PartialUpdates.cs | 107 ++ .../Services/UserServiceCrudTests.Update.cs | 189 ++++ .../Services/UserServiceCrudTests.cs | 87 ++ .../Security/BackOfficeUserStoreTests.cs | 28 +- .../Services/UserIdKeyResolverTests.cs | 76 ++ .../Umbraco.Tests.Integration.csproj | 21 + 95 files changed, 4254 insertions(+), 326 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Users/ByKeyUsersController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Users/ChangePasswordUsersController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Users/ClearAvatarUsersController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Users/CreateUsersController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Users/DeleteUsersController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Users/DisableUsersController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Users/EnableUsersController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Users/FilterUsersController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Users/GetAllUsersController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Users/InviteUsersController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Users/SetAvatarUsersController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Users/UnlockUsersController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Users/UpdateUserGroupsUsersController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Users/UpdateUsersController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Users/UsersControllerBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/DependencyInjection/UsersBuilderExtensions.cs create mode 100644 src/Umbraco.Cms.Api.Management/Factories/IUserPresentationFactory.cs create mode 100644 src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Users/ChangePasswordUserRequestModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Users/ChangePasswordUserResponseModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Users/CreateUserRequestModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Users/CreateUserResponseModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Users/DisableUserRequestModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Users/EnableUserRequestModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Users/InviteUserRequestModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Users/SetAvatarRequestModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Users/UnlockUsersRequestModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Users/UpdateUserGroupsOnUserRequestModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Users/UpdateUserRequestModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Users/UserPresentationBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Users/UserResponseModel.cs rename src/{Umbraco.Infrastructure => Umbraco.Core}/Extensions/TemporaryFileServiceExtensions.cs (75%) create mode 100644 src/Umbraco.Core/Models/Membership/ChangeBackofficeUserPasswordModel.cs create mode 100644 src/Umbraco.Core/Models/Membership/IErrorMessageResult.cs create mode 100644 src/Umbraco.Core/Models/Membership/IdentityCreationResult.cs create mode 100644 src/Umbraco.Core/Models/Membership/UserCreationResult.cs create mode 100644 src/Umbraco.Core/Models/Membership/UserFilter.cs create mode 100644 src/Umbraco.Core/Models/Membership/UserInvitationResult.cs create mode 100644 src/Umbraco.Core/Models/Membership/UserOrder.cs create mode 100644 src/Umbraco.Core/Models/Membership/UserUnlockResult.cs create mode 100644 src/Umbraco.Core/Models/UserCreateModel.cs create mode 100644 src/Umbraco.Core/Models/UserInvitationMessage.cs create mode 100644 src/Umbraco.Core/Models/UserInviteModel.cs create mode 100644 src/Umbraco.Core/Models/UserUpdateModel.cs create mode 100644 src/Umbraco.Core/Security/IBackofficePasswordChanger.cs create mode 100644 src/Umbraco.Core/Security/IBackofficeUserStore.cs create mode 100644 src/Umbraco.Core/Security/ICoreBackofficeUserManager.cs create mode 100644 src/Umbraco.Core/Security/IInviteUriProvider.cs create mode 100644 src/Umbraco.Core/Security/ILocalLoginSettingProvider.cs create mode 100644 src/Umbraco.Core/Security/IUserInviteSender.cs create mode 100644 src/Umbraco.Core/Services/IUserIdKeyResolver.cs create mode 100644 src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs create mode 100644 src/Umbraco.Infrastructure/Security/EmailUserInviteSender.cs create mode 100644 src/Umbraco.Infrastructure/Services/Implement/UserIdKeyResolver.cs create mode 100644 src/Umbraco.Web.BackOffice/Security/BackofficePasswordChanger.cs create mode 100644 src/Umbraco.Web.BackOffice/Security/InviteUriProvider.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Create.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Delete.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Filter.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Get.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Invite.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.PartialUpdates.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Update.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/UserIdKeyResolverTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Users/ByKeyUsersController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Users/ByKeyUsersController.cs new file mode 100644 index 0000000000..aa3e48fa5e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Users/ByKeyUsersController.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Users; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Users; + +public class ByKeyUsersController : UsersControllerBase +{ + private readonly IUserService _userService; + private readonly IUserPresentationFactory _userPresentationFactory; + + public ByKeyUsersController( + IUserService userService, + IUserPresentationFactory userPresentationFactory) + { + _userService = userService; + _userPresentationFactory = userPresentationFactory; + } + + [HttpGet("{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(UserResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ByKey(Guid id) + { + IUser? user = await _userService.GetAsync(id); + + if (user is null) + { + return NotFound(); + } + + UserResponseModel responseModel = _userPresentationFactory.CreateResponseModel(user); + return Ok(responseModel); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Users/ChangePasswordUsersController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Users/ChangePasswordUsersController.cs new file mode 100644 index 0000000000..1cc01fcb4f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Users/ChangePasswordUsersController.cs @@ -0,0 +1,60 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.Builders; +using Umbraco.Cms.Api.Management.ViewModels.Users; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Users; + +public class ChangePasswordUsersController : UsersControllerBase +{ + private readonly IUserService _userService; + + public ChangePasswordUsersController(IUserService userService) + { + _userService = userService; + } + + [HttpPost("change-password/{id:guid}")] + [MapToApiVersion("1.0")] + public async Task ChangePassword(Guid id, ChangePasswordUserRequestModel model) + { + IUser? existingUser = await _userService.GetAsync(id); + + if (existingUser is null) + { + return NotFound(); + } + + var passwordModel = new ChangeBackofficeUserPasswordModel + { + NewPassword = model.NewPassword, + OldPassword = model.OldPassword, + User = existingUser, + }; + + // FIXME: use the actual currently logged in user key + Attempt response = await _userService.ChangePasswordAsync(Constants.Security.SuperUserKey, passwordModel); + + if (response.Success) + { + return Ok(new ChangePasswordUserResponseModel { ResetPassword = response.Result.ResetPassword }); + } + + if (response.Result.ChangeError is not null) + { + ValidationResult validationError = response.Result.ChangeError; + + return BadRequest(new ProblemDetailsBuilder() + .WithTitle("Password change failed") + .WithDetail(validationError.ErrorMessage!) + .Build()); + } + + return UserOperationStatusResult(response.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Users/ClearAvatarUsersController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Users/ClearAvatarUsersController.cs new file mode 100644 index 0000000000..c313d43c31 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Users/ClearAvatarUsersController.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Users; + +public class ClearAvatarUsersController : UsersControllerBase +{ + private readonly IUserService _userService; + + public ClearAvatarUsersController(IUserService userService) => _userService = userService; + + [HttpDelete("avatar/{id:guid}")] + public async Task ClearAvatar(Guid id) + { + UserOperationStatus result = await _userService.ClearAvatarAsync(id); + + return result is UserOperationStatus.Success + ? Ok() + : UserOperationStatusResult(result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Users/CreateUsersController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Users/CreateUsersController.cs new file mode 100644 index 0000000000..16ab9633e9 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Users/CreateUsersController.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Users; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Users; + +public class CreateUsersController : UsersControllerBase +{ + private readonly IUserService _userService; + private readonly IUserPresentationFactory _presentationFactory; + + public CreateUsersController( + IUserService userService, + IUserPresentationFactory presentationFactory) + { + _userService = userService; + _presentationFactory = presentationFactory; + } + + [HttpPost] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(CreateUserResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task Create(CreateUserRequestModel model) + { + UserCreateModel createModel = await _presentationFactory.CreateCreationModelAsync(model); + + // FIXME: use the actual currently logged in user key + Attempt result = await _userService.CreateAsync(Constants.Security.SuperUserKey, createModel, true); + + if (result.Success) + { + return Ok(_presentationFactory.CreateCreationResponseModel(result.Result)); + } + + if (result.Status is UserOperationStatus.UnknownFailure) + { + return FormatErrorMessageResult(result.Result); + } + + return UserOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Users/DeleteUsersController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Users/DeleteUsersController.cs new file mode 100644 index 0000000000..3348211de8 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Users/DeleteUsersController.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Users; + +public class DeleteUsersController : UsersControllerBase +{ + public DeleteUsersController(IUserService userService) => _userService = userService; + + private readonly IUserService _userService; + + [HttpDelete("{id:guid}")] + public async Task DeleteUser(Guid id) + { + UserOperationStatus result = await _userService.DeleteAsync(id); + + return result is UserOperationStatus.Success + ? Ok() + : UserOperationStatusResult(result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Users/DisableUsersController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Users/DisableUsersController.cs new file mode 100644 index 0000000000..ae37ff9416 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Users/DisableUsersController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Users; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Users; + +public class DisableUsersController : UsersControllerBase +{ + private readonly IUserService _userService; + + public DisableUsersController(IUserService userService) => _userService = userService; + + [HttpPost("disable")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task DisableUsers(DisableUserRequestModel model) + { + // FIXME: use the actual currently logged in user key + UserOperationStatus result = await _userService.DisableAsync(Constants.Security.SuperUserKey, model.UserIds); + + return result is UserOperationStatus.Success + ? Ok() + : UserOperationStatusResult(result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Users/EnableUsersController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Users/EnableUsersController.cs new file mode 100644 index 0000000000..7678471865 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Users/EnableUsersController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Users; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Users; + +public class EnableUsersController : UsersControllerBase +{ + private readonly IUserService _userService; + + public EnableUsersController(IUserService userService) => _userService = userService; + + [HttpPost("enable")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task EnableUsers(EnableUserRequestModel model) + { + // FIXME: use the actual currently logged in user key + UserOperationStatus result = await _userService.EnableAsync(Constants.Security.SuperUserKey, model.UserIds); + + return result is UserOperationStatus.Success + ? Ok() + : UserOperationStatusResult(result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Users/FilterUsersController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Users/FilterUsersController.cs new file mode 100644 index 0000000000..b646abe02f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Users/FilterUsersController.cs @@ -0,0 +1,76 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Users; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.New.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.Controllers.Users; + +public class FilterUsersController : UsersControllerBase +{ + private readonly IUserService _userService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IUserPresentationFactory _userPresentationFactory; + + public FilterUsersController( + IUserService userService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IUserPresentationFactory userPresentationFactory) + { + _userService = userService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _userPresentationFactory = userPresentationFactory; + } + + /// + /// Query users + /// + /// Amount to skip. + /// Amount to take. + /// Property to order by. + /// Direction to order in. + /// Keys of the user groups to include in the result. + /// User states to include in the result. + /// A string that must be present in the users name or username. + /// A paged result of the users matching the query. + [HttpGet("filter")] + [MapToApiVersion("1.0")] + public async Task Filter( + int skip = 0, + int take = 100, + UserOrder orderBy = UserOrder.UserName, + Direction orderDirection = Direction.Ascending, + [FromQuery] SortedSet? userGroupIds = null, + [FromQuery] SortedSet? userStates = null, + string filter = "") + { + var userFilter = new UserFilter + { + IncludedUserGroups = userGroupIds, + IncludeUserStates = userStates, + NameFilters = string.IsNullOrEmpty(filter) ? null : new SortedSet { filter } + }; + + // FIXME: use the actual currently logged in user key + Attempt, UserOperationStatus> filterAttempt = + await _userService.FilterAsync(Constants.Security.SuperUserKey, userFilter, skip, take, orderBy, orderDirection); + + if (filterAttempt.Success is false) + { + return UserOperationStatusResult(filterAttempt.Status); + } + + var responseModel = new PagedViewModel + { + Total = filterAttempt.Result.Total, + Items = filterAttempt.Result.Items.Select(_userPresentationFactory.CreateResponseModel).ToArray() + }; + + return Ok(responseModel); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Users/GetAllUsersController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Users/GetAllUsersController.cs new file mode 100644 index 0000000000..ee033d841f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Users/GetAllUsersController.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Users; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Exceptions; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.New.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.Controllers.Users; + +public class GetAllUsersController : UsersControllerBase +{ + private readonly IUserService _userService; + private readonly IUserPresentationFactory _userPresentationFactory; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public GetAllUsersController( + IUserService userService, + IUserPresentationFactory userPresentationFactory, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _userService = userService; + _userPresentationFactory = userPresentationFactory; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpGet] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + public async Task GetAll(int skip = 0, int take = 100) + { + // FIXME: use the actual currently logged in user key + Attempt?, UserOperationStatus> attempt = await _userService.GetAllAsync(Constants.Security.SuperUserKey, skip, take); + + if (attempt.Success is false) + { + return UserOperationStatusResult(attempt.Status); + } + + PagedModel? result = attempt.Result; + if (result is null) + { + throw new PanicException("Get all attempt succeeded, but result was null"); + } + + var pagedViewModel = new PagedViewModel + { + Total = result.Total, + Items = result.Items.Select(x => _userPresentationFactory.CreateResponseModel(x)) + }; + + return Ok(pagedViewModel); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Users/InviteUsersController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Users/InviteUsersController.cs new file mode 100644 index 0000000000..fc504a7120 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Users/InviteUsersController.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Users; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Users; + +public class InviteUsersController : UsersControllerBase +{ + private readonly IUserService _userService; + private readonly IUserPresentationFactory _userPresentationFactory; + + public InviteUsersController( + IUserService userService, + IUserPresentationFactory userPresentationFactory) + { + _userService = userService; + _userPresentationFactory = userPresentationFactory; + } + + + [HttpPost("invite")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task Invite(InviteUserRequestModel model) + { + UserInviteModel userInvite = await _userPresentationFactory.CreateInviteModelAsync(model); + + // FIXME: use the actual currently logged in user key + Attempt result = await _userService.InviteAsync(Constants.Security.SuperUserKey, userInvite); + + if (result.Success) + { + return Ok(); + } + + return result.Status is UserOperationStatus.UnknownFailure + ? FormatErrorMessageResult(result.Result) + : UserOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Users/SetAvatarUsersController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Users/SetAvatarUsersController.cs new file mode 100644 index 0000000000..0f90aaec3c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Users/SetAvatarUsersController.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Users; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Users; + +public class SetAvatarUsersController : UsersControllerBase +{ + private readonly IUserService _userService; + + public SetAvatarUsersController(IUserService userService) + { + _userService = userService; + } + + [HttpPost("avatar/{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task SetAvatar(Guid id, SetAvatarRequestModel model) + { + IUser? user = await _userService.GetAsync(id); + + if (user is null) + { + return NotFound(); + } + + UserOperationStatus result = await _userService.SetAvatarAsync(user, model.FileKey); + + return result is UserOperationStatus.Success + ? Ok() + : UserOperationStatusResult(result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Users/UnlockUsersController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Users/UnlockUsersController.cs new file mode 100644 index 0000000000..41d70ae3e1 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Users/UnlockUsersController.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Users; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Users; + +public class UnlockUsersController : UsersControllerBase +{ + private readonly IUserService _userService; + + public UnlockUsersController(IUserService userService) => _userService = userService; + + [HttpPost("unlock")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task UnlockUsers(UnlockUsersRequestModel model) + { + // FIXME: use the actual currently logged in user key + Attempt attempt = await _userService.UnlockAsync(Constants.Security.SuperUserKey, model.UserIds.ToArray()); + + if (attempt.Success) + { + return Ok(); + } + + return attempt.Status is UserOperationStatus.UnknownFailure + ? FormatErrorMessageResult(attempt.Result) + : UserOperationStatusResult(attempt.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Users/UpdateUserGroupsUsersController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Users/UpdateUserGroupsUsersController.cs new file mode 100644 index 0000000000..b0776aa6f9 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Users/UpdateUserGroupsUsersController.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Users; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Users; + +public class UpdateUserGroupsUsersController : UsersControllerBase +{ + private readonly IUserGroupService _userGroupService; + + public UpdateUserGroupsUsersController(IUserGroupService userGroupService) + { + _userGroupService = userGroupService; + } + + [HttpPost("set-user-groups")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task UpdateUserGroups(UpdateUserGroupsOnUserRequestModel requestModel) + { + await _userGroupService.UpdateUserGroupsOnUsers(requestModel.UserGroupIds, requestModel.UserIds); + return Ok(); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Users/UpdateUsersController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Users/UpdateUsersController.cs new file mode 100644 index 0000000000..6daca51b62 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Users/UpdateUsersController.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Users; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Users; + +public class UpdateUsersController : UsersControllerBase +{ + private readonly IUserService _userService; + private readonly IUserPresentationFactory _userPresentationFactory; + + public UpdateUsersController( + IUserService userService, + IUserPresentationFactory userPresentationFactory) + { + _userService = userService; + _userPresentationFactory = userPresentationFactory; + } + + [HttpPut("{id:guid}")] + [MapToApiVersion("1.0")] + public async Task Update(Guid id, UpdateUserRequestModel model) + { + IUser? existingUser = await _userService.GetAsync(id); + + if (existingUser is null) + { + return NotFound(); + } + + // We have to use and intermediate save model, and cannot map it directly to an IUserModel + // This is because we need to compare the updated values with what the user already has, for audit purposes. + UserUpdateModel updateModel = await _userPresentationFactory.CreateUpdateModelAsync(existingUser, model); + + // FIXME: use the actual currently logged in user key + Attempt result = await _userService.UpdateAsync(Constants.Security.SuperUserKey, updateModel); + + return result.Success + ? Ok() + : UserOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Users/UsersControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Users/UsersControllerBase.cs new file mode 100644 index 0000000000..4c2c88bb84 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Users/UsersControllerBase.cs @@ -0,0 +1,80 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.Builders; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Users; + +[ApiController] +[VersionedApiBackOfficeRoute("users")] +[ApiExplorerSettings(GroupName = "Users")] +[ApiVersion("1.0")] +public abstract class UsersControllerBase : ManagementApiControllerBase +{ + protected IActionResult UserOperationStatusResult(UserOperationStatus status) => + status switch + { + UserOperationStatus.Success => Ok(), + UserOperationStatus.MissingUser => StatusCode(StatusCodes.Status500InternalServerError, "A performing user is required for the operation, but none was found."), + UserOperationStatus.MissingUserGroup => NotFound(new ProblemDetailsBuilder() + .WithTitle("Missing User Group") + .WithDetail("The specified user group was not found.") + .Build()), + UserOperationStatus.NoUserGroup => BadRequest(new ProblemDetailsBuilder() + .WithTitle("No User Group Specified") + .WithDetail("A user group must be specified to create a user") + .Build()), + UserOperationStatus.UserNameIsNotEmail => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid Username") + .WithDetail("The username must be the same as the email.") + .Build()), + UserOperationStatus.EmailCannotBeChanged => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Email Cannot be changed") + .WithDetail("Local login is disabled, so the email cannot be changed.") + .Build()), + UserOperationStatus.DuplicateUserName => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Duplicate Username") + .WithDetail("The username is already in use.") + .Build()), + UserOperationStatus.DuplicateEmail => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Duplicate Email") + .WithDetail("The email is already in use.") + .Build()), + UserOperationStatus.Unauthorized => Unauthorized(), + UserOperationStatus.CancelledByNotification => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Cancelled by notification") + .WithDetail("A notification handler prevented the user operation.") + .Build()), + UserOperationStatus.CannotInvite => StatusCode(StatusCodes.Status500InternalServerError, "Cannot send user invitation."), + UserOperationStatus.CannotDelete => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Cannot delete user") + .WithDetail("The user cannot be deleted.") + .Build()), + UserOperationStatus.CannotDisableSelf => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Cannot disable") + .WithDetail("A user cannot disable itself.") + .Build()), + UserOperationStatus.OldPasswordRequired => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Old password required") + .WithDetail("The old password is required to change the password of the specified user.") + .Build()), + UserOperationStatus.InvalidAvatar => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid avatar") + .WithDetail("The selected avatar is invalid") + .Build()), + UserOperationStatus.NotFound => NotFound(), + UserOperationStatus.CannotDisableInvitedUser => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Cannot disable invited user") + .WithDetail("An invited user cannot be disabled.") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown user operation status."), + }; + + protected IActionResult FormatErrorMessageResult(IErrorMessageResult errorMessageResult) => + BadRequest(new ProblemDetailsBuilder() + .WithTitle("An error occured.") + .WithDetail(errorMessageResult.ErrorMessage ?? "The error was unknown") + .Build()); +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UsersBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UsersBuilderExtensions.cs new file mode 100644 index 0000000000..fec138aca0 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UsersBuilderExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Core.DependencyInjection; + +namespace Umbraco.Cms.Api.Management.DependencyInjection; + +internal static class UsersBuilderExtensions +{ + internal static IUmbracoBuilder AddUsers(this IUmbracoBuilder builder) + { + builder.Services.AddTransient(); + return builder; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/IUserPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IUserPresentationFactory.cs new file mode 100644 index 0000000000..a8e091bb1a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/IUserPresentationFactory.cs @@ -0,0 +1,18 @@ +using Umbraco.Cms.Api.Management.ViewModels.Users; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; + +namespace Umbraco.Cms.Api.Management.Factories; + +public interface IUserPresentationFactory +{ + UserResponseModel CreateResponseModel(IUser user); + + Task CreateCreationModelAsync(CreateUserRequestModel requestModel); + + Task CreateInviteModelAsync(InviteUserRequestModel requestModel); + + Task CreateUpdateModelAsync(IUser existingUser, UpdateUserRequestModel updateModel); + + CreateUserResponseModel CreateCreationResponseModel(UserCreationResult creationResult); +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs new file mode 100644 index 0000000000..979a31397a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs @@ -0,0 +1,126 @@ +using Umbraco.Cms.Api.Management.ViewModels.Users; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Media; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Factories; + +public class UserPresentationFactory : IUserPresentationFactory +{ + private readonly IEntityService _entityService; + private readonly AppCaches _appCaches; + private readonly MediaFileManager _mediaFileManager; + private readonly IImageUrlGenerator _imageUrlGenerator; + private readonly IUserGroupService _userGroupService; + + public UserPresentationFactory( + IEntityService entityService, + AppCaches appCaches, + MediaFileManager mediaFileManager, + IImageUrlGenerator imageUrlGenerator, + IUserGroupService userGroupService) + { + _entityService = entityService; + _appCaches = appCaches; + _mediaFileManager = mediaFileManager; + _imageUrlGenerator = imageUrlGenerator; + _userGroupService = userGroupService; + } + + public UserResponseModel CreateResponseModel(IUser user) + { + var responseModel = new UserResponseModel + { + Key = user.Key, + Email = user.Email, + Name = user.Name ?? string.Empty, + AvatarUrls = user.GetUserAvatarUrls(_appCaches.RuntimeCache, _mediaFileManager, _imageUrlGenerator), + UserName = user.Username, + LanguageIsoCode = user.Language, + CreateDate = user.CreateDate, + UpdateDate = user.UpdateDate, + State = user.UserState, + UserGroupIds = new SortedSet(user.Groups.Select(x => x.Key)), + ContentStartNodeKeys = GetKeysFromIds(user.StartContentIds, UmbracoObjectTypes.Document), + MediaStartNodeKeys = GetKeysFromIds(user.StartMediaIds, UmbracoObjectTypes.Media), + FailedLoginAttempts = user.FailedPasswordAttempts, + LastLoginDate = user.LastLoginDate, + LastlockoutDate = user.LastLockoutDate, + LastPasswordChangeDate = user.LastPasswordChangeDate, + }; + + return responseModel; + } + + public async Task CreateCreationModelAsync(CreateUserRequestModel requestModel) + { + IEnumerable groups = await _userGroupService.GetAsync(requestModel.UserGroupIds); + + var createModel = new UserCreateModel + { + Email = requestModel.Email, + Name = requestModel.Name, + UserName = requestModel.UserName, + UserGroups = new HashSet(groups), + }; + + return createModel; + } + + public async Task CreateInviteModelAsync(InviteUserRequestModel requestModel) + { + IEnumerable groups = await _userGroupService.GetAsync(requestModel.UserGroupIds); + + var inviteModel = new UserInviteModel + { + Email = requestModel.Email, + Name = requestModel.Name, + UserName = requestModel.UserName, + UserGroups = new HashSet(groups), + Message = requestModel.Message, + }; + + return inviteModel; + } + + public async Task CreateUpdateModelAsync(IUser existingUser, UpdateUserRequestModel updateModel) + { + var model = new UserUpdateModel + { + ExistingUser = existingUser, + Email = updateModel.Email, + Name = updateModel.Name, + UserName = updateModel.UserName, + Language = updateModel.LanguageIsoCode, + ContentStartNodeKeys = updateModel.ContentStartNodeIds, + MediaStartNodeKeys = updateModel.MediaStartNodeIds, + }; + + IEnumerable userGroups = await _userGroupService.GetAsync(updateModel.UserGroupIds); + model.UserGroups = userGroups; + + return model; + } + + public CreateUserResponseModel CreateCreationResponseModel(UserCreationResult creationResult) + => new() + { + UserKey = creationResult.CreatedUser?.Key ?? Guid.Empty, + InitialPassword = creationResult.InitialPassword, + }; + + private SortedSet GetKeysFromIds(IEnumerable? ids, UmbracoObjectTypes type) + { + IEnumerable? keys = ids? + .Select(x => _entityService.GetKey(x, type)) + .Where(x => x.Success) + .Select(x => x.Result); + + return keys is null + ? new SortedSet() + : new SortedSet(keys); + } +} diff --git a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs index f140a45e4e..0298fd29dd 100644 --- a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs +++ b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs @@ -41,6 +41,7 @@ public class ManagementApiComposer : IComposer .AddTemplates() .AddRelationTypes() .AddLogViewer() + .AddUsers() .AddUserGroups() .AddPackages() .AddBackOfficeAuthentication() diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Users/ChangePasswordUserRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Users/ChangePasswordUserRequestModel.cs new file mode 100644 index 0000000000..b2edfa964a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Users/ChangePasswordUserRequestModel.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Users; + +public class ChangePasswordUserRequestModel +{ + /// + /// The new password. + /// + public required string NewPassword { get; set; } + + /// + /// The old password. + /// + public string? OldPassword { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Users/ChangePasswordUserResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Users/ChangePasswordUserResponseModel.cs new file mode 100644 index 0000000000..d7fcc1fe2d --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Users/ChangePasswordUserResponseModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Users; + +public class ChangePasswordUserResponseModel +{ + public string? ResetPassword { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Users/CreateUserRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Users/CreateUserRequestModel.cs new file mode 100644 index 0000000000..edfac33824 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Users/CreateUserRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Users; + +public class CreateUserRequestModel : UserPresentationBase +{ + +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Users/CreateUserResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Users/CreateUserResponseModel.cs new file mode 100644 index 0000000000..77450e028f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Users/CreateUserResponseModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Users; + +public class CreateUserResponseModel +{ + public Guid UserKey { get; set; } + + public string? InitialPassword { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Users/DisableUserRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Users/DisableUserRequestModel.cs new file mode 100644 index 0000000000..9f2f07afb2 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Users/DisableUserRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Users; + +public class DisableUserRequestModel +{ + public SortedSet UserIds { get; set; } = new(); + } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Users/EnableUserRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Users/EnableUserRequestModel.cs new file mode 100644 index 0000000000..74ace4d27b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Users/EnableUserRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Users; + +public class EnableUserRequestModel +{ + public SortedSet UserIds { get; set; } = new(); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Users/InviteUserRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Users/InviteUserRequestModel.cs new file mode 100644 index 0000000000..c945534d96 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Users/InviteUserRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Users; + +public class InviteUserRequestModel : CreateUserRequestModel +{ + public string? Message { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Users/SetAvatarRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Users/SetAvatarRequestModel.cs new file mode 100644 index 0000000000..b171fa74a2 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Users/SetAvatarRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Users; + +public class SetAvatarRequestModel +{ + public Guid FileKey { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Users/UnlockUsersRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Users/UnlockUsersRequestModel.cs new file mode 100644 index 0000000000..6e44b94ef9 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Users/UnlockUsersRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Users; + +public class UnlockUsersRequestModel +{ + public SortedSet UserIds { get; set; } = new(); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Users/UpdateUserGroupsOnUserRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Users/UpdateUserGroupsOnUserRequestModel.cs new file mode 100644 index 0000000000..c0b56eaa5a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Users/UpdateUserGroupsOnUserRequestModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Users; + +public class UpdateUserGroupsOnUserRequestModel +{ + public required SortedSet UserIds { get; set; } + + public required SortedSet UserGroupIds { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Users/UpdateUserRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Users/UpdateUserRequestModel.cs new file mode 100644 index 0000000000..b8b9eea52e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Users/UpdateUserRequestModel.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Users; + +public class UpdateUserRequestModel : UserPresentationBase +{ + public string LanguageIsoCode { get; set; } = string.Empty; + + public SortedSet ContentStartNodeIds { get; set; } = new(); + + public SortedSet MediaStartNodeIds { get; set; } = new(); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Users/UserPresentationBase.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Users/UserPresentationBase.cs new file mode 100644 index 0000000000..cbd3df0dda --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Users/UserPresentationBase.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Users; + +public class UserPresentationBase +{ + public string Email { get; set; } = string.Empty; + + public string UserName { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public SortedSet UserGroupIds { get; set; } = new(); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Users/UserResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Users/UserResponseModel.cs new file mode 100644 index 0000000000..db844d2e73 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Users/UserResponseModel.cs @@ -0,0 +1,30 @@ +using Umbraco.Cms.Core.Models.Membership; + +namespace Umbraco.Cms.Api.Management.ViewModels.Users; + +public class UserResponseModel : UserPresentationBase, INamedEntityPresentationModel +{ + public Guid Key { get; set; } + + public string? LanguageIsoCode { get; set; } + + public SortedSet ContentStartNodeKeys { get; set; } = new(); + + public SortedSet MediaStartNodeKeys { get; set; } = new(); + + public IEnumerable AvatarUrls { get; set; } = Enumerable.Empty(); + + public UserState State { get; set; } + + public int FailedLoginAttempts { get; set; } + + public DateTime CreateDate { get; set; } + + public DateTime UpdateDate { get; set; } + + public DateTime? LastLoginDate { get; set; } + + public DateTime? LastlockoutDate { get; set; } + + public DateTime? LastPasswordChangeDate { get; set; } +} diff --git a/src/Umbraco.Core/CompatibilitySuppressions.xml b/src/Umbraco.Core/CompatibilitySuppressions.xml index c8b8cf85b6..85d7533100 100644 --- a/src/Umbraco.Core/CompatibilitySuppressions.xml +++ b/src/Umbraco.Core/CompatibilitySuppressions.xml @@ -1190,6 +1190,90 @@ lib/net7.0/Umbraco.Core.dll true + + CP0006 + M:Umbraco.Cms.Core.Services.IUserService.ChangePasswordAsync(System.Guid,Umbraco.Cms.Core.Models.Membership.ChangeBackofficeUserPasswordModel) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0006 + M:Umbraco.Cms.Core.Services.IUserService.ClearAvatarAsync(System.Guid) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0006 + M:Umbraco.Cms.Core.Services.IUserService.CreateAsync(System.Guid,Umbraco.Cms.Core.Models.UserCreateModel,System.Boolean) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0006 + M:Umbraco.Cms.Core.Services.IUserService.DeleteAsync(System.Guid) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0006 + M:Umbraco.Cms.Core.Services.IUserService.DisableAsync(System.Guid,System.Collections.Generic.ISet{System.Guid}) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0006 + M:Umbraco.Cms.Core.Services.IUserService.DisableAsync(System.Guid,System.Guid[]) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0006 + M:Umbraco.Cms.Core.Services.IUserService.EnableAsync(System.Guid,System.Collections.Generic.ISet{System.Guid}) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0006 + M:Umbraco.Cms.Core.Services.IUserService.EnableAsync(System.Guid,System.Guid[]) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0006 + M:Umbraco.Cms.Core.Services.IUserService.InviteAsync(System.Guid,Umbraco.Cms.Core.Models.UserInviteModel) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0006 + M:Umbraco.Cms.Core.Services.IUserService.SetAvatarAsync(Umbraco.Cms.Core.Models.Membership.IUser,System.Guid) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0006 + M:Umbraco.Cms.Core.Services.IUserService.UnlockAsync(System.Guid,System.Guid[]) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0006 + M:Umbraco.Cms.Core.Services.IUserService.UpdateAsync(System.Guid,Umbraco.Cms.Core.Models.UserUpdateModel) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + CP0006 M:Umbraco.Cms.Core.Telemetry.ITelemetryService.GetTelemetryReportDataAsync diff --git a/src/Umbraco.Infrastructure/Extensions/TemporaryFileServiceExtensions.cs b/src/Umbraco.Core/Extensions/TemporaryFileServiceExtensions.cs similarity index 75% rename from src/Umbraco.Infrastructure/Extensions/TemporaryFileServiceExtensions.cs rename to src/Umbraco.Core/Extensions/TemporaryFileServiceExtensions.cs index 5f8310ab01..b2ebb39304 100644 --- a/src/Umbraco.Infrastructure/Extensions/TemporaryFileServiceExtensions.cs +++ b/src/Umbraco.Core/Extensions/TemporaryFileServiceExtensions.cs @@ -1,12 +1,12 @@ using System.Runtime.CompilerServices; -using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; namespace Umbraco.Extensions; public static class TemporaryFileServiceExtensions { - public static void EnlistDeleteIfScopeCompletes(this ITemporaryFileService temporaryFileService, Guid temporaryFileKey, IScopeProvider scopeProvider, [CallerMemberName] string memberName = "") + public static void EnlistDeleteIfScopeCompletes(this ITemporaryFileService temporaryFileService, Guid temporaryFileKey, ICoreScopeProvider scopeProvider, [CallerMemberName] string memberName = "") { scopeProvider.Context?.Enlist( temporaryFileKey.ToString(), diff --git a/src/Umbraco.Core/Models/Membership/ChangeBackofficeUserPasswordModel.cs b/src/Umbraco.Core/Models/Membership/ChangeBackofficeUserPasswordModel.cs new file mode 100644 index 0000000000..5723d57fe4 --- /dev/null +++ b/src/Umbraco.Core/Models/Membership/ChangeBackofficeUserPasswordModel.cs @@ -0,0 +1,16 @@ +namespace Umbraco.Cms.Core.Models.Membership; + +public class ChangeBackofficeUserPasswordModel +{ + public required string NewPassword { get; set; } + + /// + /// The old password - used to change a password when: EnablePasswordRetrieval = false + /// + public string? OldPassword { get; set; } + + /// + /// The user requesting the password change + /// + public required IUser User { get; set; } +} diff --git a/src/Umbraco.Core/Models/Membership/IErrorMessageResult.cs b/src/Umbraco.Core/Models/Membership/IErrorMessageResult.cs new file mode 100644 index 0000000000..ca90b54bb3 --- /dev/null +++ b/src/Umbraco.Core/Models/Membership/IErrorMessageResult.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.Models.Membership; + +public interface IErrorMessageResult +{ + public string? ErrorMessage { get; } +} diff --git a/src/Umbraco.Core/Models/Membership/IdentityCreationResult.cs b/src/Umbraco.Core/Models/Membership/IdentityCreationResult.cs new file mode 100644 index 0000000000..d31d48574a --- /dev/null +++ b/src/Umbraco.Core/Models/Membership/IdentityCreationResult.cs @@ -0,0 +1,13 @@ +namespace Umbraco.Cms.Core.Models.Membership; + +public class IdentityCreationResult +{ + public static IdentityCreationResult Fail(string errorMessage) => + new IdentityCreationResult { ErrorMessage = errorMessage, Succeded = false }; + + public bool Succeded { get; init; } + + public string? ErrorMessage { get; init; } + + public string? InitialPassword { get; init; } +} diff --git a/src/Umbraco.Core/Models/Membership/UserCreationResult.cs b/src/Umbraco.Core/Models/Membership/UserCreationResult.cs new file mode 100644 index 0000000000..9476492905 --- /dev/null +++ b/src/Umbraco.Core/Models/Membership/UserCreationResult.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Core.Models.Membership; + +public class UserCreationResult : IErrorMessageResult +{ + public IUser? CreatedUser { get; init; } + + public string? InitialPassword { get; init; } + + public string? ErrorMessage { get; init; } +} diff --git a/src/Umbraco.Core/Models/Membership/UserFilter.cs b/src/Umbraco.Core/Models/Membership/UserFilter.cs new file mode 100644 index 0000000000..8577974cfb --- /dev/null +++ b/src/Umbraco.Core/Models/Membership/UserFilter.cs @@ -0,0 +1,52 @@ +using Umbraco.Cms.Core.Persistence.Querying; + +namespace Umbraco.Cms.Core.Models.Membership; + +public class UserFilter +{ + public SortedSet? IncludedUserGroups { get; set; } + + public SortedSet? ExcludeUserGroups { get; set; } + + // FIXME: Fix when we have known user group aliases. + // This isn't awesome, but we don't want to surface aliases as a possible filter + // But we can't actually query by key, so if we already know we need to filter out an alias this means we can skip a step + internal SortedSet? ExcludedUserGroupAliases { get; set; } + + public SortedSet? IncludeUserStates { get; set; } + + public SortedSet? NameFilters { get; set; } + + + /// + /// Merges two user filters + /// + /// User filter to merge with. + /// A new filter containing the union of the two filters. + public UserFilter Merge(UserFilter target) => + new UserFilter + { + IncludedUserGroups = MergeSet(IncludedUserGroups, target.IncludedUserGroups), + ExcludeUserGroups = MergeSet(ExcludeUserGroups, target.ExcludeUserGroups), + IncludeUserStates = MergeSet(IncludeUserStates, target.IncludeUserStates), + ExcludedUserGroupAliases = MergeSet(ExcludedUserGroupAliases, target.ExcludedUserGroupAliases), + NameFilters = MergeSet(NameFilters, target.NameFilters) + }; + + private SortedSet? MergeSet(SortedSet? source, SortedSet? target) + { + var set = new SortedSet(); + + if (source is not null) + { + set.UnionWith(source); + } + + if (target is not null) + { + set.UnionWith(target); + } + + return set.Count == 0 ? null : set; + } +} diff --git a/src/Umbraco.Core/Models/Membership/UserInvitationResult.cs b/src/Umbraco.Core/Models/Membership/UserInvitationResult.cs new file mode 100644 index 0000000000..bb5de140a3 --- /dev/null +++ b/src/Umbraco.Core/Models/Membership/UserInvitationResult.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Models.Membership; + +public class UserInvitationResult: IErrorMessageResult +{ + public IUser? InvitedUser { get; init; } + + public string? ErrorMessage { get; init; } +} diff --git a/src/Umbraco.Core/Models/Membership/UserOrder.cs b/src/Umbraco.Core/Models/Membership/UserOrder.cs new file mode 100644 index 0000000000..6a72212945 --- /dev/null +++ b/src/Umbraco.Core/Models/Membership/UserOrder.cs @@ -0,0 +1,15 @@ +namespace Umbraco.Cms.Core.Models.Membership; + +public enum UserOrder +{ + UserName, + Language, + Name, + Email, + Id, + CreateDate, + UpdateDate, + IsApproved, + IsLockedOut, + LastLoginDate, +} diff --git a/src/Umbraco.Core/Models/Membership/UserUnlockResult.cs b/src/Umbraco.Core/Models/Membership/UserUnlockResult.cs new file mode 100644 index 0000000000..a40860efda --- /dev/null +++ b/src/Umbraco.Core/Models/Membership/UserUnlockResult.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.Models.Membership; + +public class UserUnlockResult : IErrorMessageResult +{ + public string? ErrorMessage { get; init; } +} diff --git a/src/Umbraco.Core/Models/UserCreateModel.cs b/src/Umbraco.Core/Models/UserCreateModel.cs new file mode 100644 index 0000000000..bb446fdfd8 --- /dev/null +++ b/src/Umbraco.Core/Models/UserCreateModel.cs @@ -0,0 +1,14 @@ +using Umbraco.Cms.Core.Models.Membership; + +namespace Umbraco.Cms.Core.Models; + +public class UserCreateModel +{ + public string Email { get; set; } = string.Empty; + + public string UserName { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public HashSet UserGroups { get; set; } = new(); +} diff --git a/src/Umbraco.Core/Models/UserExtensions.cs b/src/Umbraco.Core/Models/UserExtensions.cs index f17f6e4de0..ec66b92551 100644 --- a/src/Umbraco.Core/Models/UserExtensions.cs +++ b/src/Umbraco.Core/Models/UserExtensions.cs @@ -25,55 +25,11 @@ public static class UserExtensions /// public static string[] GetUserAvatarUrls(this IUser user, IAppCache cache, MediaFileManager mediaFileManager, IImageUrlGenerator imageUrlGenerator) { - // If FIPS is required, never check the Gravatar service as it only supports MD5 hashing. - // Unfortunately, if the FIPS setting is enabled on Windows, using MD5 will throw an exception - // and the website will not run. - // Also, check if the user has explicitly removed all avatars including a Gravatar, this will be possible and the value will be "none" - if (user.Avatar == "none" || CryptoConfig.AllowOnlyFipsAlgorithms) + if (user.Avatar.IsNullOrWhiteSpace() || user.Avatar == "none") { return new string[0]; } - if (user.Avatar.IsNullOrWhiteSpace()) - { - var gravatarHash = user.Email?.GenerateHash(); - var gravatarUrl = "https://www.gravatar.com/avatar/" + gravatarHash + "?d=404"; - - // try Gravatar - var gravatarAccess = cache.GetCacheItem("UserAvatar" + user.Id, () => - { - // Test if we can reach this URL, will fail when there's network or firewall errors - var request = (HttpWebRequest)WebRequest.Create(gravatarUrl); - - // Require response within 10 seconds - request.Timeout = 10000; - try - { - using ((HttpWebResponse)request.GetResponse()) - { - } - } - catch (Exception) - { - // There was an HTTP or other error, return an null instead - return false; - } - - return true; - }); - - if (gravatarAccess) - { - return new[] - { - gravatarUrl + "&s=30", gravatarUrl + "&s=60", gravatarUrl + "&s=90", gravatarUrl + "&s=150", - gravatarUrl + "&s=300", - }; - } - - return new string[0]; - } - // use the custom avatar var avatarUrl = mediaFileManager.FileSystem.GetUrl(user.Avatar); return new[] diff --git a/src/Umbraco.Core/Models/UserInvitationMessage.cs b/src/Umbraco.Core/Models/UserInvitationMessage.cs new file mode 100644 index 0000000000..e833bbc1d7 --- /dev/null +++ b/src/Umbraco.Core/Models/UserInvitationMessage.cs @@ -0,0 +1,14 @@ +using Umbraco.Cms.Core.Models.Membership; + +namespace Umbraco.Cms.Core.Models; + +public class UserInvitationMessage +{ + public required string Message { get; set; } + + public required Uri InviteUri { get; set; } + + public required IUser Recipient { get; set; } + + public required IUser Sender { get; set; } +} diff --git a/src/Umbraco.Core/Models/UserInviteModel.cs b/src/Umbraco.Core/Models/UserInviteModel.cs new file mode 100644 index 0000000000..744adbe893 --- /dev/null +++ b/src/Umbraco.Core/Models/UserInviteModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.Models; + +public class UserInviteModel : UserCreateModel +{ + public string? Message { get; set; } +} diff --git a/src/Umbraco.Core/Models/UserUpdateModel.cs b/src/Umbraco.Core/Models/UserUpdateModel.cs new file mode 100644 index 0000000000..e5e7e8b490 --- /dev/null +++ b/src/Umbraco.Core/Models/UserUpdateModel.cs @@ -0,0 +1,22 @@ +using Umbraco.Cms.Core.Models.Membership; + +namespace Umbraco.Cms.Core.Models; + +public class UserUpdateModel +{ + public required IUser ExistingUser { get; set; } + + public string Email { get; set; } = string.Empty; + + public string UserName { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string Language { get; set; } = string.Empty; + + public SortedSet ContentStartNodeKeys { get; set; } = new(); + + public SortedSet MediaStartNodeKeys { get; set; } = new(); + + public IEnumerable UserGroups { get; set; } = Enumerable.Empty(); +} diff --git a/src/Umbraco.Core/Security/IBackofficePasswordChanger.cs b/src/Umbraco.Core/Security/IBackofficePasswordChanger.cs new file mode 100644 index 0000000000..0c6813a638 --- /dev/null +++ b/src/Umbraco.Core/Security/IBackofficePasswordChanger.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; + +namespace Umbraco.Cms.Core.Security; + +public interface IBackofficePasswordChanger +{ + Task> ChangeBackofficePassword(ChangeBackofficeUserPasswordModel model); +} diff --git a/src/Umbraco.Core/Security/IBackofficeUserStore.cs b/src/Umbraco.Core/Security/IBackofficeUserStore.cs new file mode 100644 index 0000000000..53d7b9623b --- /dev/null +++ b/src/Umbraco.Core/Security/IBackofficeUserStore.cs @@ -0,0 +1,73 @@ +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Security; + +/// +/// Manages persistence of users. +/// +public interface IBackofficeUserStore +{ + /// + /// Saves an + /// + /// to Save + /// A task resolving into an . + Task SaveAsync(IUser user); + + /// + /// Disables an + /// + /// to disable. + /// A task resolving into an . + Task DisableAsync(IUser user); + + /// + /// Get an by username + /// + /// Username to use for retrieval. + /// + /// A task resolving into an + /// + Task GetByUserNameAsync(string username); + + /// + /// Get an by email + /// + /// Email to use for retrieval. + /// + /// A task resolving into an + /// + Task GetByEmailAsync(string email); + + /// + /// Gets a user by Id + /// + /// Id of the user to retrieve + /// + /// A task resolving into an + /// + Task GetAsync(int id); + + /// + /// Gets a user by it's key. + /// + /// Key of the user to retrieve. + /// Task resolving into an . + Task GetAsync(Guid key); + + Task> GetUsersAsync(params Guid[]? keys); + + Task> GetUsersAsync(params int[]? ids); + + + /// + /// Gets a list of objects associated with a given group + /// + /// Id of group. + /// + /// A task resolving into an + /// + Task> GetAllInGroupAsync(int groupId); + +} diff --git a/src/Umbraco.Core/Security/ICoreBackofficeUserManager.cs b/src/Umbraco.Core/Security/ICoreBackofficeUserManager.cs new file mode 100644 index 0000000000..2ce3260925 --- /dev/null +++ b/src/Umbraco.Core/Security/ICoreBackofficeUserManager.cs @@ -0,0 +1,21 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Security; + +public interface ICoreBackofficeUserManager +{ + Task CreateAsync(UserCreateModel createModel); + + /// + /// Creates a user for an invite. This means that the password will not be populated with + /// + /// + /// + Task CreateForInvite(UserCreateModel createModel); + + Task> GenerateEmailConfirmationTokenAsync(IUser user); + + Task> UnlockUser(IUser user); +} diff --git a/src/Umbraco.Core/Security/IInviteUriProvider.cs b/src/Umbraco.Core/Security/IInviteUriProvider.cs new file mode 100644 index 0000000000..52e5525a98 --- /dev/null +++ b/src/Umbraco.Core/Security/IInviteUriProvider.cs @@ -0,0 +1,10 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Security; + +public interface IInviteUriProvider +{ + Task> CreateInviteUriAsync(IUser invitee); +} diff --git a/src/Umbraco.Core/Security/ILocalLoginSettingProvider.cs b/src/Umbraco.Core/Security/ILocalLoginSettingProvider.cs new file mode 100644 index 0000000000..60709553fd --- /dev/null +++ b/src/Umbraco.Core/Security/ILocalLoginSettingProvider.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Cms.Core.Security; + +/// +/// A setting provider local logins. +/// +/// This cannot be an app setting since it's specified the external login providers. +/// +/// +public interface ILocalLoginSettingProvider +{ + bool HasDenyLocalLogin(); +} diff --git a/src/Umbraco.Core/Security/IUserInviteSender.cs b/src/Umbraco.Core/Security/IUserInviteSender.cs new file mode 100644 index 0000000000..6bfe49326e --- /dev/null +++ b/src/Umbraco.Core/Security/IUserInviteSender.cs @@ -0,0 +1,10 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Security; + +public interface IUserInviteSender +{ + Task InviteUser(UserInvitationMessage invite); + + bool CanSendInvites(); +} diff --git a/src/Umbraco.Core/Services/ContentEditingService.cs b/src/Umbraco.Core/Services/ContentEditingService.cs index 3f2d63c9a6..43dde51ce9 100644 --- a/src/Umbraco.Core/Services/ContentEditingService.cs +++ b/src/Umbraco.Core/Services/ContentEditingService.cs @@ -13,7 +13,7 @@ internal sealed class ContentEditingService private readonly ITemplateService _templateService; private readonly ILogger _logger; private readonly ICoreScopeProvider _scopeProvider; - private readonly IUserService _userService; + private readonly IUserIdKeyResolver _userIdKeyResolver; public ContentEditingService( IContentService contentService, @@ -23,13 +23,13 @@ internal sealed class ContentEditingService ITemplateService templateService, ILogger logger, ICoreScopeProvider scopeProvider, - IUserService userService) + IUserIdKeyResolver userIdKeyResolver) : base(contentService, contentTypeService, propertyEditorCollection, dataTypeService, logger, scopeProvider) { _templateService = templateService; _logger = logger; _scopeProvider = scopeProvider; - _userService = userService; + _userIdKeyResolver = userIdKeyResolver; } public async Task GetAsync(Guid id) @@ -81,13 +81,13 @@ internal sealed class ContentEditingService public async Task> MoveToRecycleBinAsync(Guid id, Guid userKey) { - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(id) ?? Constants.Security.SuperUserId; return await HandleDeletionAsync(id, content => ContentService.MoveToRecycleBin(content, currentUserId), false); } public async Task> DeleteAsync(Guid id, Guid userKey) { - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(id) ?? Constants.Security.SuperUserId; return await HandleDeletionAsync(id, content => ContentService.Delete(content, currentUserId), false); } @@ -122,7 +122,7 @@ internal sealed class ContentEditingService { try { - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = _userIdKeyResolver.GetAsync(userKey).GetAwaiter().GetResult() ?? Constants.Security.SuperUserId; OperationResult saveResult = ContentService.Save(content, currentUserId); return saveResult.Result switch { diff --git a/src/Umbraco.Core/Services/DataTypeContainerService.cs b/src/Umbraco.Core/Services/DataTypeContainerService.cs index 0bfcc1594a..668bbc65b9 100644 --- a/src/Umbraco.Core/Services/DataTypeContainerService.cs +++ b/src/Umbraco.Core/Services/DataTypeContainerService.cs @@ -14,7 +14,7 @@ internal sealed class DataTypeContainerService : RepositoryService, IDataTypeCon private readonly IDataTypeContainerRepository _dataTypeContainerRepository; private readonly IAuditRepository _auditRepository; private readonly IEntityRepository _entityRepository; - private readonly IUserService _userService; + private readonly IUserIdKeyResolver _userIdKeyResolver; public DataTypeContainerService( ICoreScopeProvider provider, @@ -23,13 +23,13 @@ internal sealed class DataTypeContainerService : RepositoryService, IDataTypeCon IDataTypeContainerRepository dataTypeContainerRepository, IAuditRepository auditRepository, IEntityRepository entityRepository, - IUserService userService) + IUserIdKeyResolver userIdKeyResolver) : base(provider, loggerFactory, eventMessagesFactory) { _dataTypeContainerRepository = dataTypeContainerRepository; _auditRepository = auditRepository; _entityRepository = entityRepository; - _userService = userService; + _userIdKeyResolver = userIdKeyResolver; } /// @@ -126,7 +126,7 @@ internal sealed class DataTypeContainerService : RepositoryService, IDataTypeCon _dataTypeContainerRepository.Delete(container); - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; Audit(AuditType.Delete, currentUserId, container.Id); scope.Complete(); @@ -160,7 +160,7 @@ internal sealed class DataTypeContainerService : RepositoryService, IDataTypeCon _dataTypeContainerRepository.Save(container); - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; Audit(auditType, currentUserId, container.Id); scope.Complete(); diff --git a/src/Umbraco.Core/Services/DataTypeService.cs b/src/Umbraco.Core/Services/DataTypeService.cs index ad6e85d793..03b418a684 100644 --- a/src/Umbraco.Core/Services/DataTypeService.cs +++ b/src/Umbraco.Core/Services/DataTypeService.cs @@ -30,8 +30,8 @@ namespace Umbraco.Cms.Core.Services.Implement private readonly IAuditRepository _auditRepository; private readonly IIOHelper _ioHelper; private readonly IEditorConfigurationParser _editorConfigurationParser; - private readonly IUserService _userService; private readonly IDataTypeContainerService _dataTypeContainerService; + private readonly IUserIdKeyResolver _userIdKeyResolver; [Obsolete("Please use the constructor that takes less parameters. Will be removed in V15.")] public DataTypeService( @@ -83,12 +83,10 @@ namespace Umbraco.Cms.Core.Services.Implement _ioHelper = ioHelper; _editorConfigurationParser = editorConfigurationParser; - // Trying to inject user service will cause ambigious constructors, this should be properly DI, when old ctor is removed. - _userService = StaticServiceProvider.Instance.GetRequiredService(); - // resolve dependencies for obsolete methods through the static service provider, so they don't pollute the constructor signature _dataTypeContainerService = StaticServiceProvider.Instance.GetRequiredService(); _dataTypeContainerRepository = StaticServiceProvider.Instance.GetRequiredService(); + _userIdKeyResolver = StaticServiceProvider.Instance.GetRequiredService(); } #region Containers @@ -111,7 +109,7 @@ namespace Umbraco.Cms.Core.Services.Implement Key = key }; - Guid currentUserKey = _userService.GetUserById(userId)?.Key ?? Constants.Security.SuperUserKey; + Guid currentUserKey = _userIdKeyResolver.GetAsync(userId).GetAwaiter().GetResult() ?? Constants.Security.SuperUserKey; Attempt result = _dataTypeContainerService.CreateAsync(container, parentKey, currentUserKey).GetAwaiter().GetResult(); // mimic old service behavior @@ -177,7 +175,7 @@ namespace Umbraco.Cms.Core.Services.Implement { var isNew = container.Id == 0; Guid? parentKey = isNew && container.ParentId > 0 ? _dataTypeContainerRepository.Get(container.ParentId)?.Key : null; - Guid currentUserKey = _userService.GetUserById(userId)?.Key ?? Constants.Security.SuperUserKey; + Guid currentUserKey = _userIdKeyResolver.GetAsync(userId).GetAwaiter().GetResult() ?? Constants.Security.SuperUserKey; Attempt result = isNew ? _dataTypeContainerService.CreateAsync(container, parentKey, currentUserKey).GetAwaiter().GetResult() @@ -207,7 +205,7 @@ namespace Umbraco.Cms.Core.Services.Implement return OperationResult.Attempt.NoOperation(evtMsgs); } - Guid currentUserKey = _userService.GetUserById(userId)?.Key ?? Constants.Security.SuperUserKey; + Guid currentUserKey = _userIdKeyResolver.GetAsync(userId).GetAwaiter().GetResult() ?? Constants.Security.SuperUserKey; Attempt result = _dataTypeContainerService.DeleteAsync(container.Key, currentUserKey).GetAwaiter().GetResult(); // mimic old service behavior return result.Status switch @@ -237,7 +235,7 @@ namespace Umbraco.Cms.Core.Services.Implement } container.Name = name; - Guid currentUserKey = _userService.GetUserById(userId)?.Key ?? Constants.Security.SuperUserKey; + Guid currentUserKey = _userIdKeyResolver.GetAsync(userId).GetAwaiter().GetResult() ?? Constants.Security.SuperUserKey; Attempt result = _dataTypeContainerService.UpdateAsync(container, currentUserKey).GetAwaiter().GetResult(); // mimic old service behavior return result.Status switch @@ -425,7 +423,7 @@ namespace Umbraco.Cms.Core.Services.Implement scope.Notifications.Publish(new DataTypeMovedNotification(moveEventInfo, eventMessages).WithStateFrom(movingDataTypeNotification)); - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ??Constants.Security.SuperUserId; Audit(AuditType.Move, currentUserId, toMove.Id); scope.Complete(); } @@ -454,7 +452,7 @@ namespace Umbraco.Cms.Core.Services.Implement containerKey = container.Key; } - Guid currentUserKey = _userService.GetUserById(userId)?.Key ?? Constants.Security.SuperUserKey; + Guid currentUserKey = _userIdKeyResolver.GetAsync(userId).GetAwaiter().GetResult() ?? Constants.Security.SuperUserKey; Attempt result = CopyAsync(copying, containerKey, currentUserKey).GetAwaiter().GetResult(); // mimic old service behavior @@ -507,8 +505,7 @@ namespace Umbraco.Cms.Core.Services.Implement throw new InvalidOperationException("Name cannot be more than 255 characters in length."); } - Guid currentUserKey = _userService.GetUserById(userId)?.Key ?? Constants.Security.SuperUserKey; - + Guid currentUserKey = _userIdKeyResolver.GetAsync(userId).GetAwaiter().GetResult() ?? Constants.Security.SuperUserKey; SaveAsync( dataType, @@ -585,7 +582,7 @@ namespace Umbraco.Cms.Core.Services.Implement /// Optional Id of the user issuing the deletion public void Delete(IDataType dataType, int userId = Constants.Security.SuperUserId) { - Guid currentUserKey = _userService.GetUserById(userId)?.Key ?? Constants.Security.SuperUserKey; + Guid currentUserKey = _userIdKeyResolver.GetAsync(userId).GetAwaiter().GetResult() ?? Constants.Security.SuperUserKey; DeleteAsync(dataType.Key, currentUserKey).GetAwaiter().GetResult(); } @@ -643,7 +640,7 @@ namespace Umbraco.Cms.Core.Services.Implement scope.Notifications.Publish(new DataTypeDeletedNotification(dataType, eventMessages).WithStateFrom(deletingDataTypeNotification)); - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; Audit(AuditType.Delete, currentUserId, dataType.Id); scope.Complete(); @@ -702,7 +699,7 @@ namespace Umbraco.Cms.Core.Services.Implement EventMessages eventMessages = EventMessagesFactory.Get(); - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; dataType.CreatorId = currentUserId; using ICoreScope scope = ScopeProvider.CreateCoreScope(); diff --git a/src/Umbraco.Core/Services/DictionaryItemService.cs b/src/Umbraco.Core/Services/DictionaryItemService.cs index e6c8605d83..d873e6015e 100644 --- a/src/Umbraco.Core/Services/DictionaryItemService.cs +++ b/src/Umbraco.Core/Services/DictionaryItemService.cs @@ -14,7 +14,7 @@ internal sealed class DictionaryItemService : RepositoryService, IDictionaryItem private readonly IDictionaryRepository _dictionaryRepository; private readonly IAuditRepository _auditRepository; private readonly ILanguageService _languageService; - private readonly IUserService _userService; + private readonly IUserIdKeyResolver _userIdKeyResolver; public DictionaryItemService( ICoreScopeProvider provider, @@ -23,13 +23,13 @@ internal sealed class DictionaryItemService : RepositoryService, IDictionaryItem IDictionaryRepository dictionaryRepository, IAuditRepository auditRepository, ILanguageService languageService, - IUserService userService) + IUserIdKeyResolver userIdKeyResolver) : base(provider, loggerFactory, eventMessagesFactory) { _dictionaryRepository = dictionaryRepository; _auditRepository = auditRepository; _languageService = languageService; - _userService = userService; + _userIdKeyResolver = userIdKeyResolver; } /// @@ -167,7 +167,7 @@ internal sealed class DictionaryItemService : RepositoryService, IDictionaryItem new DictionaryItemDeletedNotification(dictionaryItem, eventMessages) .WithStateFrom(deletingNotification)); - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; Audit(AuditType.Delete, "Delete DictionaryItem", currentUserId, dictionaryItem.Id, nameof(DictionaryItem)); scope.Complete(); @@ -229,7 +229,7 @@ internal sealed class DictionaryItemService : RepositoryService, IDictionaryItem scope.Notifications.Publish( new DictionaryItemMovedNotification(moveEventInfo, eventMessages).WithStateFrom(movingNotification)); - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; Audit(AuditType.Move, "Move DictionaryItem", currentUserId, dictionaryItem.Id, nameof(DictionaryItem)); scope.Complete(); @@ -281,7 +281,7 @@ internal sealed class DictionaryItemService : RepositoryService, IDictionaryItem scope.Notifications.Publish( new DictionaryItemSavedNotification(dictionaryItem, eventMessages).WithStateFrom(savingNotification)); - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; Audit(auditType, auditMessage, currentUserId, dictionaryItem.Id, nameof(DictionaryItem)); scope.Complete(); diff --git a/src/Umbraco.Core/Services/FileService.cs b/src/Umbraco.Core/Services/FileService.cs index 818e522baa..2e0561074c 100644 --- a/src/Umbraco.Core/Services/FileService.cs +++ b/src/Umbraco.Core/Services/FileService.cs @@ -34,7 +34,7 @@ public class FileService : RepositoryService, IFileService private readonly IStylesheetRepository _stylesheetRepository; private readonly ITemplateService _templateService; private readonly ITemplateRepository _templateRepository; - private readonly IUserService _userService; + private readonly IUserIdKeyResolver _userIdKeyResolver; [Obsolete("Use other ctor - will be removed in Umbraco 15")] public FileService( @@ -62,7 +62,7 @@ public class FileService : RepositoryService, IFileService hostingEnvironment, StaticServiceProvider.Instance.GetRequiredService(), templateRepository, - StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), shortStringHelper, globalSettings) { @@ -81,7 +81,7 @@ public class FileService : RepositoryService, IFileService IHostingEnvironment hostingEnvironment, ITemplateService templateService, ITemplateRepository templateRepository, - IUserService userService, + IUserIdKeyResolver userIdKeyResolver, // We need these else it will be ambigious ctors IShortStringHelper shortStringHelper, IOptions globalSettings) @@ -95,7 +95,7 @@ public class FileService : RepositoryService, IFileService _hostingEnvironment = hostingEnvironment; _templateService = templateService; _templateRepository = templateRepository; - _userService = userService; + _userIdKeyResolver = userIdKeyResolver; } #region Stylesheets @@ -383,7 +383,7 @@ public class FileService : RepositoryService, IFileService throw new InvalidOperationException("Name cannot be more than 255 characters in length."); } - Guid currentUserKey = _userService.GetUserById(userId)?.Key ?? Constants.Security.SuperUserKey; + Guid currentUserKey = _userIdKeyResolver.GetAsync(userId).GetAwaiter().GetResult() ?? Constants.Security.SuperUserKey; Attempt result = _templateService.CreateForContentTypeAsync(contentTypeAlias, contentTypeName, currentUserKey).GetAwaiter().GetResult(); // mimic old service behavior @@ -418,7 +418,7 @@ public class FileService : RepositoryService, IFileService throw new ArgumentOutOfRangeException(nameof(name), "Name cannot be more than 255 characters in length."); } - Guid currentUserKey = _userService.GetUserById(userId)?.Key ?? Constants.Security.SuperUserKey; + Guid currentUserKey = _userIdKeyResolver.GetAsync(userId).GetAwaiter().GetResult() ?? Constants.Security.SuperUserKey; Attempt result = _templateService.CreateAsync(name, alias, content, currentUserKey).GetAwaiter().GetResult(); return result.Result; } @@ -495,7 +495,7 @@ public class FileService : RepositoryService, IFileService "Name cannot be null, empty, contain only white-space characters or be more than 255 characters in length."); } - Guid currentUserKey = _userService.GetUserById(userId)?.Key ?? Constants.Security.SuperUserKey; + Guid currentUserKey = _userIdKeyResolver.GetAsync(userId).GetAwaiter().GetResult() ?? Constants.Security.SuperUserKey; if (template.Id > 0) { _templateService.UpdateAsync(template, currentUserKey).GetAwaiter().GetResult(); @@ -548,7 +548,7 @@ public class FileService : RepositoryService, IFileService [Obsolete("Please use ITemplateService for template operations - will be removed in Umbraco 15")] public void DeleteTemplate(string alias, int userId = Constants.Security.SuperUserId) { - Guid currentUserKey = _userService.GetUserById(userId)?.Key ?? Constants.Security.SuperUserKey; + Guid currentUserKey = _userIdKeyResolver.GetAsync(userId).GetAwaiter().GetResult() ?? Constants.Security.SuperUserKey; _templateService.DeleteAsync(alias, currentUserKey).GetAwaiter().GetResult(); } diff --git a/src/Umbraco.Core/Services/IUserGroupService.cs b/src/Umbraco.Core/Services/IUserGroupService.cs index ee49334d12..af8f29c03c 100644 --- a/src/Umbraco.Core/Services/IUserGroupService.cs +++ b/src/Umbraco.Core/Services/IUserGroupService.cs @@ -60,6 +60,8 @@ public interface IUserGroupService /// Task GetAsync(Guid key); + Task> GetAsync(IEnumerable keys); + /// /// Persists a new user group. /// @@ -83,4 +85,12 @@ public interface IUserGroupService /// The key of the user group to delete. /// An attempt indicating if the operation was a success as well as a more detailed . Task> DeleteAsync(Guid key); + + /// + /// Updates the users to have the groups specified. + /// + /// The user groups the users should be part of. + /// The user whose groups we want to alter. + /// An attempt indicating if the operation was a success as well as a more detailed . + Task UpdateUserGroupsOnUsers(ISet userGroupKeys, ISet userKeys); } diff --git a/src/Umbraco.Core/Services/IUserIdKeyResolver.cs b/src/Umbraco.Core/Services/IUserIdKeyResolver.cs new file mode 100644 index 0000000000..85008c28b5 --- /dev/null +++ b/src/Umbraco.Core/Services/IUserIdKeyResolver.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Services; + +public interface IUserIdKeyResolver +{ + public Task GetAsync(Guid key); + + public Task GetAsync(int id); +} diff --git a/src/Umbraco.Core/Services/IUserService.cs b/src/Umbraco.Core/Services/IUserService.cs index 095585c0e7..033c1db977 100644 --- a/src/Umbraco.Core/Services/IUserService.cs +++ b/src/Umbraco.Core/Services/IUserService.cs @@ -1,5 +1,8 @@ +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.New.Cms.Core.Models; namespace Umbraco.Cms.Core.Services; @@ -42,6 +45,51 @@ public interface IUserService : IMembershipUserService /// IDictionary GetUserStates(); + /// + /// Creates a user based in a create model and persists it to the database. + /// + /// + /// This creates both the Umbraco user and the identity user. + /// + /// The key of the user performing the operation. + /// Model to create the user from. + /// Specifies if the user should be enabled be default. Defaults to false. + /// An attempt indicating if the operation was a success as well as a more detailed . + Task> CreateAsync(Guid performingUserKey, UserCreateModel model, bool approveUser = false); + + Task> InviteAsync(Guid performingUserKey, UserInviteModel model); + + Task> UpdateAsync(Guid performingUserKey, UserUpdateModel model); + + Task SetAvatarAsync(IUser user, Guid temporaryFileKey); + + Task DeleteAsync(Guid key); + + Task DisableAsync(Guid performingUserKey, ISet keys); + + Task EnableAsync(Guid performingUserKey, ISet keys); + + Task> UnlockAsync(Guid performingUserKey, params Guid[] keys); + + Task> ChangePasswordAsync(Guid performingUserKey, ChangeBackofficeUserPasswordModel model); + + Task ClearAvatarAsync(Guid userKey); + + /// + /// Gets all users that the requesting user is allowed to see. + /// + /// The Key of the user requesting the users. + /// + Task?, UserOperationStatus>> GetAllAsync(Guid requestingUserKey, int skip, int take) => throw new NotImplementedException(); + + public Task, UserOperationStatus>> FilterAsync( + Guid requestingUserKey, + UserFilter filter, + int skip = 0, + int take = 100, + UserOrder orderBy = UserOrder.UserName, + Direction orderDirection = Direction.Ascending) => throw new NotImplementedException(); + /// /// Get paged users /// @@ -126,6 +174,8 @@ public interface IUserService : IMembershipUserService /// The found user, or null if nothing was found. Task GetAsync(Guid key) => Task.FromResult(GetAll(0, int.MaxValue, out _).FirstOrDefault(x=>x.Key == key)); + Task> GetAsync(IEnumerable keys) => Task.FromResult(GetAll(0, int.MaxValue, out _).Where(x => keys.Contains(x.Key))); + /// /// Gets a user by Id /// diff --git a/src/Umbraco.Core/Services/LanguageService.cs b/src/Umbraco.Core/Services/LanguageService.cs index 59753c67f4..48e5b2ddd3 100644 --- a/src/Umbraco.Core/Services/LanguageService.cs +++ b/src/Umbraco.Core/Services/LanguageService.cs @@ -14,7 +14,7 @@ internal sealed class LanguageService : RepositoryService, ILanguageService { private readonly ILanguageRepository _languageRepository; private readonly IAuditRepository _auditRepository; - private readonly IUserService _userService; + private readonly IUserIdKeyResolver _userIdKeyResolver; public LanguageService( ICoreScopeProvider provider, @@ -22,12 +22,12 @@ internal sealed class LanguageService : RepositoryService, ILanguageService IEventMessagesFactory eventMessagesFactory, ILanguageRepository languageRepository, IAuditRepository auditRepository, - IUserService userService) + IUserIdKeyResolver userIdKeyResolver) : base(provider, loggerFactory, eventMessagesFactory) { _languageRepository = languageRepository; _auditRepository = auditRepository; - _userService = userService; + _userIdKeyResolver = userIdKeyResolver; } /// @@ -139,7 +139,7 @@ internal sealed class LanguageService : RepositoryService, ILanguageService scope.Notifications.Publish( new LanguageDeletedNotification(language, eventMessages).WithStateFrom(deletingLanguageNotification)); - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; Audit(AuditType.Delete, "Delete Language", currentUserId, language.Id, UmbracoObjectTypes.Language.GetName()); scope.Complete(); return await Task.FromResult(Attempt.SucceedWithStatus(LanguageOperationStatus.Success, language)); @@ -191,7 +191,7 @@ internal sealed class LanguageService : RepositoryService, ILanguageService scope.Notifications.Publish( new LanguageSavedNotification(language, eventMessages).WithStateFrom(savingNotification)); - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; Audit(auditType, auditMessage, currentUserId, language.Id, UmbracoObjectTypes.Language.GetName()); scope.Complete(); diff --git a/src/Umbraco.Core/Services/LocalizationService.cs b/src/Umbraco.Core/Services/LocalizationService.cs index be36fc5671..e799913411 100644 --- a/src/Umbraco.Core/Services/LocalizationService.cs +++ b/src/Umbraco.Core/Services/LocalizationService.cs @@ -22,7 +22,7 @@ internal class LocalizationService : RepositoryService, ILocalizationService private readonly ILanguageRepository _languageRepository; private readonly ILanguageService _languageService; private readonly IDictionaryItemService _dictionaryItemService; - private readonly IUserService _userService; + private readonly IUserIdKeyResolver _userIdKeyResolver; [Obsolete("Please use constructor with language, dictionary and user services. Will be removed in V15")] public LocalizationService( @@ -41,7 +41,7 @@ internal class LocalizationService : RepositoryService, ILocalizationService languageRepository, StaticServiceProvider.Instance.GetRequiredService(), StaticServiceProvider.Instance.GetRequiredService(), - StaticServiceProvider.Instance.GetRequiredService()) + StaticServiceProvider.Instance.GetRequiredService()) { } @@ -55,7 +55,7 @@ internal class LocalizationService : RepositoryService, ILocalizationService ILanguageRepository languageRepository, ILanguageService languageService, IDictionaryItemService dictionaryItemService, - IUserService userService) + IUserIdKeyResolver userIdKeyResolver) : base(provider, loggerFactory, eventMessagesFactory) { _dictionaryRepository = dictionaryRepository; @@ -63,7 +63,7 @@ internal class LocalizationService : RepositoryService, ILocalizationService _languageRepository = languageRepository; _languageService = languageService; _dictionaryItemService = dictionaryItemService; - _userService = userService; + _userIdKeyResolver = userIdKeyResolver; } /// @@ -220,8 +220,8 @@ internal class LocalizationService : RepositoryService, ILocalizationService /// Optional id of the user saving the dictionary item [Obsolete("Please use IDictionaryItemService for dictionary item operations. Will be removed in V15.")] public void Save(IDictionaryItem dictionaryItem, int userId = Constants.Security.SuperUserId) - { - Guid currentUserKey = _userService.GetUserById(userId)?.Key ?? Constants.Security.SuperUserKey; + { ; + Guid currentUserKey = _userIdKeyResolver.GetAsync(userId).GetAwaiter().GetResult() ?? Constants.Security.SuperUserKey; if (dictionaryItem.Id > 0) { _dictionaryItemService.UpdateAsync(dictionaryItem, currentUserKey).GetAwaiter().GetResult(); @@ -241,7 +241,7 @@ internal class LocalizationService : RepositoryService, ILocalizationService [Obsolete("Please use IDictionaryItemService for dictionary item operations. Will be removed in V15.")] public void Delete(IDictionaryItem dictionaryItem, int userId = Constants.Security.SuperUserId) { - Guid currentUserKey = _userService.GetUserById(userId)?.Key ?? Constants.Security.SuperUserKey; + Guid currentUserKey = _userIdKeyResolver.GetAsync(userId).GetAwaiter().GetResult() ?? Constants.Security.SuperUserKey; _dictionaryItemService.DeleteAsync(dictionaryItem.Key, currentUserKey).GetAwaiter().GetResult(); } @@ -321,7 +321,7 @@ internal class LocalizationService : RepositoryService, ILocalizationService [Obsolete("Please use ILanguageService for language operations. Will be removed in V15.")] public void Save(ILanguage language, int userId = Constants.Security.SuperUserId) { - Guid currentUserKey = _userService.GetUserById(userId)?.Key ?? Constants.Security.SuperUserKey; + Guid currentUserKey = _userIdKeyResolver.GetAsync(userId).GetAwaiter().GetResult() ?? Constants.Security.SuperUserKey; Attempt result = language.Id > 0 ? _languageService.UpdateAsync(language, currentUserKey).GetAwaiter().GetResult() : _languageService.CreateAsync(language, currentUserKey).GetAwaiter().GetResult(); @@ -341,7 +341,7 @@ internal class LocalizationService : RepositoryService, ILocalizationService [Obsolete("Please use ILanguageService for language operations. Will be removed in V15.")] public void Delete(ILanguage language, int userId = Constants.Security.SuperUserId) { - Guid currentUserKey = _userService.GetUserById(userId)?.Key ?? Constants.Security.SuperUserKey; + Guid currentUserKey = _userIdKeyResolver.GetAsync(userId).GetAwaiter().GetResult() ?? Constants.Security.SuperUserKey; _languageService.DeleteAsync(language.IsoCode, currentUserKey).GetAwaiter().GetResult(); } diff --git a/src/Umbraco.Core/Services/MediaEditingService.cs b/src/Umbraco.Core/Services/MediaEditingService.cs index cfd11f2a85..5ca0b830b4 100644 --- a/src/Umbraco.Core/Services/MediaEditingService.cs +++ b/src/Umbraco.Core/Services/MediaEditingService.cs @@ -11,7 +11,7 @@ internal sealed class MediaEditingService : ContentEditingServiceBase, IMediaEditingService { private readonly ILogger> _logger; - private readonly IUserService _userService; + private readonly IUserIdKeyResolver _userIdKeyResolver; public MediaEditingService( IMediaService contentService, @@ -20,11 +20,11 @@ internal sealed class MediaEditingService IDataTypeService dataTypeService, ILogger> logger, ICoreScopeProvider scopeProvider, - IUserService userService) + IUserIdKeyResolver userIdKeyResolver) : base(contentService, contentTypeService, propertyEditorCollection, dataTypeService, logger, scopeProvider) { _logger = logger; - _userService = userService; + _userIdKeyResolver = userIdKeyResolver; } public async Task GetAsync(Guid id) @@ -43,7 +43,7 @@ internal sealed class MediaEditingService IMedia media = result.Result!; - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; ContentEditingOperationStatus operationStatus = Save(media, currentUserId); return operationStatus == ContentEditingOperationStatus.Success ? Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, media) @@ -58,7 +58,7 @@ internal sealed class MediaEditingService return Attempt.FailWithStatus(result.Result, content); } - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; ContentEditingOperationStatus operationStatus = Save(content, currentUserId); return operationStatus == ContentEditingOperationStatus.Success ? Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, content) @@ -67,13 +67,13 @@ internal sealed class MediaEditingService public async Task> MoveToRecycleBinAsync(Guid id, Guid userKey) { - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; return await HandleDeletionAsync(id, media => ContentService.MoveToRecycleBin(media, currentUserId).Result, false); } public async Task> DeleteAsync(Guid id, Guid userKey) { - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; return await HandleDeletionAsync(id, media => ContentService.Delete(media, currentUserId).Result, true); } diff --git a/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs new file mode 100644 index 0000000000..0de3548683 --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs @@ -0,0 +1,24 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum UserOperationStatus +{ + Success, + MissingUser, + MissingUserGroup, + UserNameIsNotEmail, + EmailCannotBeChanged, + NoUserGroup, + DuplicateUserName, + DuplicateEmail, + Unauthorized, + Forbidden, + CancelledByNotification, + NotFound, + CannotInvite, + CannotDelete, + CannotDisableSelf, + CannotDisableInvitedUser, + OldPasswordRequired, + InvalidAvatar, + UnknownFailure, +} diff --git a/src/Umbraco.Core/Services/RelationService.cs b/src/Umbraco.Core/Services/RelationService.cs index de7ecd4084..200cbb37d6 100644 --- a/src/Umbraco.Core/Services/RelationService.cs +++ b/src/Umbraco.Core/Services/RelationService.cs @@ -17,7 +17,7 @@ namespace Umbraco.Cms.Core.Services; public class RelationService : RepositoryService, IRelationService { private readonly IAuditRepository _auditRepository; - private readonly IUserService _userService; + private readonly IUserIdKeyResolver _userIdKeyResolver; private readonly IEntityService _entityService; private readonly IRelationRepository _relationRepository; private readonly IRelationTypeRepository _relationTypeRepository; @@ -39,7 +39,7 @@ public class RelationService : RepositoryService, IRelationService relationRepository, relationTypeRepository, auditRepository, - StaticServiceProvider.Instance.GetRequiredService()) + StaticServiceProvider.Instance.GetRequiredService()) { } @@ -51,13 +51,13 @@ public class RelationService : RepositoryService, IRelationService IRelationRepository relationRepository, IRelationTypeRepository relationTypeRepository, IAuditRepository auditRepository, - IUserService userService) + IUserIdKeyResolver userIdKeyResolver) : base(uowProvider, loggerFactory, eventMessagesFactory) { _relationRepository = relationRepository; _relationTypeRepository = relationTypeRepository; _auditRepository = auditRepository; - _userService = userService; + _userIdKeyResolver = userIdKeyResolver; _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); } @@ -622,7 +622,7 @@ public class RelationService : RepositoryService, IRelationService } _relationTypeRepository.Save(relationType); - var currentUser = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUser = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; Audit(auditType, currentUser, relationType.Id, auditMessage); scope.Complete(); scope.Notifications.Publish( @@ -691,7 +691,7 @@ public class RelationService : RepositoryService, IRelationService } _relationTypeRepository.Delete(relationType); - var currentUser = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUser = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; Audit(AuditType.Delete, currentUser, relationType.Id, "Deleted relation type"); scope.Notifications.Publish(new RelationTypeDeletedNotification(relationType, eventMessages).WithStateFrom(deletingNotification)); scope.Complete(); diff --git a/src/Umbraco.Core/Services/TemplateService.cs b/src/Umbraco.Core/Services/TemplateService.cs index 9dd00c50a3..e412020ff3 100644 --- a/src/Umbraco.Core/Services/TemplateService.cs +++ b/src/Umbraco.Core/Services/TemplateService.cs @@ -19,7 +19,7 @@ public class TemplateService : RepositoryService, ITemplateService private readonly ITemplateRepository _templateRepository; private readonly IAuditRepository _auditRepository; private readonly ITemplateContentParserService _templateContentParserService; - private readonly IUserService _userService; + private readonly IUserIdKeyResolver _userIdKeyResolver; public TemplateService( ICoreScopeProvider provider, @@ -29,14 +29,14 @@ public class TemplateService : RepositoryService, ITemplateService ITemplateRepository templateRepository, IAuditRepository auditRepository, ITemplateContentParserService templateContentParserService, - IUserService userService) + IUserIdKeyResolver userIdKeyResolver) : base(provider, loggerFactory, eventMessagesFactory) { _shortStringHelper = shortStringHelper; _templateRepository = templateRepository; _auditRepository = auditRepository; _templateContentParserService = templateContentParserService; - _userService = userService; + _userIdKeyResolver = userIdKeyResolver; } [Obsolete("Please use ctor that takes all parameters, scheduled for removal in v15")] @@ -56,7 +56,7 @@ public class TemplateService : RepositoryService, ITemplateService templateRepository, auditRepository, templateContentParserService, - StaticServiceProvider.Instance.GetRequiredService()) + StaticServiceProvider.Instance.GetRequiredService()) { } @@ -100,7 +100,7 @@ public class TemplateService : RepositoryService, ITemplateService scope.Notifications.Publish( new TemplateSavedNotification(template, eventMessages).WithStateFrom(savingEvent)); - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; Audit(AuditType.New, currentUserId, template.Id, UmbracoObjectTypes.Template.GetName()); scope.Complete(); } @@ -239,7 +239,7 @@ public class TemplateService : RepositoryService, ITemplateService scope.Notifications.Publish( new TemplateSavedNotification(template, eventMessages).WithStateFrom(savingNotification)); - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; Audit(auditType, currentUserId, template.Id, UmbracoObjectTypes.Template.GetName()); scope.Complete(); return Attempt.SucceedWithStatus(TemplateOperationStatus.Success, template); @@ -386,7 +386,7 @@ public class TemplateService : RepositoryService, ITemplateService scope.Notifications.Publish( new TemplateDeletedNotification(template, eventMessages).WithStateFrom(deletingNotification)); - var currentUserId = _userService.GetAsync(userKey).Result?.Id ?? Constants.Security.SuperUserId; + var currentUserId = await _userIdKeyResolver.GetAsync(userKey) ?? Constants.Security.SuperUserId; Audit(AuditType.Delete, currentUserId, template.Id, UmbracoObjectTypes.Template.GetName()); scope.Complete(); return Attempt.SucceedWithStatus(TemplateOperationStatus.Success, template); diff --git a/src/Umbraco.Core/Services/UserGroupService.cs b/src/Umbraco.Core/Services/UserGroupService.cs index 42bf0070e7..6a2f7415f5 100644 --- a/src/Umbraco.Core/Services/UserGroupService.cs +++ b/src/Umbraco.Core/Services/UserGroupService.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Logging; +using System.Formats.Asn1; +using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; @@ -21,8 +22,8 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService private readonly IUserGroupRepository _userGroupRepository; private readonly IUserGroupAuthorizationService _userGroupAuthorizationService; - private readonly IUserService _userService; private readonly IEntityService _entityService; + private readonly IUserService _userService; public UserGroupService( ICoreScopeProvider provider, @@ -30,14 +31,14 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService IEventMessagesFactory eventMessagesFactory, IUserGroupRepository userGroupRepository, IUserGroupAuthorizationService userGroupAuthorizationService, - IUserService userService, - IEntityService entityService) + IEntityService entityService, + IUserService userService) : base(provider, loggerFactory, eventMessagesFactory) { _userGroupRepository = userGroupRepository; _userGroupAuthorizationService = userGroupAuthorizationService; - _userService = userService; _entityService = entityService; + _userService = userService; } /// @@ -119,6 +120,21 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService return Task.FromResult(groups); } + public Task> GetAsync(IEnumerable keys) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + + IQuery query = Query().Where(x => keys.SqlIn(x.Key)); + + IUserGroup[] result = _userGroupRepository + .Get(query) + .WhereNotNull() + .OrderBy(x => x.Name) + .ToArray(); + + return Task.FromResult>(result); + } + /// public async Task> DeleteAsync(Guid key) { @@ -152,6 +168,28 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService return Attempt.Succeed(UserGroupOperationStatus.Success); } + public async Task UpdateUserGroupsOnUsers( + ISet userGroupKeys, + ISet userKeys) + { + IUser[] users = (await _userService.GetAsync(userKeys)).ToArray(); + + IReadOnlyUserGroup[] userGroups = (await GetAsync(userGroupKeys)) + .Select(x => x.ToReadOnlyGroup()) + .ToArray(); + + foreach(IUser user in users) + { + user.ClearGroups(); + foreach (IReadOnlyUserGroup userGroup in userGroups) + { + user.AddGroup(userGroup); + } + } + + _userService.Save(users); + } + private Attempt ValidateUserGroupDeletion(IUserGroup? userGroup) { if (userGroup is null) @@ -202,7 +240,7 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService } var checkedGroupMembers = EnsureNonAdminUserIsInSavedUserGroup(performingUser, groupMembersUserIds ?? Enumerable.Empty()).ToArray(); - IEnumerable usersToAdd = _userService.GetUsersById(checkedGroupMembers); + IEnumerable usersToAdd = _userService.GetUsersById(performingUserId); // Since this is a brand new creation we don't have to be worried about what users were added and removed // simply put all members that are requested to be in the group will be "added" diff --git a/src/Umbraco.Core/Services/UserService.cs b/src/Umbraco.Core/Services/UserService.cs index 156355591f..ccc1511cc3 100644 --- a/src/Umbraco.Core/Services/UserService.cs +++ b/src/Umbraco.Core/Services/UserService.cs @@ -1,17 +1,28 @@ -using System.Data.Common; +using System.Formats.Asn1; using System.Globalization; using System.Linq.Expressions; +using System.Security.Cryptography; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Editors; using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Exceptions; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Models.TemporaryFile; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence; using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; +using Umbraco.New.Cms.Core.Models; using Umbraco.Extensions; using UserProfile = Umbraco.Cms.Core.Models.Membership.UserProfile; @@ -24,31 +35,55 @@ namespace Umbraco.Cms.Core.Services; internal class UserService : RepositoryService, IUserService { private readonly GlobalSettings _globalSettings; + private readonly SecuritySettings _securitySettings; private readonly ILogger _logger; - private readonly IRuntimeState _runtimeState; private readonly IUserGroupRepository _userGroupRepository; + private readonly UserEditorAuthorizationHelper _userEditorAuthorizationHelper; + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly IEntityService _entityService; + private readonly ILocalLoginSettingProvider _localLoginSettingProvider; + private readonly IUserInviteSender _inviteSender; + private readonly MediaFileManager _mediaFileManager; + private readonly ITemporaryFileService _temporaryFileService; + private readonly IShortStringHelper _shortStringHelper; private readonly IUserRepository _userRepository; + private readonly ContentSettings _contentSettings; public UserService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, IEventMessagesFactory eventMessagesFactory, - IRuntimeState runtimeState, IUserRepository userRepository, IUserGroupRepository userGroupRepository, - IOptions globalSettings) + IOptions globalSettings, + IOptions securitySettings, + UserEditorAuthorizationHelper userEditorAuthorizationHelper, + IServiceScopeFactory serviceScopeFactory, + IEntityService entityService, + ILocalLoginSettingProvider localLoginSettingProvider, + IUserInviteSender inviteSender, + MediaFileManager mediaFileManager, + ITemporaryFileService temporaryFileService, + IShortStringHelper shortStringHelper, + IOptions contentSettings) : base(provider, loggerFactory, eventMessagesFactory) { - _runtimeState = runtimeState; _userRepository = userRepository; _userGroupRepository = userGroupRepository; + _userEditorAuthorizationHelper = userEditorAuthorizationHelper; + _serviceScopeFactory = serviceScopeFactory; + _entityService = entityService; + _localLoginSettingProvider = localLoginSettingProvider; + _inviteSender = inviteSender; + _mediaFileManager = mediaFileManager; + _temporaryFileService = temporaryFileService; + _shortStringHelper = shortStringHelper; _globalSettings = globalSettings.Value; + _securitySettings = securitySettings.Value; + _contentSettings = contentSettings.Value; _logger = loggerFactory.CreateLogger(); } - private bool IsUpgrading => - _runtimeState.Level == RuntimeLevel.Install || _runtimeState.Level == RuntimeLevel.Upgrade; - /// /// Checks in a set of permissions associated with a user for those related to a given nodeId /// @@ -246,25 +281,9 @@ internal class UserService : RepositoryService, IUserService /// public IUser? GetByEmail(string email) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - try - { - IQuery query = Query().Where(x => x.Email.Equals(email)); - return _userRepository.Get(query)?.FirstOrDefault(); - } - catch(DbException) - { - // We also need to catch upgrade state here, because the framework will try to call this to validate the email. - if (IsUpgrading) - { - return _userRepository.GetForUpgradeByEmail(email); - } - - throw; - } - - } + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IBackofficeUserStore backofficeUserStore = scope.ServiceProvider.GetRequiredService(); + return backofficeUserStore.GetByEmailAsync(email).GetAwaiter().GetResult(); } /// @@ -280,28 +299,10 @@ internal class UserService : RepositoryService, IUserService { return null; } + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IBackofficeUserStore backofficeUserStore = scope.ServiceProvider.GetRequiredService(); - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - try - { - return _userRepository.GetByUsername(username, true); - } - catch (DbException) - { - // TODO: refactor users/upgrade - // currently kinda accepting anything on upgrade, but that won't deal with all cases - // so we need to do it differently, see the custom UmbracoPocoDataBuilder which should - // be better BUT requires that the app restarts after the upgrade! - if (IsUpgrading) - { - // NOTE: this will not be cached - return _userRepository.GetForUpgradeByUsername(username); - } - - throw; - } - } + return backofficeUserStore.GetByUserNameAsync(username).GetAwaiter().GetResult(); } /// @@ -310,10 +311,10 @@ internal class UserService : RepositoryService, IUserService /// to disable public void Delete(IUser membershipUser) { - // disable - membershipUser.IsApproved = false; + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IBackofficeUserStore backofficeUserStore = scope.ServiceProvider.GetRequiredService(); - Save(membershipUser); + backofficeUserStore.DisableAsync(membershipUser).GetAwaiter().GetResult(); } /// @@ -355,51 +356,22 @@ internal class UserService : RepositoryService, IUserService /// to Save public void Save(IUser entity) { - EventMessages evtMsgs = EventMessagesFactory.Get(); + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IBackofficeUserStore backofficeUserStore = scope.ServiceProvider.GetRequiredService(); - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - var savingNotification = new UserSavingNotification(entity, evtMsgs); - if (scope.Notifications.PublishCancelable(savingNotification)) - { - scope.Complete(); - return; - } + backofficeUserStore.SaveAsync(entity).GetAwaiter().GetResult(); + } - if (string.IsNullOrWhiteSpace(entity.Username)) - { - throw new ArgumentException("Empty username.", nameof(entity)); - } + /// + /// Saves an + /// + /// to Save + public async Task SaveAsync(IUser entity) + { + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IBackofficeUserStore backofficeUserStore = scope.ServiceProvider.GetRequiredService(); - if (string.IsNullOrWhiteSpace(entity.Name)) - { - throw new ArgumentException("Empty name.", nameof(entity)); - } - - try - { - _userRepository.Save(entity); - scope.Notifications.Publish( - new UserSavedNotification(entity, evtMsgs).WithStateFrom(savingNotification)); - - scope.Complete(); - } - catch (DbException ex) - { - // if we are upgrading and an exception occurs, log and swallow it - if (IsUpgrading == false) - { - throw; - } - - _logger.LogWarning( - ex, - "An error occurred attempting to save a user instance during upgrade, normally this warning can be ignored"); - - // we don't want the uow to rollback its scope! - scope.Complete(); - } - } + return await backofficeUserStore.SaveAsync(entity); } /// @@ -624,6 +596,751 @@ internal class UserService : RepositoryService, IUserService } } + /// + public async Task> CreateAsync(Guid performingUserKey, UserCreateModel model, bool approveUser = false) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + using IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); + + IUser? performingUser = await GetAsync(performingUserKey); + + if (performingUser is null) + { + return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new UserCreationResult()); + } + + UserOperationStatus result = ValidateUserCreateModel(model); + if (result != UserOperationStatus.Success) + { + return Attempt.FailWithStatus(result, new UserCreationResult()); + } + + Attempt authorizationAttempt = _userEditorAuthorizationHelper.IsAuthorized( + performingUser, + null, + null, + null, + model.UserGroups.Select(x => x.Alias)); + + if (authorizationAttempt.Success is false) + { + return Attempt.FailWithStatus(UserOperationStatus.Unauthorized, new UserCreationResult()); + } + + ICoreBackofficeUserManager backofficeUserManager = serviceScope.ServiceProvider.GetRequiredService(); + + IdentityCreationResult identityCreationResult = await backofficeUserManager.CreateAsync(model); + + if (identityCreationResult.Succeded is false) + { + // If we fail from something in Identity we can't know exactly why, so we have to resolve to returning an unknown failure. + // But there should be more information in the message. + return Attempt.FailWithStatus( + UserOperationStatus.UnknownFailure, + new UserCreationResult { ErrorMessage = identityCreationResult.ErrorMessage }); + } + + // The user is now created, so we can fetch it to map it to a result model with our generated password. + // and set it to being approved + IBackofficeUserStore backofficeUserStore = serviceScope.ServiceProvider.GetRequiredService(); + IUser? createdUser = await backofficeUserStore.GetByEmailAsync(model.Email); + + if (createdUser is null) + { + // This really shouldn't happen, we literally just created the user + throw new PanicException("Was unable to get user after creating it"); + } + + createdUser.IsApproved = approveUser; + + foreach (IUserGroup userGroup in model.UserGroups) + { + createdUser.AddGroup(userGroup.ToReadOnlyGroup()); + } + + await backofficeUserStore.SaveAsync(createdUser); + + scope.Complete(); + + var creationResult = new UserCreationResult + { + CreatedUser = createdUser, + InitialPassword = identityCreationResult.InitialPassword + }; + + return Attempt.SucceedWithStatus(UserOperationStatus.Success, creationResult); + } + + public async Task> InviteAsync(Guid performingUserKey, UserInviteModel model) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + using IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); + + IUser? performingUser = await GetAsync(performingUserKey); + + if (performingUser is null) + { + return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new UserInvitationResult()); + } + + UserOperationStatus validationResult = ValidateUserCreateModel(model); + + if (validationResult is not UserOperationStatus.Success) + { + return Attempt.FailWithStatus(validationResult, new UserInvitationResult()); + } + + Attempt authorizationAttempt = _userEditorAuthorizationHelper.IsAuthorized( + performingUser, + null, + null, + null, + model.UserGroups.Select(x => x.Alias)); + + if (authorizationAttempt.Success is false) + { + return Attempt.FailWithStatus(UserOperationStatus.Unauthorized, new UserInvitationResult()); + } + + if (_inviteSender.CanSendInvites() is false) + { + return Attempt.FailWithStatus(UserOperationStatus.CannotInvite, new UserInvitationResult()); + } + + ICoreBackofficeUserManager userManager = serviceScope.ServiceProvider.GetRequiredService(); + IBackofficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService(); + + IdentityCreationResult creationResult = await userManager.CreateForInvite(model); + if (creationResult.Succeded is false) + { + // If we fail from something in Identity we can't know exactly why, so we have to resolve to returning an unknown failure. + // But there should be more information in the message. + return Attempt.FailWithStatus( + UserOperationStatus.UnknownFailure, + new UserInvitationResult { ErrorMessage = creationResult.ErrorMessage }); + } + + IUser? invitedUser = await userStore.GetByEmailAsync(model.Email); + + if (invitedUser is null) + { + // This really shouldn't happen, we literally just created the user + throw new PanicException("Was unable to get user after creating it"); + } + + invitedUser.InvitedDate = DateTime.Now; + invitedUser.ClearGroups(); + foreach(IUserGroup userGroup in model.UserGroups) + { + invitedUser.AddGroup(userGroup.ToReadOnlyGroup()); + } + + await userStore.SaveAsync(invitedUser); + + IInviteUriProvider inviteUriProvider = serviceScope.ServiceProvider.GetRequiredService(); + Attempt inviteUriAttempt = await inviteUriProvider.CreateInviteUriAsync(invitedUser); + if (inviteUriAttempt.Success is false) + { + return Attempt.FailWithStatus(inviteUriAttempt.Status, new UserInvitationResult()); + } + + var invitation = new UserInvitationMessage + { + InviteUri = inviteUriAttempt.Result, + Message = model.Message ?? string.Empty, + Recipient = invitedUser, + Sender = performingUser, + }; + await _inviteSender.InviteUser(invitation); + + scope.Complete(); + + return Attempt.SucceedWithStatus(UserOperationStatus.Success, new UserInvitationResult { InvitedUser = invitedUser }); + } + + private UserOperationStatus ValidateUserCreateModel(UserCreateModel model) + { + if (_securitySettings.UsernameIsEmail && model.UserName != model.Email) + { + return UserOperationStatus.UserNameIsNotEmail; + } + + if (GetByEmail(model.Email) is not null) + { + return UserOperationStatus.DuplicateEmail; + } + + if (GetByUsername(model.UserName) is not null) + { + return UserOperationStatus.DuplicateUserName; + } + + if(model.UserGroups.Count == 0) + { + return UserOperationStatus.NoUserGroup; + } + + return UserOperationStatus.Success; + } + + public async Task> UpdateAsync(Guid performingUserKey, UserUpdateModel model) + { + IUser? performingUser = await GetAsync(performingUserKey); + + if (performingUser is null) + { + return Attempt.FailWithStatus(UserOperationStatus.MissingUser, model.ExistingUser); + } + + // We have to resolve the keys to ids to be compatible with the repository, this could be done in the factory, + // but I'd rather keep the ids out of the service API as much as possible. + int[]? startContentIds = GetIdsFromKeys(model.ContentStartNodeKeys, UmbracoObjectTypes.Document); + int[]? startMediaIds = GetIdsFromKeys(model.MediaStartNodeKeys, UmbracoObjectTypes.Media); + + Attempt isAuthorized = _userEditorAuthorizationHelper.IsAuthorized( + performingUser, + model.ExistingUser, + startContentIds, + startMediaIds, + model.UserGroups.Select(x => x.Alias)); + + if (isAuthorized.Success is false) + { + return Attempt.FailWithStatus(UserOperationStatus.Unauthorized, model.ExistingUser); + } + + UserOperationStatus validationStatus = ValidateUserUpdateModel(model); + if (validationStatus is not UserOperationStatus.Success) + { + return Attempt.FailWithStatus(validationStatus, model.ExistingUser); + } + + // Now that we're all authorized and validated we can actually map over changes and update the user + // TODO: This probably shouldn't live here, once we have user content start nodes as keys this can be moved to a mapper + // Alternatively it should be a map definition, but then we need to use entity service to resolve the IDs + // TODO: Add auditing + IUser updated = MapUserUpdate(model, model.ExistingUser, startContentIds, startMediaIds); + UserOperationStatus saveStatus = await SaveAsync(updated); + + return saveStatus is UserOperationStatus.Success + ? Attempt.SucceedWithStatus(UserOperationStatus.Success, updated) + : Attempt.FailWithStatus(saveStatus, model.ExistingUser); + } + + public async Task SetAvatarAsync(IUser user, Guid temporaryFileKey) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + TemporaryFileModel? avatarTemporaryFile = await _temporaryFileService.GetAsync(temporaryFileKey); + _temporaryFileService.EnlistDeleteIfScopeCompletes(temporaryFileKey, ScopeProvider); + + if (avatarTemporaryFile is null) + { + return UserOperationStatus.NotFound; + } + + const string allowedAvatarFileTypes = "jpeg,jpg,gif,bmp,png,tiff,tif,webp"; + + // This shouldn't really be necessary since we're just gonna use it to generate a hash, but that's how it was. + var avatarFileName = avatarTemporaryFile.FileName.ToSafeFileName(_shortStringHelper); + var extension = Path.GetExtension(avatarFileName)[1..]; + if(allowedAvatarFileTypes.Contains(extension) is false || _contentSettings.DisallowedUploadedFileExtensions.Contains(extension)) + { + return UserOperationStatus.InvalidAvatar; + } + + // Generate a path from known data, we don't want this to be guessable + var avatarHash = $"{user.Key}{avatarFileName}".GenerateHash(); + var avatarPath = $"UserAvatars/{avatarHash}.{extension}"; + + await using (Stream fileStream = avatarTemporaryFile.OpenReadStream()) + { + _mediaFileManager.FileSystem.AddFile(avatarPath, fileStream, true); + } + + user.Avatar = avatarPath; + await SaveAsync(user); + + scope.Complete(); + return UserOperationStatus.Success; + } + + private IUser MapUserUpdate( + UserUpdateModel source, + IUser target, + int[]? startContentIds, + int[]? startMediaIds) + { + target.Name = source.Name; + target.Language = source.Language; + target.Email = source.Email; + target.Username = source.UserName; + target.StartContentIds = startContentIds; + target.StartMediaIds = startMediaIds; + + target.ClearGroups(); + foreach (IUserGroup group in source.UserGroups) + { + target.AddGroup(group.ToReadOnlyGroup()); + } + + return target; + } + + private UserOperationStatus ValidateUserUpdateModel(UserUpdateModel model) + { + // We need to check if there's any Deny Local login providers present, if so we need to ensure that the user's email address cannot be changed. + if (_localLoginSettingProvider.HasDenyLocalLogin() && model.Email != model.ExistingUser.Email) + { + return UserOperationStatus.EmailCannotBeChanged; + } + + if (_securitySettings.UsernameIsEmail && model.UserName != model.Email) + { + return UserOperationStatus.UserNameIsNotEmail; + } + + IUser? existing = GetByEmail(model.Email); + if (existing is not null && existing.Key != model.ExistingUser.Key) + { + return UserOperationStatus.DuplicateEmail; + } + + // In case the user has updated their username to be a different email, but not their actually email + // we have to try and get the user by email using their username, and ensure we don't get any collisions. + existing = GetByEmail(model.UserName); + if (existing is not null && existing.Key != model.ExistingUser.Key) + { + return UserOperationStatus.DuplicateUserName; + } + + existing = GetByUsername(model.UserName); + if (existing is not null && existing.Key != model.ExistingUser.Key) + { + return UserOperationStatus.DuplicateUserName; + } + + return UserOperationStatus.Success; + } + + private int[]? GetIdsFromKeys(IEnumerable? guids, UmbracoObjectTypes type) + { + int[]? keys = guids? + .Select(x => _entityService.GetId(x, type)) + .Where(x => x.Success) + .Select(x => x.Result) + .ToArray(); + + return keys; + } + + public async Task> ChangePasswordAsync(Guid performingUserKey, ChangeBackofficeUserPasswordModel model) + { + IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + IUser? performingUser = await GetAsync(performingUserKey); + if (performingUser is null) + { + return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new PasswordChangedModel()); + } + + if (performingUser.Username == model.User.Username && string.IsNullOrEmpty(model.OldPassword)) + { + return Attempt.FailWithStatus(UserOperationStatus.OldPasswordRequired, new PasswordChangedModel()); + } + + if (performingUser.IsAdmin() is false && model.User.IsAdmin()) + { + return Attempt.FailWithStatus(UserOperationStatus.Forbidden, new PasswordChangedModel()); + } + + IBackofficePasswordChanger passwordChanger = serviceScope.ServiceProvider.GetRequiredService(); + Attempt result = await passwordChanger.ChangeBackofficePassword(model); + + if (result.Success is false) + { + return Attempt.FailWithStatus(UserOperationStatus.UnknownFailure, result.Result ?? new PasswordChangedModel()); + } + + scope.Complete(); + return Attempt.SucceedWithStatus(UserOperationStatus.Success, result.Result ?? new PasswordChangedModel()); + } + + public async Task?, UserOperationStatus>> GetAllAsync(Guid requestingUserKey, int skip, int take) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + IUser? requestingUser = await GetAsync(requestingUserKey); + + if (requestingUser is null) + { + return Attempt.FailWithStatus?, UserOperationStatus>(UserOperationStatus.MissingUser, null); + } + + UserFilter baseFilter = CreateBaseUserFilter(requestingUser, out IQuery query); + + PaginationHelper.ConvertSkipTakeToPaging(skip, take, out long pageNumber, out int pageSize); + + IEnumerable result = _userRepository.GetPagedResultsByQuery( + null, + pageNumber, + pageSize, + out long totalRecords, + x => x.Username, + excludeUserGroups: baseFilter.ExcludedUserGroupAliases?.ToArray(), + filter: query, + userState: baseFilter.IncludeUserStates?.ToArray()); + + var pagedResult = new PagedModel { Items = result, Total = totalRecords }; + + scope.Complete(); + return Attempt.SucceedWithStatus?, UserOperationStatus>(UserOperationStatus.Success, pagedResult); + } + + public async Task, UserOperationStatus>> FilterAsync( + Guid requestingUserKey, + UserFilter filter, + int skip = 0, + int take = 100, + UserOrder orderBy = UserOrder.UserName, + Direction orderDirection = Direction.Ascending) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + IUser? requestingUser = await GetAsync(requestingUserKey); + + if (requestingUser is null) + { + return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new PagedModel()); + } + + UserFilter baseFilter = CreateBaseUserFilter(requestingUser, out IQuery baseQuery); + + UserFilter mergedFilter = filter.Merge(baseFilter); + + // FIXME: This is not needed after we have keys + SortedSet excludedUserGroupAliases = mergedFilter.ExcludedUserGroupAliases ?? new SortedSet(); + if (mergedFilter.ExcludeUserGroups is not null) + { + Attempt, UserOperationStatus> userGroupKeyConversionAttempt = + GetUserGroupAliasesFromKeys(mergedFilter.ExcludeUserGroups); + + + if (userGroupKeyConversionAttempt.Success is false) + { + return Attempt.FailWithStatus(UserOperationStatus.MissingUserGroup, new PagedModel()); + } + + excludedUserGroupAliases.UnionWith(userGroupKeyConversionAttempt.Result); + } + + string[]? includedUserGroupAliases = null; + if (mergedFilter.IncludedUserGroups is not null) + { + Attempt, UserOperationStatus> userGroupKeyConversionAttempt = GetUserGroupAliasesFromKeys(mergedFilter.IncludedUserGroups); + + if (userGroupKeyConversionAttempt.Success is false) + { + return Attempt.FailWithStatus(UserOperationStatus.MissingUserGroup, new PagedModel()); + } + + includedUserGroupAliases = userGroupKeyConversionAttempt.Result.ToArray(); + } + + + if (mergedFilter.NameFilters is not null) + { + foreach (var nameFilter in mergedFilter.NameFilters) + { + baseQuery.Where(x => x.Name!.Contains(nameFilter) || x.Username.Contains(nameFilter)); + } + } + + SortedSet? includeUserStates = null; + + // The issue is that this is a limiting filter we have to ensure that it still follows our rules + // So I'm not allowed to ask for the disabled users if the setting has been flipped + if (baseFilter.IncludeUserStates is null || baseFilter.IncludeUserStates.IsCollectionEmpty()) + { + includeUserStates = filter.IncludeUserStates; + } + else + { + includeUserStates = new SortedSet(filter.IncludeUserStates!); + includeUserStates.IntersectWith(baseFilter.IncludeUserStates); + + // This means that we've only chosen to include a user state that is not allowed, so we'll return an empty result + if(includeUserStates.Count == 0) + { + return Attempt.SucceedWithStatus(UserOperationStatus.Success, new PagedModel()); + } + } + + + PaginationHelper.ConvertSkipTakeToPaging(skip, take, out long pageNumber, out int pageSize); + Expression> orderByExpression = GetOrderByExpression(orderBy); + + // TODO: We should create a Query method on the repo that allows to filter by aliases. + IEnumerable result = _userRepository.GetPagedResultsByQuery( + null, + pageNumber, + pageSize, + out long totalRecords, + orderByExpression, + orderDirection, + includedUserGroupAliases?.ToArray(), + excludedUserGroupAliases.ToArray(), + includeUserStates?.ToArray(), + baseQuery); + + scope.Complete(); + + var model = new PagedModel { Items = result, Total = totalRecords }; + + return Attempt.SucceedWithStatus(UserOperationStatus.Success, model); + } + + /// + /// Creates a base user filter which ensures our rules are followed, I.E. Only admins can se other admins. + /// + /// + /// We return the query as an out parameter instead of having it in the intermediate object because a two queries cannot be merged into one. + /// + /// + private UserFilter CreateBaseUserFilter(IUser performingUser, out IQuery baseQuery) + { + var filter = new UserFilter(); + baseQuery = Query(); + + // Only super can see super + if (performingUser.IsSuper() is false) + { + baseQuery.Where(x => x.Key != Constants.Security.SuperUserKey); + } + + // Only admins can see admins + if (performingUser.IsAdmin() is false) + { + filter.ExcludedUserGroupAliases = new SortedSet { Constants.Security.AdminGroupAlias }; + } + + if (_securitySettings.HideDisabledUsersInBackOffice) + { + filter.IncludeUserStates = new SortedSet { UserState.Active, UserState.Invited, UserState.LockedOut, UserState.Inactive }; + } + + return filter; + } + + private Attempt, UserOperationStatus> GetUserGroupAliasesFromKeys(IEnumerable userGroupKeys) + { + var aliases = new List(); + + foreach (Guid key in userGroupKeys) + { + IUserGroup? group = _userGroupRepository.Get(Query().Where(x => x.Key == key)).FirstOrDefault(); + if (group is null) + { + return Attempt.FailWithStatus(UserOperationStatus.MissingUserGroup, Enumerable.Empty()); + } + + aliases.Add(group.Alias); + } + + return Attempt.SucceedWithStatus, UserOperationStatus>(UserOperationStatus.Success, aliases); + } + + private Expression> GetOrderByExpression(UserOrder orderBy) + { + return orderBy switch + { + UserOrder.UserName => x => x.Username, + UserOrder.Language => x => x.Language, + UserOrder.Name => x => x.Name, + UserOrder.Email => x => x.Email, + UserOrder.Id => x => x.Id, + UserOrder.CreateDate => x => x.CreateDate, + UserOrder.UpdateDate => x => x.UpdateDate, + UserOrder.IsApproved => x => x.IsApproved, + UserOrder.IsLockedOut => x => x.IsLockedOut, + UserOrder.LastLoginDate => x => x.LastLoginDate, + _ => throw new ArgumentOutOfRangeException(nameof(orderBy), orderBy, null) + }; + } + + public async Task DeleteAsync(Guid key) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + IUser? user = await GetAsync(key); + + if (user is null) + { + return UserOperationStatus.NotFound; + } + + // Check user hasn't logged in. If they have they may have made content changes which will mean + // the Id is associated with audit trails, versions etc. and can't be removed. + if (user.LastLoginDate is not null && user.LastLoginDate != default(DateTime)) + { + return UserOperationStatus.CannotDelete; + } + + Delete(user, true); + + scope.Complete(); + return UserOperationStatus.Success; + } + + public async Task DisableAsync(Guid performingUserKey, ISet keys) + { + if(keys.Any() is false) + { + return UserOperationStatus.Success; + } + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + IUser? performingUser = await GetAsync(performingUserKey); + + if (performingUser is null) + { + return UserOperationStatus.MissingUser; + } + + if (keys.Contains(performingUser.Key)) + { + return UserOperationStatus.CannotDisableSelf; + } + + IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); + IBackofficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService(); + IUser[] usersToDisable = (await userStore.GetUsersAsync(keys.ToArray())).ToArray(); + + if (usersToDisable.Length != keys.Count) + { + return UserOperationStatus.NotFound; + } + + foreach (IUser user in usersToDisable) + { + if (user.UserState is UserState.Invited) + { + return UserOperationStatus.CannotDisableInvitedUser; + } + + user.IsApproved = false; + user.InvitedDate = null; + } + + Save(usersToDisable); + + scope.Complete(); + return UserOperationStatus.Success; + } + + public async Task EnableAsync(Guid performingUserKey, ISet keys) + { + if(keys.Any() is false) + { + return UserOperationStatus.Success; + } + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + IUser? performingUser = await GetAsync(performingUserKey); + + if (performingUser is null) + { + return UserOperationStatus.MissingUser; + } + + IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); + IBackofficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService(); + IUser[] usersToEnable = (await userStore.GetUsersAsync(keys.ToArray())).ToArray(); + + if (usersToEnable.Length != keys.Count) + { + return UserOperationStatus.NotFound; + } + + foreach (IUser user in usersToEnable) + { + user.IsApproved = true; + } + + Save(usersToEnable); + + scope.Complete(); + return UserOperationStatus.Success; + } + + public async Task ClearAvatarAsync(Guid userKey) + { + IUser? user = await GetAsync(userKey); + + if (user is null) + { + return UserOperationStatus.NotFound; + } + + if (string.IsNullOrWhiteSpace(user.Avatar)) + { + // Nothing to do + return UserOperationStatus.Success; + } + + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IBackofficeUserStore backofficeUserStore = scope.ServiceProvider.GetRequiredService(); + + string filePath = user.Avatar; + user.Avatar = null; + UserOperationStatus result = await backofficeUserStore.SaveAsync(user); + + if (result is not UserOperationStatus.Success) + { + return result; + } + + if (_mediaFileManager.FileSystem.FileExists(filePath)) + { + _mediaFileManager.FileSystem.DeleteFile(filePath); + } + + return UserOperationStatus.Success; + } + + public async Task> UnlockAsync(Guid performingUserKey, params Guid[] keys) + { + if (keys.Length == 0) + { + return Attempt.SucceedWithStatus(UserOperationStatus.Success, new UserUnlockResult()); + } + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + IUser? performingUser = await GetAsync(performingUserKey); + + if (performingUser is null) + { + return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new UserUnlockResult()); + } + + IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); + ICoreBackofficeUserManager manager = serviceScope.ServiceProvider.GetRequiredService(); + IBackofficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService(); + + IEnumerable usersToUnlock = await userStore.GetUsersAsync(keys); + + foreach (IUser user in usersToUnlock) + { + Attempt result = await manager.UnlockUser(user); + if (result.Success is false) + { + return Attempt.FailWithStatus(UserOperationStatus.UnknownFailure, result.Result); + } + } + + scope.Complete(); + return Attempt.SucceedWithStatus(UserOperationStatus.Success, new UserUnlockResult()); + } + public IEnumerable GetAll(long pageIndex, int pageSize, out long totalRecords, string orderBy, Direction orderDirection, UserState[]? userState = null, string[]? userGroups = null, string? filter = null) { IQuery? filterQuery = null; @@ -729,10 +1446,10 @@ internal class UserService : RepositoryService, IUserService return Array.Empty(); } - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _userRepository.GetAllInGroup(groupId.Value); - } + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IBackofficeUserStore backofficeUserStore = scope.ServiceProvider.GetRequiredService(); + + return backofficeUserStore.GetAllInGroupAsync(groupId.Value).GetAwaiter().GetResult(); } /// @@ -792,55 +1509,45 @@ internal class UserService : RepositoryService, IUserService /// /// Gets a user by Id /// - /// Id of the user to retrieve + /// Id of the user to retrieve. /// /// /// public IUser? GetUserById(int id) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - try - { - return _userRepository.Get(id); - } - catch (DbException) - { - // TODO: refactor users/upgrade - // currently kinda accepting anything on upgrade, but that won't deal with all cases - // so we need to do it differently, see the custom UmbracoPocoDataBuilder which should - // be better BUT requires that the app restarts after the upgrade! - if (IsUpgrading) - { - // NOTE: this will not be cached - return _userRepository.GetForUpgrade(id); - } + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IBackofficeUserStore backofficeUserStore = scope.ServiceProvider.GetRequiredService(); - throw; - } - } + return backofficeUserStore.GetAsync(id).GetAwaiter().GetResult(); } + /// + /// Gets a user by it's key. + /// + /// Key of the user to retrieve. + /// Task resolving into an . public Task GetAsync(Guid key) { - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - IQuery query = Query().Where(x => x.Key == key); - return Task.FromResult(_userRepository.Get(query).FirstOrDefault()); - } + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IBackofficeUserStore backofficeUserStore = scope.ServiceProvider.GetRequiredService(); + + return backofficeUserStore.GetAsync(key); + } + + public Task> GetAsync(IEnumerable keys) + { + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IBackofficeUserStore backofficeUserStore = scope.ServiceProvider.GetRequiredService(); + + return backofficeUserStore.GetUsersAsync(keys.ToArray()); } public IEnumerable GetUsersById(params int[]? ids) { - if (ids?.Length <= 0) - { - return Enumerable.Empty(); - } + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + IBackofficeUserStore backofficeUserStore = scope.ServiceProvider.GetRequiredService(); - using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _userRepository.GetMany(ids); - } + return backofficeUserStore.GetUsersAsync(ids).GetAwaiter().GetResult(); } /// diff --git a/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml b/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml index 78f73f8fbf..f2bbac9348 100644 --- a/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml +++ b/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml @@ -1,5 +1,4 @@  - CP0001 @@ -106,6 +105,13 @@ lib/net7.0/Umbraco.Infrastructure.dll true + + CP0002 + M:Umbraco.Cms.Core.Security.BackOfficeUserStore.#ctor(Umbraco.Cms.Core.Scoping.ICoreScopeProvider,Umbraco.Cms.Core.Services.IUserService,Umbraco.Cms.Core.Services.IEntityService,Umbraco.Cms.Core.Services.IExternalLoginWithKeyService,Microsoft.Extensions.Options.IOptionsSnapshot{Umbraco.Cms.Core.Configuration.Models.GlobalSettings},Umbraco.Cms.Core.Mapping.IUmbracoMapper,Umbraco.Cms.Core.Security.BackOfficeErrorDescriber,Umbraco.Cms.Core.Cache.AppCaches,Umbraco.Cms.Core.Services.ITwoFactorLoginService) + lib/net7.0/Umbraco.Infrastructure.dll + lib/net7.0/Umbraco.Infrastructure.dll + true + CP0002 M:Umbraco.Cms.Core.Services.Implement.PackagingService.#ctor(Umbraco.Cms.Core.Services.IAuditService,Umbraco.Cms.Core.Packaging.ICreatedPackagesRepository,Umbraco.Cms.Core.Packaging.IPackageInstallation,Umbraco.Cms.Core.Events.IEventAggregator,Umbraco.Cms.Core.Manifest.IManifestParser,Umbraco.Cms.Core.Services.IKeyValueService,Umbraco.Cms.Core.Packaging.PackageMigrationPlanCollection) diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index 73f0b0da49..ace1fd75dd 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -30,6 +30,7 @@ using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Runtime; using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Implement; @@ -54,6 +55,7 @@ using Umbraco.Cms.Infrastructure.Runtime; using Umbraco.Cms.Infrastructure.Runtime.RuntimeModeValidators; using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Cms.Infrastructure.Search; +using Umbraco.Cms.Infrastructure.Security; using Umbraco.Cms.Infrastructure.Serialization; using Umbraco.Cms.Infrastructure.Services.Implement; using Umbraco.Extensions; @@ -183,6 +185,7 @@ public static partial class UmbracoBuilderExtensions services.GetRequiredService(), services.GetService>(), services.GetService>())); + builder.Services.AddTransient(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs index 7a793c74cd..1098d0a990 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs @@ -41,6 +41,7 @@ public static partial class UmbracoBuilderExtensions // register the special idk map builder.Services.AddUnique(); + builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs index 73b118f71f..50358a917c 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs @@ -1,16 +1,22 @@ using System.Data; +using System.Data.Common; using System.Globalization; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Security; @@ -18,8 +24,10 @@ namespace Umbraco.Cms.Core.Security; /// /// The user store for back office users /// -public class BackOfficeUserStore : UmbracoUserStore>, - IUserSessionStore +public class BackOfficeUserStore : + UmbracoUserStore>, + IUserSessionStore, + IBackofficeUserStore { private readonly AppCaches _appCaches; private readonly IEntityService _entityService; @@ -29,7 +37,10 @@ public class BackOfficeUserStore : UmbracoUserStore _logger; /// /// Initializes a new instance of the class. @@ -37,7 +48,6 @@ public class BackOfficeUserStore : UmbracoUserStore globalSettings, @@ -45,11 +55,14 @@ public class BackOfficeUserStore : UmbracoUserStore logger) : base(describer) { _scopeProvider = scopeProvider; - _userService = userService ?? throw new ArgumentNullException(nameof(userService)); _entityService = entityService; _externalLoginService = externalLoginService ?? throw new ArgumentNullException(nameof(externalLoginService)); _globalSettings = globalSettings.Value; @@ -57,46 +70,30 @@ public class BackOfficeUserStore : UmbracoUserStore globalSettings, - IUmbracoMapper mapper, - BackOfficeErrorDescriber describer, - AppCaches appCaches, - ITwoFactorLoginService twoFactorLoginService) - : this( - scopeProvider, - userService, - entityService, - externalLoginService, - globalSettings, - mapper, - describer, - appCaches, - twoFactorLoginService, - StaticServiceProvider.Instance.GetRequiredService()) - { - } - /// public async Task ValidateSessionIdAsync(string? userId, string? sessionId) { - if (Guid.TryParse(sessionId, out Guid guidSessionId)) + if (!Guid.TryParse(sessionId, out Guid guidSessionId)) { - // We need to resolve the id from the key here... - var id = await ResolveEntityIdFromIdentityId(userId); - return _userService.ValidateLoginSession(id, guidSessionId); + return false; } - return false; + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + + // We need to resolve the id from the key here... + var id = await ResolveEntityIdFromIdentityId(userId); + + var sessionIsValid = _userRepository.ValidateLoginSession(id, guidSessionId); + scope.Complete(); + + return sessionIsValid; } /// @@ -151,7 +148,7 @@ public class BackOfficeUserStore : UmbracoUserStore + public Task SaveAsync(IUser user) + { + EventMessages eventMessages = _eventMessagesFactory.Get(); + + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + + var savingNotification = new UserSavingNotification(user, eventMessages); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + scope.Complete(); + return Task.FromResult(UserOperationStatus.CancelledByNotification); + } + + if (string.IsNullOrWhiteSpace(user.Username)) + { + throw new ArgumentException("Empty username.", nameof(user)); + } + + if (string.IsNullOrWhiteSpace(user.Name)) + { + throw new ArgumentException("Empty name.", nameof(user)); + } + + try + { + _userRepository.Save(user); + scope.Notifications.Publish( + new UserSavedNotification(user, eventMessages).WithStateFrom(savingNotification)); + + scope.Complete(); + } + catch (DbException ex) + { + // if we are upgrading and an exception occurs, log and swallow it + if (IsUpgrading == false) + { + throw; + } + + _logger.LogWarning( + ex, + "An error occurred attempting to save a user instance during upgrade, normally this warning can be ignored"); + + // we don't want the uow to rollback its scope! + scope.Complete(); + } + + return Task.FromResult(UserOperationStatus.Success); + } + + /// + public Task DisableAsync(IUser user) + { + // disable + user.IsApproved = false; + + return SaveAsync(user); + } + + public Task GetAsync(int id) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + + try + { + return Task.FromResult(_userRepository.Get(id)); + } + catch (DbException) + { + // TODO: refactor users/upgrade + // currently kinda accepting anything on upgrade, but that won't deal with all cases + // so we need to do it differently, see the custom UmbracoPocoDataBuilder which should + // be better BUT requires that the app restarts after the upgrade! + if (IsUpgrading) + { + // NOTE: this will not be cached + return Task.FromResult(_userRepository.GetForUpgrade(id)); + } + + throw; + } + } + + public Task> GetUsersAsync(params int[]? ids) + { + if (ids?.Length <= 0) + { + return Task.FromResult(Enumerable.Empty()); + } + + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + IEnumerable users = _userRepository.GetMany(ids); + + return Task.FromResult(users); + } + + public Task> GetUsersAsync(params Guid[]? keys) + { + if (keys is null || keys.Length <= 0) + { + return Task.FromResult(Enumerable.Empty()); + } + + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = _scopeProvider.CreateQuery().Where(x => keys.Contains(x.Key)); + IEnumerable users = _userRepository.Get(query); + + return Task.FromResult(users); + } + + /// + public Task GetAsync(Guid key) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + IQuery query = _scopeProvider.CreateQuery().Where(x => x.Key == key); + return Task.FromResult(_userRepository.Get(query).FirstOrDefault()); + } + + /// + public Task GetByUserNameAsync(string username) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + + try + { + IUser? user = _userRepository.GetByUsername(username, true); + return Task.FromResult(user); + } + catch (DbException) + { + // TODO: refactor users/upgrade + // currently kinda accepting anything on upgrade, but that won't deal with all cases + // so we need to do it differently, see the custom UmbracoPocoDataBuilder which should + // be better BUT requires that the app restarts after the upgrade! + if (IsUpgrading) + { + // NOTE: this will not be cached + IUser? upgradeUser = _userRepository.GetForUpgradeByUsername(username); + return Task.FromResult(upgradeUser); + } + + throw; + } + } + + /// + public Task GetByEmailAsync(string email) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + + try + { + IQuery query = _scopeProvider.CreateQuery().Where(x => x.Email.Equals(email)); + IUser? user = _userRepository.Get(query).FirstOrDefault(); + return Task.FromResult(user); + } + catch(DbException) + { + // We also need to catch upgrade state here, because the framework will try to call this to validate the email. + if (IsUpgrading) + { + IUser? upgradeUser = _userRepository.GetForUpgradeByEmail(email); + return Task.FromResult(upgradeUser); + } + + throw; + } + } + + /// + public Task> GetAllInGroupAsync(int groupId) + { + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + IEnumerable usersInGroup = _userRepository.GetAllInGroup(groupId); + return Task.FromResult(usersInGroup); + } + + private bool IsUpgrading => + _runtimeState.Level == RuntimeLevel.Install || _runtimeState.Level == RuntimeLevel.Upgrade; + /// public override Task UpdateAsync( BackOfficeIdentityUser user, @@ -203,7 +381,7 @@ public class BackOfficeUserStore : UmbracoUserStore - public override Task FindByNameAsync(string userName, CancellationToken cancellationToken = default) + public override async Task FindByNameAsync(string userName, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - IUser? user = _userService.GetByUsername(userName); + IUser? user = await GetByUserNameAsync(userName); if (user == null) { - return Task.FromResult(null); + return null; } BackOfficeIdentityUser? result = AssignLoginsCallback(_mapper.Map(user)); - return Task.FromResult(result)!; + return result; } /// @@ -301,14 +479,14 @@ public class BackOfficeUserStore : UmbracoUserStore - public override Task FindByEmailAsync( + public override async Task FindByEmailAsync( string email, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - IUser? user = _userService.GetByEmail(email); + IUser? user = await GetByEmailAsync(email); BackOfficeIdentityUser? result = user == null ? null : _mapper.Map(user); - return Task.FromResult(AssignLoginsCallback(result)); + return AssignLoginsCallback(result); } /// @@ -424,7 +602,7 @@ public class BackOfficeUserStore : UmbracoUserStore /// Identity Role names are equal to Umbraco UserGroup alias. /// - public override Task> GetUsersInRoleAsync( + public override async Task> GetUsersInRoleAsync( string normalizedRoleName, CancellationToken cancellationToken = default) { @@ -437,11 +615,16 @@ public class BackOfficeUserStore : UmbracoUserStore users = _userService.GetAllInGroup(userGroup?.Id); + if (userGroup is null) + { + return new List(); + } + + IEnumerable users = await GetAllInGroupAsync(userGroup.Id); IList backOfficeIdentityUsers = users.Select(x => _mapper.Map(x)).Where(x => x != null).ToList()!; - return Task.FromResult(backOfficeIdentityUsers); + return backOfficeIdentityUsers; } /// diff --git a/src/Umbraco.Infrastructure/Security/EmailUserInviteSender.cs b/src/Umbraco.Infrastructure/Security/EmailUserInviteSender.cs new file mode 100644 index 0000000000..acbb5ccdf0 --- /dev/null +++ b/src/Umbraco.Infrastructure/Security/EmailUserInviteSender.cs @@ -0,0 +1,73 @@ +using System.Globalization; +using Microsoft.Extensions.Options; +using MimeKit; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Mail; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Email; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Security; + +public class EmailUserInviteSender : IUserInviteSender +{ + private readonly IEmailSender _emailSender; + private readonly ILocalizedTextService _localizedTextService; + private readonly GlobalSettings _globalSettings; + + public EmailUserInviteSender( + IEmailSender emailSender, + ILocalizedTextService localizedTextService, + IOptions globalSettings) + { + _emailSender = emailSender; + _localizedTextService = localizedTextService; + _globalSettings = globalSettings.Value; + } + + public async Task InviteUser(UserInvitationMessage invite) + { + CultureInfo recipientCulture = UmbracoUserExtensions.GetUserCulture( + invite.Recipient.Language, + _localizedTextService, + _globalSettings); + + string senderEmail = string.IsNullOrEmpty(_globalSettings.Smtp?.From) + ? invite.Sender.Email + : _globalSettings.Smtp.From; + + string emailSubject = _localizedTextService.Localize( + "user", + "inviteEmailCopySubject", + recipientCulture); + + string?[] bodyTokes = + { + invite.Recipient.Name, + invite.Sender.Name ?? invite.Sender.Email, + invite.Message, + invite.InviteUri.ToString(), + senderEmail, + }; + + string emailBody = _localizedTextService.Localize( + "user", + "inviteEmailCopyFormat", + recipientCulture, + bodyTokes); + + // This needs to be in the correct mailto format including the name, else + // the name cannot be captured in the email sending notification. + // i.e. "Some Person" + var address = new MailboxAddress(invite.Recipient.Name, invite.Recipient.Email); + + var message = new EmailMessage(senderEmail, address.ToString(), emailSubject, emailBody, true); + + await _emailSender.SendAsync(message, Constants.Web.EmailTypes.UserInvite, true); + } + + public bool CanSendInvites() => _emailSender.CanSendRequiredEmail(); +} diff --git a/src/Umbraco.Infrastructure/Services/Implement/UserIdKeyResolver.cs b/src/Umbraco.Infrastructure/Services/Implement/UserIdKeyResolver.cs new file mode 100644 index 0000000000..6153c21646 --- /dev/null +++ b/src/Umbraco.Infrastructure/Services/Implement/UserIdKeyResolver.cs @@ -0,0 +1,51 @@ +using NPoco; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Services.Implement; + +// This could be made better with caching and stuff, but it's really a stop gap measure +// So for now we'll just use the database to resolve the key/id every time. +// It's okay that we never clear this, since you can never change a user's key/id +// and it'll be caught by the services if it doesn't exist. +internal sealed class UserIdKeyResolver : IUserIdKeyResolver +{ + private readonly IScopeProvider _scopeProvider; + + public UserIdKeyResolver(IScopeProvider scopeProvider) + { + _scopeProvider = scopeProvider; + } + + public Task GetAsync(Guid key) + { + using IScope scope = _scopeProvider.CreateScope(autoComplete: true); + ISqlContext sqlContext = scope.SqlContext; + + Sql query = sqlContext.Sql() + .Select(x => x.Id) + .From() + .Where(x => x.Key == key); + + + return scope.Database.ExecuteScalarAsync(query); + } + + public async Task GetAsync(int id) + { + using IScope scope = _scopeProvider.CreateScope(autoComplete: true); + ISqlContext sqlContext = scope.SqlContext; + + Sql query = sqlContext.Sql() + .Select(x => x.Key) + .From() + .Where(x => x.Id == id); + + string? guidString = await scope.Database.ExecuteScalarAsync(query); + + return guidString is null ? null : new Guid(guidString); + } +} diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs index 97722d8305..8909bca018 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs @@ -56,6 +56,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddUnique(); builder.Services.AddUnique, PasswordChanger>(); builder.Services.AddUnique, PasswordChanger>(); + builder.Services.AddScoped(); builder.AddNotificationHandler(); builder.AddNotificationHandler(); diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs index 6d3ff7edda..2132cdf608 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs @@ -1,12 +1,15 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Net; +using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; @@ -35,21 +38,30 @@ public static partial class UmbracoBuilderExtensions .AddDefaultTokenProviders() .AddUserStore, BackOfficeUserStore>(factory => new BackOfficeUserStore( factory.GetRequiredService(), - factory.GetRequiredService(), factory.GetRequiredService(), factory.GetRequiredService(), factory.GetRequiredService>(), factory.GetRequiredService(), factory.GetRequiredService(), factory.GetRequiredService(), - factory.GetRequiredService() + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService(), + factory.GetRequiredService>() )) .AddUserManager() .AddSignInManager() .AddClaimsPrincipalFactory() .AddErrorDescriber(); - services.TryAddSingleton(); + // We also need to register the store as a core-friendly interface that doesn't leak technology. + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddSingleton(); ; // Configure the options specifically for the UmbracoBackOfficeIdentityOptions instance services.ConfigureOptions(); @@ -64,6 +76,8 @@ public static partial class UmbracoBuilderExtensions services.TryAddScoped(); services.TryAddSingleton(); + // We need to know in the core services if local logins is denied, so we register the providers with a core friendly interface. + services.TryAddSingleton(); services.TryAddSingleton(); return new BackOfficeIdentityBuilder(services); diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviders.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviders.cs index 277fd06c6b..75548032f4 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviders.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeExternalLoginProviders.cs @@ -1,9 +1,10 @@ using Microsoft.AspNetCore.Authentication; +using Umbraco.Cms.Core.Security; namespace Umbraco.Cms.Web.BackOffice.Security; /// -public class BackOfficeExternalLoginProviders : IBackOfficeExternalLoginProviders +public class BackOfficeExternalLoginProviders : IBackOfficeExternalLoginProviders, ILocalLoginSettingProvider { private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider; private readonly Dictionary _externalLogins; diff --git a/src/Umbraco.Web.BackOffice/Security/BackofficePasswordChanger.cs b/src/Umbraco.Web.BackOffice/Security/BackofficePasswordChanger.cs new file mode 100644 index 0000000000..ca9764efee --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Security/BackofficePasswordChanger.cs @@ -0,0 +1,34 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Web.Common.Security; + +namespace Umbraco.Cms.Web.BackOffice.Security; + +public class BackofficePasswordChanger : IBackofficePasswordChanger +{ + private readonly IPasswordChanger _passwordChanger; + private readonly IBackOfficeUserManager _userManager; + + public BackofficePasswordChanger( + IPasswordChanger passwordChanger, + IBackOfficeUserManager userManager) + { + _passwordChanger = passwordChanger; + _userManager = userManager; + } + + public async Task> ChangeBackofficePassword( + ChangeBackofficeUserPasswordModel model) + { + var mappedModel = new ChangingPasswordModel + { + Id = model.User.Id, + OldPassword = model.OldPassword, + NewPassword = model.NewPassword + }; + + return await _passwordChanger.ChangePasswordWithIdentityAsync(mappedModel, _userManager); + } +} diff --git a/src/Umbraco.Web.BackOffice/Security/InviteUriProvider.cs b/src/Umbraco.Web.BackOffice/Security/InviteUriProvider.cs new file mode 100644 index 0000000000..dd3632b895 --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Security/InviteUriProvider.cs @@ -0,0 +1,59 @@ +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.BackOffice.Controllers; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Web.BackOffice.Security; + +public class InviteUriProvider : IInviteUriProvider +{ + private readonly LinkGenerator _linkGenerator; + private readonly ICoreBackofficeUserManager _userManager; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly WebRoutingSettings _webRoutingSettings; + + public InviteUriProvider( + LinkGenerator linkGenerator, + ICoreBackofficeUserManager userManager, + IHttpContextAccessor httpContextAccessor, + IOptions webRoutingSettings) + { + _linkGenerator = linkGenerator; + _userManager = userManager; + _httpContextAccessor = httpContextAccessor; + _webRoutingSettings = webRoutingSettings.Value; + } + + public async Task> CreateInviteUriAsync(IUser invitee) + { + Attempt tokenAttempt = await _userManager.GenerateEmailConfirmationTokenAsync(invitee); + + if (tokenAttempt.Success is false) + { + return Attempt.FailWithStatus(tokenAttempt.Status, new Uri(string.Empty)); + } + + string inviteToken = $"{invitee.Id}{WebUtility.UrlEncode("|")}{tokenAttempt.Result.ToUrlBase64()}"; + + // FIXME: This will need to change. + string? action = _linkGenerator.GetPathByAction( + nameof(BackOfficeController.VerifyInvite), + ControllerExtensions.GetControllerName(), + new { area = Constants.Web.Mvc.BackOfficeArea, invite = inviteToken }); + + Uri applicationUri = _httpContextAccessor + .GetRequiredHttpContext() + .Request + .GetApplicationUri(_webRoutingSettings); + + var inviteUri = new Uri(applicationUri, action); + return Attempt.SucceedWithStatus(UserOperationStatus.Success, inviteUri); + } +} diff --git a/src/Umbraco.Web.Common/CompatibilitySuppressions.xml b/src/Umbraco.Web.Common/CompatibilitySuppressions.xml index 61592d6e64..af97dca9af 100644 --- a/src/Umbraco.Web.Common/CompatibilitySuppressions.xml +++ b/src/Umbraco.Web.Common/CompatibilitySuppressions.xml @@ -1,5 +1,4 @@  - CP0002 @@ -8,6 +7,13 @@ lib/net7.0/Umbraco.Web.Common.dll true + + CP0002 + M:Umbraco.Cms.Web.Common.Security.BackOfficeUserManager.#ctor(Umbraco.Cms.Core.Net.IIpResolver,Microsoft.AspNetCore.Identity.IUserStore{Umbraco.Cms.Core.Security.BackOfficeIdentityUser},Microsoft.Extensions.Options.IOptions{Umbraco.Cms.Core.Security.BackOfficeIdentityOptions},Microsoft.AspNetCore.Identity.IPasswordHasher{Umbraco.Cms.Core.Security.BackOfficeIdentityUser},System.Collections.Generic.IEnumerable{Microsoft.AspNetCore.Identity.IUserValidator{Umbraco.Cms.Core.Security.BackOfficeIdentityUser}},System.Collections.Generic.IEnumerable{Microsoft.AspNetCore.Identity.IPasswordValidator{Umbraco.Cms.Core.Security.BackOfficeIdentityUser}},Umbraco.Cms.Core.Security.BackOfficeErrorDescriber,System.IServiceProvider,Microsoft.AspNetCore.Http.IHttpContextAccessor,Microsoft.Extensions.Logging.ILogger{Microsoft.AspNetCore.Identity.UserManager{Umbraco.Cms.Core.Security.BackOfficeIdentityUser}},Microsoft.Extensions.Options.IOptions{Umbraco.Cms.Core.Configuration.Models.UserPasswordConfigurationSettings},Umbraco.Cms.Core.Events.IEventAggregator,Umbraco.Cms.Core.Security.IBackOfficeUserPasswordChecker) + lib/net7.0/Umbraco.Web.Common.dll + lib/net7.0/Umbraco.Web.Common.dll + true + CP0008 T:Umbraco.Cms.Web.Common.FileProviders.WebRootFileProviderFactory diff --git a/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs b/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs index 352136504e..4dd7db9e74 100644 --- a/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs +++ b/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs @@ -4,20 +4,26 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Net; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Infrastructure.Security; using Umbraco.Extensions; namespace Umbraco.Cms.Web.Common.Security; public class BackOfficeUserManager : UmbracoUserManager, - IBackOfficeUserManager + IBackOfficeUserManager, + ICoreBackofficeUserManager { private readonly IBackOfficeUserPasswordChecker _backOfficeUserPasswordChecker; + private readonly GlobalSettings _globalSettings; private readonly IEventAggregator _eventAggregator; private readonly IHttpContextAccessor _httpContextAccessor; @@ -34,7 +40,8 @@ public class BackOfficeUserManager : UmbracoUserManager> logger, IOptions passwordConfiguration, IEventAggregator eventAggregator, - IBackOfficeUserPasswordChecker backOfficeUserPasswordChecker) + IBackOfficeUserPasswordChecker backOfficeUserPasswordChecker, + IOptions globalSettings) : base( ipResolver, store, @@ -50,6 +57,7 @@ public class BackOfficeUserManager : UmbracoUserManager @@ -139,6 +147,22 @@ public class BackOfficeUserManager : UmbracoUserManager> UnlockUser(IUser user) + { + BackOfficeIdentityUser? identityUser = await FindByIdAsync(user.Id.ToString()); + + if (identityUser is null) + { + return Attempt.FailWithStatus(UserOperationStatus.NotFound, new UserUnlockResult()); + } + + IdentityResult result = await SetLockoutEndDateAsync(identityUser, DateTimeOffset.Now.AddMinutes(-1)); + + return result.Succeeded + ? Attempt.SucceedWithStatus(UserOperationStatus.Success, new UserUnlockResult()) + : Attempt.FailWithStatus(UserOperationStatus.UnknownFailure, new UserUnlockResult { ErrorMessage = result.Errors.ToErrorMessage() }); + } + public override async Task ResetAccessFailedCountAsync(BackOfficeIdentityUser user) { IdentityResult result = await base.ResetAccessFailedCountAsync(user); @@ -246,4 +270,63 @@ public class BackOfficeUserManager : UmbracoUserManager CreateForInvite(UserCreateModel createModel) + { + var identityUser = BackOfficeIdentityUser.CreateNew( + _globalSettings, + createModel.UserName, + createModel.Email, + _globalSettings.DefaultUILanguage); + + identityUser.Name = createModel.Name; + + IdentityResult created = await CreateAsync(identityUser); + + return created.Succeeded + ? new IdentityCreationResult { Succeded = true } + : IdentityCreationResult.Fail(created.Errors.ToErrorMessage()); + } + + public async Task CreateAsync(UserCreateModel createModel) + { + var identityUser = BackOfficeIdentityUser.CreateNew( + _globalSettings, + createModel.UserName, + createModel.Email, + _globalSettings.DefaultUILanguage); + + identityUser.Name = createModel.Name; + + IdentityResult created = await CreateAsync(identityUser); + + if (created.Succeeded is false) + { + return IdentityCreationResult.Fail(created.Errors.ToErrorMessage()); + } + + var password = GeneratePassword(); + + IdentityResult passwordAdded = await AddPasswordAsync(identityUser, password); + if (passwordAdded.Succeeded is false) + { + return IdentityCreationResult.Fail(passwordAdded.Errors.ToErrorMessage()); + } + + return new IdentityCreationResult { Succeded = true, InitialPassword = password }; + } + + public async Task> GenerateEmailConfirmationTokenAsync(IUser user) + { + BackOfficeIdentityUser? identityUser = await FindByIdAsync(user.Id.ToString()); + + if (identityUser is null) + { + return Attempt.FailWithStatus(UserOperationStatus.NotFound, string.Empty); + } + + var token = await GenerateEmailConfirmationTokenAsync(identityUser); + + return Attempt.SucceedWithStatus(UserOperationStatus.Success, token); + } } diff --git a/tests/Umbraco.Tests.Common/CompatibilitySuppressions.xml b/tests/Umbraco.Tests.Common/CompatibilitySuppressions.xml index a709113936..657f05d2d7 100644 --- a/tests/Umbraco.Tests.Common/CompatibilitySuppressions.xml +++ b/tests/Umbraco.Tests.Common/CompatibilitySuppressions.xml @@ -1,5 +1,4 @@  - CP0002 diff --git a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml index 2ff5f38afb..bdfa11824e 100644 --- a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml +++ b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml @@ -1,5 +1,4 @@  - CP0002 diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Create.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Create.cs new file mode 100644 index 0000000000..316a8d2c7a --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Create.cs @@ -0,0 +1,143 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class UserServiceCrudTests +{ + [Test] + [TestCase("test@email.com", "test@email.com", true, true)] + [TestCase("test@email.com", "notTheUserName@email.com", true, false)] + [TestCase("NotAnEmail", "test@email.com", true, false)] + [TestCase("test@email.com", "test@email.com", false, true)] + [TestCase("NotAnEmail", "test@email.com", false, true)] + [TestCase("aDifferentEmail@email.com", "test@email.com", false, true)] + public async Task Creating_User_Name_Must_Be_Email( + string username, + string email, + bool userNameIsEmailEnabled, + bool shouldSucceed) + { + var securitySettings = new SecuritySettings { UsernameIsEmail = userNameIsEmailEnabled }; + var userService = CreateUserService(securitySettings); + + var userGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + var creationModel = new UserCreateModel + { + UserName = username, + Email = email, + Name = "Test Mc. Gee", + UserGroups = new HashSet { userGroup! } + }; + + var result = await userService.CreateAsync(Constants.Security.SuperUserKey, creationModel, true); + + if (shouldSucceed is false) + { + Assert.IsFalse(result.Success); + Assert.AreEqual(UserOperationStatus.UserNameIsNotEmail, result.Status); + return; + } + + Assert.IsTrue(result.Success); + Assert.AreEqual(UserOperationStatus.Success, result.Status); + var createdUser = result.Result.CreatedUser; + Assert.IsNotNull(createdUser); + Assert.AreEqual(username, createdUser.Username); + Assert.AreEqual(email, createdUser.Email); + } + + [Test] + public async Task Cannot_Create_User_With_Duplicate_Email() + { + var email = "test@test.com"; + var userGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + var initialUserCreateModel = new UserCreateModel + { + UserName = "Test1", + Email = email, + Name = "Test Mc. Gee", + UserGroups = new HashSet { userGroup! } + }; + + var userService = CreateUserService(new SecuritySettings { UsernameIsEmail = false }); + var result = await userService.CreateAsync(Constants.Security.SuperUserKey, initialUserCreateModel, true); + Assert.IsTrue(result.Success); + + var duplicateUserCreateModel = new UserCreateModel + { + UserName = "Test2", + Email = email, + Name = "Duplicate Mc. Gee", + UserGroups = new HashSet { userGroup! } + }; + + var secondResult = await userService.CreateAsync(Constants.Security.SuperUserKey, duplicateUserCreateModel, true); + Assert.IsFalse(secondResult.Success); + Assert.AreEqual(UserOperationStatus.DuplicateEmail, secondResult.Status); + } + + [Test] + public async Task Cannot_Create_User_With_Duplicate_UserName() + { + var userName = "UserName"; + var userGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + var initialUserCreateModel = new UserCreateModel + { + UserName = userName, + Email = "test@email.com", + Name = "Test Mc. Gee", + UserGroups = new HashSet { userGroup! } + }; + + var userService = CreateUserService(new SecuritySettings { UsernameIsEmail = false }); + var result = await userService.CreateAsync(Constants.Security.SuperUserKey, initialUserCreateModel, true); + Assert.IsTrue(result.Success); + + var duplicateUserCreateModel = new UserCreateModel + { + UserName = userName, + Email = "another@email.com", + Name = "Duplicate Mc. Gee", + UserGroups = new HashSet { userGroup! } + }; + + var secondResult = await userService.CreateAsync(Constants.Security.SuperUserKey, duplicateUserCreateModel, true); + Assert.IsFalse(secondResult.Success); + Assert.AreEqual(UserOperationStatus.DuplicateUserName, secondResult.Status); + } + + [Test] + public async Task Cannot_Create_User_Without_User_Group() + { + UserCreateModel userCreateModel = new UserCreateModel + { + UserName = "NoUser@Group.com", + Email = "NoUser@Group.com", + Name = "NoUser@Group.com", + }; + + IUserService userService = CreateUserService(); + + var result = await userService.CreateAsync(Constants.Security.SuperUserKey, userCreateModel, true); + + Assert.IsFalse(result.Success); + Assert.AreEqual(UserOperationStatus.NoUserGroup, result.Status); + } + + [Test] + public async Task Performing_User_Must_Exist_When_Creating() + { + IUserService userService = CreateUserService(); + + var result = await userService.CreateAsync(Guid.Empty, new UserCreateModel(), true); + + Assert.IsFalse(result.Success); + Assert.AreEqual(UserOperationStatus.MissingUser, result.Status); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Delete.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Delete.cs new file mode 100644 index 0000000000..1ba02a1b05 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Delete.cs @@ -0,0 +1,70 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class UserServiceCrudTests +{ + [Test] + public async Task Delete_Returns_Not_Found_If_Not_Found() + { + var userService = CreateUserService(); + var result = await userService.DeleteAsync(Guid.NewGuid()); + Assert.AreEqual(UserOperationStatus.NotFound, result); + } + + [Test] + public async Task Cannot_Delete_User_With_Login() + { + var userGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + var userCreateModel = new UserCreateModel + { + Email = "test@test.com", + UserName = "test@test.com", + Name = "test@test.com", + UserGroups = new HashSet { userGroup! } + }; + + var userService = CreateUserService(); + var creationResult = await userService.CreateAsync(Constants.Security.SuperUserKey, userCreateModel, true); + Assert.IsTrue(creationResult.Success); + var createdUser = creationResult.Result.CreatedUser; + + createdUser!.LastLoginDate = DateTime.Now; + userService.Save(createdUser); + + var result = await userService.DeleteAsync(createdUser.Key); + Assert.AreEqual(UserOperationStatus.CannotDelete, result); + + // Asset that it is in fact not deleted + var postDeletedUser = await userService.GetAsync(createdUser.Key); + Assert.IsNotNull(postDeletedUser); + Assert.AreEqual(createdUser.Key, postDeletedUser.Key); + } + + [Test] + public async Task Can_Delete_User_That_Has_Not_Logged_In() + { + var userGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + var userCreateModel = new UserCreateModel + { + Email = "test@test.com", + UserName = "test@test.com", + Name = "test@test.com", + UserGroups = new HashSet { userGroup! } + }; + + var userService = CreateUserService(); + var creationResult = await userService.CreateAsync(Constants.Security.SuperUserKey, userCreateModel, true); + Assert.IsTrue(creationResult.Success); + + var deletionResult = await userService.DeleteAsync(creationResult.Result.CreatedUser!.Key); + Assert.AreEqual(UserOperationStatus.Success, deletionResult); + // Make sure it's actually deleted + var postDeletedUser = await userService.GetAsync(creationResult.Result.CreatedUser.Key); + Assert.IsNull(postDeletedUser); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Filter.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Filter.cs new file mode 100644 index 0000000000..add38f9f40 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Filter.cs @@ -0,0 +1,245 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class UserServiceCrudTests +{ + [Test] + [TestCase(UserState.Disabled)] + [TestCase(UserState.All)] + public async Task Cannot_Request_Disabled_If_Hidden(UserState includeState) + { + var userService = CreateUserService(new SecuritySettings {HideDisabledUsersInBackOffice = true}); + var editorGroup = await UserGroupService.GetAsync(Constants.Security.EditorGroupAlias); + + var createModel = new UserCreateModel + { + UserName = "editor@mail.com", + Email = "editor@mail.com", + Name = "Editor", + UserGroups = new HashSet {editorGroup!} + }; + + var createAttempt = await userService.CreateAsync(Constants.Security.SuperUserKey, createModel, true); + Assert.IsTrue(createAttempt.Success); + + var disableStatus = + await userService.DisableAsync(Constants.Security.SuperUserKey, new HashSet{ createAttempt.Result.CreatedUser!.Key }); + Assert.AreEqual(UserOperationStatus.Success, disableStatus); + + var filter = new UserFilter {IncludeUserStates = new SortedSet {includeState}}; + + var filterAttempt = await userService.FilterAsync(Constants.Security.SuperUserKey, filter, 0, 1000); + Assert.IsTrue(filterAttempt.Success); + Assert.AreEqual(0, filterAttempt.Result.Items.Count()); + } + + [Test] + public async Task Only_Super_User_Can_Filter_Super_user() + { + var userService = CreateUserService(); + var editorGroup = await UserGroupService.GetAsync(Constants.Security.EditorGroupAlias); + var adminGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + + var nonSuperCreateModel = new UserCreateModel + { + Email = "not@super.com", + UserName = "not@super.com", + UserGroups = new HashSet {editorGroup!, adminGroup!}, + Name = "Not A Super User" + }; + + var createEditorAttempt = + await userService.CreateAsync(Constants.Security.SuperUserKey, nonSuperCreateModel, true); + Assert.IsTrue(createEditorAttempt.Success); + + var editor = createEditorAttempt.Result.CreatedUser; + + // An empty filter is essentially the same as "Give me everything" but you still can't see super users. + var filter = new UserFilter(); + var filterAttempt = await userService.FilterAsync(editor!.Key, filter, 0, 10000); + + Assert.IsTrue(filterAttempt.Success); + var result = filterAttempt.Result; + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Items.Count()); + Assert.AreEqual(1, result.Total); + var onlyUser = result.Items.First(); + Assert.AreEqual(editor.Key, onlyUser.Key); + } + + [Test] + public async Task Super_User_Can_Filter_Super_User() + { + var userService = CreateUserService(); + var editorGroup = await UserGroupService.GetAsync(Constants.Security.EditorGroupAlias); + + var nonSuperCreateModel = new UserCreateModel + { + Email = "not@super.com", + UserName = "not@super.com", + UserGroups = new HashSet {editorGroup!}, + Name = "Not A Super User" + }; + + var createEditorAttempt = + await userService.CreateAsync(Constants.Security.SuperUserKey, nonSuperCreateModel, true); + Assert.IsTrue(createEditorAttempt.Success); + + var filter = new UserFilter {NameFilters = new SortedSet {"admin"}}; + + var filterAttempt = await userService.FilterAsync(Constants.Security.SuperUserKey, filter, 0, 10000); + Assert.IsTrue(filterAttempt.Success); + var result = filterAttempt.Result; + + Assert.AreEqual(1, result.Items.Count()); + Assert.AreEqual(1, result.Total); + Assert.IsNotNull(result.Items.FirstOrDefault(x => x.Key == Constants.Security.SuperUserKey)); + } + + [Test] + public async Task Only_Admins_Can_Filter_Admins() + { + var userService = CreateUserService(); + var adminGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + var editorGroup = await UserGroupService.GetAsync(Constants.Security.EditorGroupAlias); + + var editorCreateModel = new UserCreateModel + { + UserName = "editor@mail.com", + Email = "editor@mail.com", + Name = "Editor Mc. Gee", + UserGroups = new HashSet {editorGroup!} + }; + + var adminCreateModel = new UserCreateModel + { + UserName = "admin@mail.com", + Email = "admin@mail.com", + Name = "Admin Mc. Gee", + UserGroups = new HashSet {adminGroup!, editorGroup} + }; + + var createEditorAttempt = + await userService.CreateAsync(Constants.Security.SuperUserKey, editorCreateModel, true); + var createAdminAttempt = await userService.CreateAsync(Constants.Security.SuperUserKey, adminCreateModel, true); + + Assert.IsTrue(createEditorAttempt.Success); + Assert.IsTrue(createAdminAttempt.Success); + + var filter = new UserFilter {IncludedUserGroups = new SortedSet {adminGroup!.Key}}; + + var editorFilterAttempt = + await userService.FilterAsync(createEditorAttempt.Result.CreatedUser!.Key, filter, 0, 10000); + Assert.IsTrue(editorFilterAttempt.Success); + var editorAllUsers = editorFilterAttempt.Result.Items.ToList(); + Assert.AreEqual(0, editorAllUsers.Count); + + var adminFilterAttempt = + await userService.FilterAsync(createAdminAttempt.Result.CreatedUser!.Key, filter, 0, 10000); + Assert.IsTrue(adminFilterAttempt.Success); + var adminAllUsers = adminFilterAttempt.Result.Items.ToList(); + Assert.AreEqual(1, adminAllUsers.Count); + Assert.IsNotNull(adminAllUsers.FirstOrDefault(x => x.Key == createAdminAttempt.Result.CreatedUser!.Key)); + } + + private async Task CreateTestUsers(IUserService userService) + { + var editorGroup = await UserGroupService.GetAsync(Constants.Security.EditorGroupAlias); + var adminGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + var writerGroup = await UserGroupService.GetAsync(Constants.Security.WriterGroupAlias); + var translatorGroup = await UserGroupService.GetAsync(Constants.Security.TranslatorGroupAlias); + + var createModels = new List + { + new() + { + UserName = "editor@email.com", + Email = "editor@email.com", + Name = "Editor", + UserGroups = new HashSet {editorGroup!} + }, + new() + { + UserName = "admin@email.com", + Email = "admin@email.com", + Name = "Admin", + UserGroups = new HashSet {adminGroup!} + }, + new() + { + UserName = "write@email.com", + Email = "write@email.com", + Name = "Write", + UserGroups = new HashSet {writerGroup} + }, + new() + { + UserName = "translator@email.com", + Email = "translator@email.com", + Name = "Translator", + UserGroups = new HashSet {translatorGroup} + }, + new() + { + UserName = "EverythingButAdmin@email.com", + Email = "EverythingButAdmin@email.com", + Name = "Everything But Admin", + UserGroups = new HashSet {editorGroup, writerGroup, translatorGroup} + } + }; + + foreach (var model in createModels) + { + var result = await userService.CreateAsync(Constants.Security.SuperUserKey, model); + Assert.IsTrue(result.Success); + } + } + + [Test] + public async Task Can_Include_User_Groups() + { + var userService = CreateUserService(); + await CreateTestUsers(userService); + + var writerGroup = await UserGroupService.GetAsync(Constants.Security.WriterGroupAlias); + var filter = new UserFilter + { + IncludedUserGroups = new SortedSet { writerGroup!.Key } + }; + + var onlyWritesResult = await userService.FilterAsync(Constants.Security.SuperUserKey, filter, 0, 1000); + + Assert.IsTrue(onlyWritesResult.Success); + var users = onlyWritesResult.Result.Items.ToList(); + Assert.IsTrue(users.Any()); + Assert.IsFalse(users.Any(x => x.Groups.FirstOrDefault(y => y.Key == writerGroup.Key) is null)); + } + + [Test] + public async Task Can_Exclude_User_Groups() + { + var userService = CreateUserService(); + await CreateTestUsers(userService); + + var editorGroup = await UserGroupService.GetAsync(Constants.Security.EditorGroupAlias); + var filter = new UserFilter + { + ExcludeUserGroups = new SortedSet { editorGroup!.Key } + }; + + var noEditorResult = await userService.FilterAsync(Constants.Security.SuperUserKey, filter, 0, 1000); + Assert.IsTrue(noEditorResult); + var users = noEditorResult.Result.Items.ToList(); + Assert.IsTrue(users.Any()); + Assert.IsFalse(users.Any(x => x.Groups.FirstOrDefault(y => y.Key == editorGroup.Key) is not null)); + } +} + + diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Get.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Get.cs new file mode 100644 index 0000000000..25faf96f61 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Get.cs @@ -0,0 +1,162 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class UserServiceCrudTests +{ + [Test] + public async Task Only_Super_User_Can_Get_Super_user() + { + var userService = CreateUserService(); + var editorGroup = await UserGroupService.GetAsync(Constants.Security.EditorGroupAlias); + var adminGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + + var nonSuperCreateModel = new UserCreateModel + { + Email = "not@super.com", + UserName = "not@super.com", + UserGroups = new HashSet { editorGroup!, adminGroup! }, + Name = "Not A Super User" + }; + + var createEditorAttempt = await userService.CreateAsync(Constants.Security.SuperUserKey, nonSuperCreateModel, true); + Assert.IsTrue(createEditorAttempt.Success); + + var editor = createEditorAttempt.Result.CreatedUser; + var allUsersAttempt = await userService.GetAllAsync(editor!.Key, 0, 10000); + + Assert.IsTrue(allUsersAttempt.Success); + var result = allUsersAttempt.Result; + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Items.Count()); + Assert.AreEqual(1, result.Total); + var onlyUser = result.Items.First(); + Assert.AreEqual(editor.Key, onlyUser.Key); + } + + [Test] + public async Task Super_User_Can_See_Super_User() + { + var userService = CreateUserService(); + var editorGroup = await UserGroupService.GetAsync(Constants.Security.EditorGroupAlias); + + var nonSuperCreateModel = new UserCreateModel + { + Email = "not@super.com", + UserName = "not@super.com", + UserGroups = new HashSet { editorGroup! }, + Name = "Not A Super User" + }; + + var createEditorAttempt = await userService.CreateAsync(Constants.Security.SuperUserKey, nonSuperCreateModel, true); + Assert.IsTrue(createEditorAttempt.Success); + + var editor = createEditorAttempt.Result.CreatedUser; + var allUsersAttempt = await userService.GetAllAsync(Constants.Security.SuperUserKey, 0, 10000); + Assert.IsTrue(allUsersAttempt.Success); + var result = allUsersAttempt.Result; + + Assert.AreEqual(2, result.Items.Count()); + Assert.AreEqual(2, result.Total); + Assert.IsTrue(result.Items.Any(x => x.Key == Constants.Security.SuperUserKey)); + Assert.IsTrue(result.Items.Any(x => x.Key == editor!.Key)); + } + + [Test] + public async Task Only_Admins_Can_See_Admins() + { + var userService = CreateUserService(); + var adminGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + var editorGroup = await UserGroupService.GetAsync(Constants.Security.EditorGroupAlias); + + var editorCreateModel = new UserCreateModel + { + UserName = "editor@mail.com", + Email = "editor@mail.com", + Name = "Editor Mc. Gee", + UserGroups = new HashSet { editorGroup! } + }; + + var adminCreateModel = new UserCreateModel + { + UserName = "admin@mail.com", + Email = "admin@mail.com", + Name = "Admin Mc. Gee", + UserGroups = new HashSet { adminGroup!, editorGroup } + }; + + var createEditorAttempt = await userService.CreateAsync(Constants.Security.SuperUserKey, editorCreateModel, true); + var createAdminAttempt = await userService.CreateAsync(Constants.Security.SuperUserKey, adminCreateModel, true); + + Assert.IsTrue(createEditorAttempt.Success); + Assert.IsTrue(createAdminAttempt.Success); + + var editorAllUsersAttempt = await userService.GetAllAsync(createEditorAttempt.Result.CreatedUser!.Key, 0, 10000); + Assert.IsTrue(editorAllUsersAttempt.Success); + var editorAllUsers = editorAllUsersAttempt.Result.Items.ToList(); + Assert.AreEqual(1, editorAllUsers.Count); + Assert.AreEqual(createEditorAttempt.Result.CreatedUser!.Key, editorAllUsers.First().Key); + + var adminAllUsersAttempt = await userService.GetAllAsync(createAdminAttempt.Result.CreatedUser!.Key, 0, 10000); + Assert.IsTrue(adminAllUsersAttempt.Success); + var adminAllUsers = adminAllUsersAttempt.Result.Items.ToList(); + Assert.AreEqual(2, adminAllUsers.Count); + Assert.IsTrue(adminAllUsers.Any(x => x.Key == createEditorAttempt.Result.CreatedUser!.Key)); + Assert.IsTrue(adminAllUsers.Any(x => x.Key == createAdminAttempt.Result.CreatedUser!.Key)); + } + + [Test] + public async Task Cannot_See_Disabled_When_HideDisabled_Is_True() + { + var userService = CreateUserService(securitySettings: new SecuritySettings { HideDisabledUsersInBackOffice = true }); + var editorGroup = await UserGroupService.GetAsync(Constants.Security.EditorGroupAlias); + + var firstEditorCreateModel = new UserCreateModel + { + UserName = "firstEditor@mail.com", + Email = "firstEditor@mail.com", + Name = "First Editor", + UserGroups = new HashSet { editorGroup! } + }; + + var firstEditorResult = await userService.CreateAsync(Constants.Security.SuperUserKey, firstEditorCreateModel, true); + Assert.IsTrue(firstEditorResult.Success); + + var secondEditorCreateModel = new UserCreateModel + { + UserName = "secondEditor@mail.com", + Email = "secondEditor@mail.com", + Name = "Second Editor", + UserGroups = new HashSet {editorGroup} + }; + + var secondEditorResult = await userService.CreateAsync(Constants.Security.SuperUserKey, secondEditorCreateModel, true); + Assert.IsTrue(secondEditorResult.Success); + + var disableStatus = await userService.DisableAsync(Constants.Security.SuperUserKey, new HashSet{ secondEditorResult.Result.CreatedUser!.Key }); + Assert.AreEqual(disableStatus, UserOperationStatus.Success); + + var allUsersAttempt = await userService.GetAllAsync(Constants.Security.SuperUserKey, 0, 10000); + Assert.IsTrue(allUsersAttempt.Success); + var allUsers = allUsersAttempt.Result!.Items.ToList(); + Assert.AreEqual(2, allUsers.Count); + Assert.IsTrue(allUsers.Any(x => x.Key == firstEditorResult.Result.CreatedUser!.Key)); + Assert.IsTrue(allUsers.Any(x => x.Key == Constants.Security.SuperUserKey)); + } + + [Test] + public async Task Requesting_User_Must_Exist_When_Calling_Get_All() + { + var userService = CreateUserService(); + + var getAllAttempt = await userService.GetAllAsync(Guid.NewGuid(), 0, 10000); + Assert.IsFalse(getAllAttempt.Success); + Assert.AreEqual(UserOperationStatus.MissingUser, getAllAttempt.Status); + Assert.IsNull(getAllAttempt.Result); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Invite.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Invite.cs new file mode 100644 index 0000000000..9f03a49f74 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Invite.cs @@ -0,0 +1,164 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class UserServiceCrudTests +{ + [Test] + [TestCase("test@email.com", "test@email.com", true, true)] + [TestCase("test@email.com", "notTheUserName@email.com", true, false)] + [TestCase("NotAnEmail", "test@email.com", true, false)] + [TestCase("test@email.com", "test@email.com", false, true)] + [TestCase("NotAnEmail", "test@email.com", false, true)] + [TestCase("aDifferentEmail@email.com", "test@email.com", false, true)] + public async Task Invite_User_Name_Must_Be_Email( + string username, + string email, + bool userNameIsEmailEnabled, + bool shouldSucceed) + { + var securitySettings = new SecuritySettings { UsernameIsEmail = userNameIsEmailEnabled }; + var userService = CreateUserService(securitySettings); + + var userGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + var inviteModel = new UserInviteModel + { + UserName = username, + Email = email, + Name = "Test Mc. Gee", + UserGroups = new HashSet { userGroup! } + }; + + var result = await userService.InviteAsync(Constants.Security.SuperUserKey, inviteModel); + + if (shouldSucceed is false) + { + Assert.IsFalse(result.Success); + Assert.AreEqual(UserOperationStatus.UserNameIsNotEmail, result.Status); + return; + } + + Assert.IsTrue(result.Success); + Assert.AreEqual(UserOperationStatus.Success, result.Status); + var invitedUser = result.Result.InvitedUser; + Assert.IsNotNull(invitedUser); + Assert.AreEqual(username, invitedUser.Username); + Assert.AreEqual(email, invitedUser.Email); + } + + [Test] + public async Task Cannot_Invite_User_With_Duplicate_Email() + { + var email = "test@test.com"; + var userGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + var initialUserCreateModel = new UserCreateModel + { + UserName = "Test1", + Email = email, + Name = "Test Mc. Gee", + UserGroups = new HashSet { userGroup! } + }; + + var userService = CreateUserService(new SecuritySettings { UsernameIsEmail = false }); + var result = await userService.CreateAsync(Constants.Security.SuperUserKey, initialUserCreateModel, true); + Assert.IsTrue(result.Success); + + var duplicateUserInviteModel = new UserInviteModel + { + UserName = "Test2", + Email = email, + Name = "Duplicate Mc. Gee", + UserGroups = new HashSet { userGroup! } + }; + + var secondResult = await userService.InviteAsync(Constants.Security.SuperUserKey, duplicateUserInviteModel); + Assert.IsFalse(secondResult.Success); + Assert.AreEqual(UserOperationStatus.DuplicateEmail, secondResult.Status); + } + + [Test] + public async Task Cannot_Invite_User_With_Duplicate_UserName() + { + var userName = "UserName"; + var userGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + var initialUserCreateModel = new UserCreateModel + { + UserName = userName, + Email = "test@email.com", + Name = "Test Mc. Gee", + UserGroups = new HashSet { userGroup! } + }; + + var userService = CreateUserService(new SecuritySettings { UsernameIsEmail = false }); + var result = await userService.CreateAsync(Constants.Security.SuperUserKey, initialUserCreateModel, true); + Assert.IsTrue(result.Success); + + var duplicateUserInviteModelModel = new UserInviteModel + { + UserName = userName, + Email = "another@email.com", + Name = "Duplicate Mc. Gee", + UserGroups = new HashSet { userGroup! } + }; + + var secondResult = await userService.InviteAsync(Constants.Security.SuperUserKey, duplicateUserInviteModelModel); + Assert.IsFalse(secondResult.Success); + Assert.AreEqual(UserOperationStatus.DuplicateUserName, secondResult.Status); + } + + [Test] + public async Task Cannot_Invite_User_Without_User_Group() + { + UserInviteModel userInviteModel = new UserInviteModel + { + UserName = "NoUser@Group.com", + Email = "NoUser@Group.com", + Name = "NoUser@Group.com", + }; + + IUserService userService = CreateUserService(); + + var result = await userService.InviteAsync(Constants.Security.SuperUserKey, userInviteModel); + + Assert.IsFalse(result.Success); + Assert.AreEqual(UserOperationStatus.NoUserGroup, result.Status); + } + + [Test] + public async Task Performing_User_Must_Exist_When_Inviting() + { + IUserService userService = CreateUserService(); + + var result = await userService.InviteAsync(Guid.Empty, new UserInviteModel()); + + Assert.IsFalse(result.Success); + Assert.AreEqual(UserOperationStatus.MissingUser, result.Status); + } + + [Test] + public async Task Invited_Users_Has_Invited_state() + { + var userGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + UserInviteModel userInviteModel = new UserInviteModel + { + UserName = "some@email.com", + Email = "some@email.com", + Name = "Bob", + UserGroups = new HashSet {userGroup!}, + }; + + IUserService userService = CreateUserService(); + var result = await userService.InviteAsync(Constants.Security.SuperUserKey, userInviteModel); + Assert.IsTrue(result.Success); + + var invitedUser = await userService.GetAsync(result.Result.InvitedUser!.Key); + Assert.IsNotNull(invitedUser); + Assert.AreEqual(UserState.Invited, invitedUser.UserState); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.PartialUpdates.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.PartialUpdates.cs new file mode 100644 index 0000000000..8645a9273e --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.PartialUpdates.cs @@ -0,0 +1,107 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class UserServiceCrudTests +{ + [Test] + public async Task Can_Enable_User() + { + var editorUserGroup = await UserGroupService.GetAsync(Constants.Security.EditorGroupAlias); + + var userCreateModel = new UserCreateModel + { + UserName = "test@email.com", + Email = "test@email.com", + Name = "Test User", + UserGroups = new HashSet { editorUserGroup } + }; + + var userService = CreateUserService(); + var createAttempt = await userService.CreateAsync(Constants.Security.SuperUserKey, userCreateModel, false); + + Assert.IsTrue(createAttempt.Success); + var user = createAttempt.Result.CreatedUser; + Assert.AreEqual(UserState.Disabled, user!.UserState); + + var enableStatus = await userService.EnableAsync(Constants.Security.SuperUserKey, new HashSet { user.Key }); + Assert.AreEqual(UserOperationStatus.Success, enableStatus); + + var updatedUser = await userService.GetAsync(user.Key); + // The user has not logged in, so after enabling the user, the user state should be inactive + Assert.AreEqual(UserState.Inactive, updatedUser!.UserState); + } + + [Test] + public async Task Can_Disable_User() + { + var editorUserGroup = await UserGroupService.GetAsync(Constants.Security.EditorGroupAlias); + + var userCreateModel = new UserCreateModel + { + UserName = "test@email.com", + Email = "test@email.com", + Name = "Test User", + UserGroups = new HashSet { editorUserGroup } + }; + + var userService = CreateUserService(); + var createAttempt = await userService.CreateAsync(Constants.Security.SuperUserKey, userCreateModel, true); + + Assert.IsTrue(createAttempt.Success); + var user = createAttempt.Result.CreatedUser; + Assert.AreEqual(UserState.Inactive, user!.UserState); + + var disableStatus = await userService.DisableAsync(Constants.Security.SuperUserKey, new HashSet { user.Key }); + Assert.AreEqual(UserOperationStatus.Success, disableStatus); + } + + [Test] + public async Task User_Cannot_Disable_Self() + { + var adminUserGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + + var userCreateModel = new UserCreateModel + { + UserName = "test@email.com", + Email = "test@email.com", + Name = "Test User", + UserGroups = new HashSet { adminUserGroup } + }; + + var userService = CreateUserService(); + var createAttempt = await userService.CreateAsync(Constants.Security.SuperUserKey, userCreateModel, true); + Assert.IsTrue(createAttempt.Success); + + var createdUser = createAttempt.Result.CreatedUser; + var disableStatus = await userService.DisableAsync(createdUser!.Key, new HashSet{ createdUser.Key }); + Assert.AreEqual(UserOperationStatus.CannotDisableSelf, disableStatus); + } + + [Test] + public async Task Cannot_Disable_Invited_User() + { + var editorUserGroup = await UserGroupService.GetAsync(Constants.Security.EditorGroupAlias); + + var userInviteModel = new UserInviteModel + { + UserName = "test@email.com", + Email = "test@email.com", + Name = "Test User", + UserGroups = new HashSet { editorUserGroup } + }; + + var userService = CreateUserService(); + + var userInviteAttempt = await userService.InviteAsync(Constants.Security.SuperUserKey, userInviteModel); + Assert.IsTrue(userInviteAttempt.Success); + + var invitedUser = userInviteAttempt.Result.InvitedUser; + var disableStatus = await userService.DisableAsync(Constants.Security.SuperUserKey, new HashSet { invitedUser!.Key }); + Assert.AreEqual(UserOperationStatus.CannotDisableInvitedUser, disableStatus); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Update.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Update.cs new file mode 100644 index 0000000000..9e24581169 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Update.cs @@ -0,0 +1,189 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +public partial class UserServiceCrudTests +{ + private SortedSet GetKeysFromIds(IEnumerable? ids, UmbracoObjectTypes type) + { + IEnumerable? keys = ids? + .Select(x => EntityService.GetKey(x, type)) + .Where(x => x.Success) + .Select(x => x.Result); + + return keys is null + ? new SortedSet() + : new SortedSet(keys); + } + + private async Task MapUserToUpdateModel(IUser user) + { + var groups = await UserGroupService.GetAsync(user.Groups.Select(x => x.Id).ToArray()); + return new UserUpdateModel + { + ExistingUser = user, + Email = user.Email, + Name = user.Name, + UserName = user.Username, + Language = user.Language, + ContentStartNodeKeys = GetKeysFromIds(user.StartContentIds, UmbracoObjectTypes.Document), + MediaStartNodeKeys = GetKeysFromIds(user.StartMediaIds, UmbracoObjectTypes.Media), + UserGroups = groups, + }; + } + + private async Task<(UserUpdateModel updateModel, IUser createdUser)> CreateUserForUpdate( + IUserService userService, + string email = "test@test.com", + string userName = "test@test.com") + { + var userGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + var createUserModel = new UserCreateModel + { + Email = email, + UserName = userName, + Name = "Test Mc. Gee", + UserGroups = new HashSet { userGroup! } + }; + + var createExistingUser = await userService.CreateAsync(Constants.Security.SuperUserKey, createUserModel, true); + + Assert.IsTrue(createExistingUser.Success); + Assert.IsNotNull(createExistingUser.Result.CreatedUser); + + var savedUser = createExistingUser.Result.CreatedUser; + var updateModel = await MapUserToUpdateModel(savedUser); + return (updateModel, createExistingUser.Result.CreatedUser); + } + + [Test] + [TestCase(true, false)] + [TestCase(false, true)] + public async Task Cannot_Change_Email_When_Deny_Local_Login_Is_True(bool denyLocalLogin, bool shouldSucceed) + { + var localLoginSetting = new Mock(); + localLoginSetting.Setup(x => x.HasDenyLocalLogin()).Returns(denyLocalLogin); + + var userService = CreateUserService( + localLoginSettingProvider: localLoginSetting.Object, + securitySettings: new SecuritySettings { UsernameIsEmail = false }); + + var (updateModel, _) = await CreateUserForUpdate(userService); + + var updatedEmail = "updated@email.com"; + updateModel.Email = updatedEmail; + + var result = await userService.UpdateAsync(Constants.Security.SuperUserKey, updateModel); + + if (shouldSucceed is false) + { + Assert.IsFalse(result.Success); + Assert.AreEqual(UserOperationStatus.EmailCannotBeChanged, result.Status); + return; + } + + Assert.IsTrue(result.Success); + // We'll get the user again to ensure that the changes has been persisted + var updatedUser = await userService.GetAsync(result.Result.Key); + Assert.IsNotNull(updatedUser); + Assert.AreEqual(updatedEmail, updatedUser.Email); + } + + [Test] + [TestCase("same@email.com", "same@email.com", true)] + [TestCase("different@email.com", "another@email.com", false)] + [TestCase("notAnEmail", "some@email.com", false)] + public async Task UserName_And_Email_Must_Be_same_When_UserNameIsEmail_Equals_True(string userName, string email, bool shouldSucceed) + { + var userService = CreateUserService(securitySettings: new SecuritySettings { UsernameIsEmail = true }); + + var (updateModel, createdUser) = await CreateUserForUpdate(userService); + + updateModel.UserName = userName; + updateModel.Email = email; + + var result = await userService.UpdateAsync(Constants.Security.SuperUserKey, updateModel); + + if (shouldSucceed is false) + { + Assert.IsFalse(result.Success); + Assert.AreEqual(UserOperationStatus.UserNameIsNotEmail, result.Status); + return; + } + + Assert.IsTrue(result.Success); + var updatedUser = await userService.GetAsync(createdUser.Key); + Assert.IsNotNull(updatedUser); + Assert.AreEqual(userName, updatedUser.Username); + Assert.AreEqual(email, updatedUser.Email); + } + + [Test] + public async Task Cannot_Change_Email_To_Duplicate_Email_On_Update() + { + var userService = CreateUserService(); + + var userGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + var email = "thiswillbe@duplicate.com"; + var createModel = new UserCreateModel + { + Email = email, + UserName = email, + Name = "Test Mc. Gee", + UserGroups = new HashSet { userGroup! } + }; + + var createExisting = await userService.CreateAsync(Constants.Security.SuperUserKey, createModel, true); + + Assert.IsTrue(createExisting.Success); + + var (updateModel, _) = await CreateUserForUpdate(userService); + + updateModel.Email = email; + updateModel.UserName = email; + + var updateAttempt = await userService.UpdateAsync(Constants.Security.SuperUserKey, updateModel); + + Assert.IsFalse(updateAttempt.Success); + Assert.AreEqual(UserOperationStatus.DuplicateEmail, updateAttempt.Status); + } + + [Test] + [TestCase("TestUser", "test@user.com", "TestUser", "another@email.com")] + [TestCase("test@email.com", "test@email.com", "test@email.com", "different@email.com")] + [TestCase("SomeName", "test@email.com", "test@email.com", "different@email.com")] + public async Task Cannot_Change_User_Name_To_Duplicate_UserName(string existingUserName, string existingEmail, string updateUserName, string updateEmail) + { + // We also ensure that your username cannot be the same as another users email. + var userService = CreateUserService(new SecuritySettings { UsernameIsEmail = false }); + var userGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + + var createModel = new UserCreateModel + { + Email = existingEmail, + UserName = existingUserName, + Name = "Test Mc. Gee", + UserGroups = new HashSet { userGroup! } + }; + + var createExisting = await userService.CreateAsync(Constants.Security.SuperUserKey, createModel, true); + Assert.IsTrue(createExisting.Success); + + var (updateModel, _) = await CreateUserForUpdate(userService); + + updateModel.Email = updateEmail; + updateModel.UserName = updateUserName; + + var updateAttempt = await userService.UpdateAsync(Constants.Security.SuperUserKey, updateModel); + Assert.IsFalse(updateAttempt.Success); + Assert.AreEqual(UserOperationStatus.DuplicateUserName, updateAttempt.Status); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.cs new file mode 100644 index 0000000000..e7e7b49eac --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.cs @@ -0,0 +1,87 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Editors; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public partial class UserServiceCrudTests : UmbracoIntegrationTest +{ + private IUserGroupService UserGroupService => GetRequiredService(); + + private IEntityService EntityService => GetRequiredService(); + + protected override void ConfigureTestServices(IServiceCollection services) + { + base.ConfigureTestServices(services); + services.RemoveAll(); + services.AddScoped(); + } + + // This is resolved from the service scope, so we have to add it to the service collection. + private class TestUriProvider : IInviteUriProvider + { + public Task> CreateInviteUriAsync(IUser invitee) + { + var fakePath = "https://localhost:44331/fakeInviteEndpoint"; + Attempt attempt = Attempt.Succeed(UserOperationStatus.Success, new Uri(fakePath)); + return Task.FromResult(attempt); + } + } + + private IUserService CreateUserService( + SecuritySettings? securitySettings = null, + IUserInviteSender? inviteSender = null, + ILocalLoginSettingProvider? localLoginSettingProvider = null) + { + securitySettings ??= GetRequiredService>().Value; + IOptions securityOptions = Options.Create(securitySettings); + + if (inviteSender is null) + { + var senderMock = new Mock(); + senderMock.Setup(x => x.CanSendInvites()).Returns(true); + inviteSender = senderMock.Object; + } + + localLoginSettingProvider ??= GetRequiredService(); + + return new UserService( + GetRequiredService(), + GetRequiredService(), + GetRequiredService(), + GetRequiredService(), + GetRequiredService(), + GetRequiredService>(), + securityOptions, + GetRequiredService(), + GetRequiredService(), + GetRequiredService(), + localLoginSettingProvider, + inviteSender, + GetRequiredService(), + GetRequiredService(), + GetRequiredService(), + GetRequiredService>()); + } + + +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Security/BackOfficeUserStoreTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Security/BackOfficeUserStoreTests.cs index dca11b6f6f..cc28332ea6 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Security/BackOfficeUserStoreTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Security/BackOfficeUserStoreTests.cs @@ -1,9 +1,13 @@ using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Tests.Common; @@ -16,24 +20,42 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Security; [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] public class BackOfficeUserStoreTests : UmbracoIntegrationTest { - private IUserService UserService => GetRequiredService(); private IEntityService EntityService => GetRequiredService(); + private IExternalLoginWithKeyService ExternalLoginService => GetRequiredService(); + private IUmbracoMapper UmbracoMapper => GetRequiredService(); + private ILocalizedTextService TextService => GetRequiredService(); + private ITwoFactorLoginService TwoFactorLoginService => GetRequiredService(); + private IUserGroupService UserGroupService => GetRequiredService(); + + private IUserRepository UserRepository => GetRequiredService(); + + private IRuntimeState RuntimeState => GetRequiredService(); + + private IEventMessagesFactory EventMessagesFactory => GetRequiredService(); + + private ILogger Logger = NullLogger.Instance; + + private BackOfficeUserStore GetUserStore() => new( ScopeProvider, - UserService, EntityService, ExternalLoginService, new TestOptionsSnapshot(GlobalSettings), UmbracoMapper, new BackOfficeErrorDescriber(TextService), AppCaches, - TwoFactorLoginService + TwoFactorLoginService, + UserGroupService, + UserRepository, + RuntimeState, + EventMessagesFactory, + Logger ); [Test] diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/UserIdKeyResolverTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/UserIdKeyResolverTests.cs new file mode 100644 index 0000000000..9690b77a7c --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/UserIdKeyResolverTests.cs @@ -0,0 +1,76 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class UserIdKeyResolverTests : UmbracoIntegrationTest +{ + private IUserService UserService => GetRequiredService(); + + private IUserGroupService UserGroupService => GetRequiredService(); + + private IUserIdKeyResolver UserIdKeyResolver => GetRequiredService(); + + [Test] + public async Task Can_Resolve_Id_To_Key() + { + var userGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + var userCreateModel = new UserCreateModel + { + UserName = "test@test.com", + Email = "test@test.com", + Name = "Test Mc. Gee", + UserGroups = new HashSet { userGroup! } + }; + + var creationResult = await UserService.CreateAsync(Constants.Security.SuperUserKey, userCreateModel); + Assert.IsTrue(creationResult.Success); + var createdUser = creationResult.Result.CreatedUser; + Assert.IsNotNull(createdUser); + + var resolvedKey = await UserIdKeyResolver.GetAsync(createdUser.Id); + Assert.AreEqual(createdUser.Key, resolvedKey); + } + + [Test] + public async Task Can_Resolve_Key_To_Id() + { + var userGroup = await UserGroupService.GetAsync(Constants.Security.AdminGroupAlias); + var userCreateModel = new UserCreateModel + { + UserName = "test@test.com", + Email = "test@test.com", + Name = "Test Mc. Gee", + UserGroups = new HashSet { userGroup! } + }; + + var creationResult = await UserService.CreateAsync(Constants.Security.SuperUserKey, userCreateModel); + Assert.IsTrue(creationResult.Success); + var createdUser = creationResult.Result.CreatedUser; + Assert.IsNotNull(createdUser); + + var resolvedId = await UserIdKeyResolver.GetAsync(createdUser.Key); + Assert.AreEqual(createdUser.Id, resolvedId); + } + + [Test] + public async Task Unknown_Key_Resolves_To_Null() + { + var resolvedId = await UserIdKeyResolver.GetAsync(Guid.NewGuid()); + Assert.IsNull(resolvedId); + } + + [Test] + public async Task Unknown_Id_Resolves_To_Null() + { + var resolvedKey = await UserIdKeyResolver.GetAsync(1234567890); + Assert.IsNull(resolvedKey); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 293ba6eed9..2e500a7a85 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -61,5 +61,26 @@ ContentEditingServiceTests.cs + + UserServiceCrudTests.cs + + + UserServiceCrudTests.cs + + + UserServiceCrudTests.cs + + + UserServiceCrudTests.cs + + + UserServiceCrudTests.cs + + + UserServiceCrudTests.cs + + + UserServiceCrudTests.cs +