From 393d178b58da44effda3a84efaa39d2a9132908b Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Thu, 29 Feb 2024 10:40:48 +0100 Subject: [PATCH] User endpoint additions and corrections (#15773) * Make create user endpoint work with the supplied id Return 201 instead of 200 with correct resource identifier * Add ResetPassword endpoint * Bring changepassword route inline with other resource actions * Fixed User endpoints not advertising all their possible response codes/ models Fixed certain endpoints not authorizing targeted user(s) versus the admin needs admin authorization requirement Fixed a user not found response bug for the update flow Fix spacing * Fixed CurrentUser endpoints not advertising all their possible response codes/ models Fix incorrect responseStatus in UserService.GetPermissionsAsync * Update OpenApi definition Fix smal model oversights in previous commits * Update incorrect Response type * Check for duplicate id's in user create validation * Remove unnecasary returnmodel from changepassword Renamed the model to it's remaining usage * rename bad constructor parameter * Renamed method parameters for better readability and usage * Fixed wrong userkey being passed down because of (refactored) bad naming Technically doesn't change anything as the two id's should be the same in this case (reset with token is always for self) * Fixed resetpassword bug * Update openapi * Update src/Umbraco.Core/Services/UserService.cs Co-authored-by: Kenn Jacobsen * Remove old password from change user password request model Only makes sense when doing it for the logged in user => current endpoint --------- Co-authored-by: Sven Geusens Co-authored-by: Kenn Jacobsen --- .../User/ChangePasswordUserController.cs | 33 +- .../User/ClearAvatarUserController.cs | 4 + .../CreateInitialPasswordUserController.cs | 1 + .../Controllers/User/CreateUserController.cs | 5 +- .../ChangePasswordCurrentUserController.cs | 16 +- ...ocumentPermissionsCurrentUserController.cs | 1 + ...etMediaPermissionsCurrentUserController.cs | 3 +- .../GetPermissionsCurrentUserController.cs | 2 +- .../Current/SetAvatarCurrentUserController.cs | 1 + .../Controllers/User/DeleteUserController.cs | 4 + .../Controllers/User/DisableUserController.cs | 1 + .../Controllers/User/EnableUserController.cs | 1 + .../Controllers/User/GetAllUserController.cs | 1 + .../Controllers/User/InviteUsersController.cs | 2 + .../User/ResendInviteUserController.cs | 1 + .../User/ResetPasswordUserController.cs | 61 + .../User/SetAvatarUserController.cs | 1 + .../Controllers/User/UpdateUserController.cs | 24 +- .../User/UserOrCurrentUserControllerBase.cs | 8 +- .../User/VerifyInviteUserController.cs | 1 + .../Factories/UserPresentationFactory.cs | 1 + .../Users/UsersViewModelsMapDefinition.cs | 4 +- src/Umbraco.Cms.Api.Management/OpenApi.json | 1165 ++++++++++++++--- .../User/ChangePasswordUserRequestModel.cs | 5 - .../ViewModels/User/CreateUserRequestModel.cs | 2 +- .../ChangePasswordCurrentUserRequestModel.cs | 9 + ...l.cs => ResetPasswordUserResponseModel.cs} | 2 +- src/Umbraco.Core/Models/UserCreateModel.cs | 2 + .../Security/ICoreBackOfficeUserManager.cs | 5 + src/Umbraco.Core/Services/IUserService.cs | 26 +- .../OperationStatus/UserOperationStatus.cs | 6 +- .../Services/TwoFactorLoginServiceBase.cs | 2 +- src/Umbraco.Core/Services/UserService.cs | 90 +- .../Security/BackOfficeIdentityUser.cs | 9 +- .../Security/BackOfficeUserStore.cs | 2 + .../Security/BackOfficeUserManager.cs | 6 +- 36 files changed, 1263 insertions(+), 244 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/User/ResetPasswordUserController.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/User/Current/ChangePasswordCurrentUserRequestModel.cs rename src/Umbraco.Cms.Api.Management/ViewModels/User/{ChangePasswordUserResponseModel.cs => ResetPasswordUserResponseModel.cs} (70%) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/ChangePasswordUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/ChangePasswordUserController.cs index fa7c99f935..9d6619c00a 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/ChangePasswordUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/ChangePasswordUserController.cs @@ -1,13 +1,17 @@ using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Security.Authorization.User; using Umbraco.Cms.Api.Management.ViewModels.User; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Mapping; 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; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Controllers.User; @@ -15,35 +19,46 @@ namespace Umbraco.Cms.Api.Management.Controllers.User; public class ChangePasswordUserController : UserControllerBase { private readonly IUserService _userService; - private readonly IUmbracoMapper _mapper; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IAuthorizationService _authorizationService; public ChangePasswordUserController( IUserService userService, - IUmbracoMapper mapper, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IAuthorizationService authorizationService) { _userService = userService; - _mapper = mapper; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _authorizationService = authorizationService; } - [HttpPost("change-password/{id:guid}")] + [HttpPost("{id:guid}/change-password")] [MapToApiVersion("1.0")] - [ProducesErrorResponseType(typeof(ChangePasswordUserResponseModel))] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] public async Task ChangePassword(Guid id, ChangePasswordUserRequestModel model) { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + UserPermissionResource.WithKeys(id), + AuthorizationPolicies.AdminUserEditsRequireAdmin); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + var passwordModel = new ChangeUserPasswordModel { NewPassword = model.NewPassword, - OldPassword = model.OldPassword, UserKey = id, }; Attempt response = await _userService.ChangePasswordAsync(CurrentUserKey(_backOfficeSecurityAccessor), passwordModel); return response.Success - ? Ok(_mapper.Map(response.Result)) + ? Ok() : UserOperationStatusResult(response.Status, response.Result); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/ClearAvatarUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/ClearAvatarUserController.cs index 95b8bbd58f..d3f439adbe 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/ClearAvatarUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/ClearAvatarUserController.cs @@ -1,5 +1,6 @@ using Asp.Versioning; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Security.Authorization.User; using Umbraco.Cms.Core.Services; @@ -23,6 +24,9 @@ public class ClearAvatarUserController : UserControllerBase [MapToApiVersion("1.0")] [HttpDelete("avatar/{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] public async Task ClearAvatar(Guid id) { AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/CreateInitialPasswordUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/CreateInitialPasswordUserController.cs index 155b712fb7..8753c327b8 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/CreateInitialPasswordUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/CreateInitialPasswordUserController.cs @@ -24,6 +24,7 @@ public class CreateInitialPasswordUserController : UserControllerBase [MapToApiVersion("1.0")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] public async Task CreateInitialPassword(CreateInitialPasswordUserRequestModel model) { Attempt response = await _userService.CreateInitialPasswordAsync(model.User.Id, model.Token, model.Password); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/CreateUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/CreateUserController.cs index 1f540f664b..285dc041bc 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/CreateUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/CreateUserController.cs @@ -35,8 +35,9 @@ public class CreateUserController : UserControllerBase [HttpPost] [MapToApiVersion("1.0")] - [ProducesResponseType(typeof(CreateUserResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task Create(CreateUserRequestModel model) { UserCreateModel createModel = await _presentationFactory.CreateCreationModelAsync(model); @@ -44,7 +45,7 @@ public class CreateUserController : UserControllerBase Attempt result = await _userService.CreateAsync(CurrentUserKey(_backOfficeSecurityAccessor), createModel, true); return result.Success - ? Ok(_mapper.Map(result.Result)) + ? CreatedAtId(controller => nameof(controller.ByKey), result.Result.CreatedUser!.Key) : UserOperationStatusResult(result.Status, result.Result); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/ChangePasswordCurrentUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/ChangePasswordCurrentUserController.cs index 5a29e278e3..f3ee849457 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/ChangePasswordCurrentUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/ChangePasswordCurrentUserController.cs @@ -1,8 +1,8 @@ using Asp.Versioning; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Api.Management.ViewModels.User; +using Umbraco.Cms.Api.Management.ViewModels.User.Current; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; @@ -16,22 +16,20 @@ public class ChangePasswordCurrentUserController : CurrentUserControllerBase { private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; private readonly IUserService _userService; - private readonly IUmbracoMapper _mapper; public ChangePasswordCurrentUserController( IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - IUserService userService, - IUmbracoMapper mapper) + IUserService userService) { _backOfficeSecurityAccessor = backOfficeSecurityAccessor; _userService = userService; - _mapper = mapper; } [HttpPost("change-password")] [MapToApiVersion("1.0")] - [ProducesErrorResponseType(typeof(ChangePasswordUserResponseModel))] - public async Task ChangePassword(ChangePasswordUserRequestModel model) + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task ChangePassword(ChangePasswordCurrentUserRequestModel model) { Guid userKey = CurrentUserKey(_backOfficeSecurityAccessor); @@ -45,7 +43,7 @@ public class ChangePasswordCurrentUserController : CurrentUserControllerBase Attempt response = await _userService.ChangePasswordAsync(userKey, changeModel); return response.Success - ? Ok(_mapper.Map(response.Result)) + ? Ok() : UserOperationStatusResult(response.Status, response.Result); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetDocumentPermissionsCurrentUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetDocumentPermissionsCurrentUserController.cs index 668f825534..bbc53306a5 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetDocumentPermissionsCurrentUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetDocumentPermissionsCurrentUserController.cs @@ -31,6 +31,7 @@ public class GetDocumentPermissionsCurrentUserController : CurrentUserController [MapToApiVersion("1.0")] [HttpGet("permissions/document")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetPermissions([FromQuery(Name = "id")] HashSet ids) { Attempt, UserOperationStatus> permissionsAttempt = await _userService.GetDocumentPermissionsAsync(CurrentUserKey(_backOfficeSecurityAccessor), ids); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetMediaPermissionsCurrentUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetMediaPermissionsCurrentUserController.cs index 558a21cfb3..d3ee9bf5dd 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetMediaPermissionsCurrentUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetMediaPermissionsCurrentUserController.cs @@ -30,7 +30,8 @@ public class GetMediaPermissionsCurrentUserController : CurrentUserControllerBas [MapToApiVersion("1.0")] [HttpGet("permissions/media")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(UserPermissionsResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetPermissions([FromQuery(Name = "id")] HashSet ids) { Attempt, UserOperationStatus> permissionsAttempt = await _userService.GetMediaPermissionsAsync(CurrentUserKey(_backOfficeSecurityAccessor), ids); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetPermissionsCurrentUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetPermissionsCurrentUserController.cs index ed34029ccb..2c664b5b5f 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetPermissionsCurrentUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/GetPermissionsCurrentUserController.cs @@ -30,7 +30,7 @@ public class GetPermissionsCurrentUserController : CurrentUserControllerBase [MapToApiVersion("1.0")] [HttpGet("permissions")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(UserPermissionsResponseModel), StatusCodes.Status200OK)] public async Task GetPermissions([FromQuery(Name = "id")] HashSet ids) { Attempt, UserOperationStatus> permissionsAttempt = await _userService.GetPermissionsAsync(CurrentUserKey(_backOfficeSecurityAccessor), ids.ToArray()); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/SetAvatarCurrentUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/SetAvatarCurrentUserController.cs index 2b764cc6b6..531f1eb0f9 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/Current/SetAvatarCurrentUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/Current/SetAvatarCurrentUserController.cs @@ -32,6 +32,7 @@ public class SetAvatarCurrentUserController : CurrentUserControllerBase [MapToApiVersion("1.0")] [HttpPost("avatar")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] public async Task SetAvatar(SetAvatarRequestModel model) { Guid userKey = CurrentUserKey(_backOfficeSecurityAccessor); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/DeleteUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/DeleteUserController.cs index 558267f9c9..b89dea3531 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/DeleteUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/DeleteUserController.cs @@ -1,5 +1,6 @@ using Asp.Versioning; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Security.Authorization.User; using Umbraco.Cms.Core.Security; @@ -29,6 +30,9 @@ public class DeleteUserController : UserControllerBase [MapToApiVersion("1.0")] [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] public async Task DeleteUser(Guid id) { AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/DisableUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/DisableUserController.cs index 4a820202e9..485170b47b 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/DisableUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/DisableUserController.cs @@ -33,6 +33,7 @@ public class DisableUserController : UserControllerBase [MapToApiVersion("1.0")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task DisableUsers(DisableUserRequestModel model) { AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/EnableUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/EnableUserController.cs index 1f62b57ec3..3ed051d3c6 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/EnableUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/EnableUserController.cs @@ -33,6 +33,7 @@ public class EnableUserController : UserControllerBase [MapToApiVersion("1.0")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task EnableUsers(EnableUserRequestModel model) { AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/GetAllUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/GetAllUserController.cs index 703f680613..7c7be52a14 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/GetAllUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/GetAllUserController.cs @@ -34,6 +34,7 @@ public class GetAllUserController : UserControllerBase [HttpGet] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetAll(int skip = 0, int take = 100) { Attempt?, UserOperationStatus> attempt = await _userService.GetAllAsync(CurrentUserKey(_backOfficeSecurityAccessor), skip, take); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/InviteUsersController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/InviteUsersController.cs index 082be71063..572efc2efb 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/InviteUsersController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/InviteUsersController.cs @@ -33,6 +33,8 @@ public class InviteUserController : UserControllerBase [HttpPost("invite")] [MapToApiVersion("1.0")] [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task Invite(InviteUserRequestModel model) { UserInviteModel userInvite = await _userPresentationFactory.CreateInviteModelAsync(model); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/ResendInviteUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/ResendInviteUserController.cs index 043a456ad0..8209b75227 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/ResendInviteUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/ResendInviteUserController.cs @@ -30,6 +30,7 @@ public class ResendInviteUserController : UserControllerBase [MapToApiVersion("1.0")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task ResendInvite(ResendInviteUserRequestModel model) { UserResendInviteModel resendInviteModel = await _userPresentationFactory.CreateResendInviteModelAsync(model); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/ResetPasswordUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/ResetPasswordUserController.cs new file mode 100644 index 0000000000..df1f6f8480 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/ResetPasswordUserController.cs @@ -0,0 +1,61 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Security.Authorization.User; +using Umbraco.Cms.Api.Management.ViewModels.User; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.User; + +[ApiVersion("1.0")] +public class ResetPasswordUserController : UserControllerBase +{ + private readonly IUserService _userService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IUmbracoMapper _mapper; + private readonly IAuthorizationService _authorizationService; + + public ResetPasswordUserController( + IUserService userService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IUmbracoMapper mapper, + IAuthorizationService authorizationService) + { + _userService = userService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _mapper = mapper; + _authorizationService = authorizationService; + } + + [HttpPost("{id:guid}/reset-password")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(ResetPasswordUserResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task ResetPassword(Guid id) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + UserPermissionResource.WithKeys(id), + AuthorizationPolicies.AdminUserEditsRequireAdmin); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + Attempt response = await _userService.ResetPasswordAsync(CurrentUserKey(_backOfficeSecurityAccessor), id); + + return response.Success + ? Ok(_mapper.Map(response.Result)) + : UserOperationStatusResult(response.Status, response.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/SetAvatarUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/SetAvatarUserController.cs index 58972a5c7e..4ec1aa91ce 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/SetAvatarUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/SetAvatarUserController.cs @@ -27,6 +27,7 @@ public class SetAvatarUserController : UserControllerBase [HttpPost("avatar/{id:guid}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task SetAvatar(Guid id, SetAvatarRequestModel model) { AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/UpdateUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/UpdateUserController.cs index a0daf50234..a4d3761cb1 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/UpdateUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/UpdateUserController.cs @@ -1,6 +1,9 @@ using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.Security.Authorization.User; using Umbraco.Cms.Api.Management.ViewModels.User; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; @@ -8,6 +11,8 @@ using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Controllers.User; @@ -17,20 +22,37 @@ public class UpdateUserController : UserControllerBase private readonly IUserService _userService; private readonly IUserPresentationFactory _userPresentationFactory; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IAuthorizationService _authorizationService; public UpdateUserController( IUserService userService, - IUserPresentationFactory userPresentationFactory, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + IUserPresentationFactory userPresentationFactory, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IAuthorizationService authorizationService) { _userService = userService; _userPresentationFactory = userPresentationFactory; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _authorizationService = authorizationService; } [HttpPut("{id:guid}")] [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task Update(Guid id, UpdateUserRequestModel model) { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + UserPermissionResource.WithKeys(id), + AuthorizationPolicies.AdminUserEditsRequireAdmin); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + // We have to use an 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(id, model); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs index dd3f116973..90ba76eec1 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/UserOrCurrentUserControllerBase.cs @@ -61,9 +61,9 @@ public abstract class UserOrCurrentUserControllerBase : ManagementApiControllerB .WithTitle("Cannot delete") .WithDetail("A user cannot delete itself.") .Build()), - UserOperationStatus.OldPasswordRequired => BadRequest(problemDetailsBuilder + UserOperationStatus.SelfOldPasswordRequired => BadRequest(problemDetailsBuilder .WithTitle("Old password required") - .WithDetail("The old password is required to change the password of the specified user.") + .WithDetail("The old password is required to change your own password.") .Build()), UserOperationStatus.InvalidAvatar => BadRequest(problemDetailsBuilder .WithTitle("Invalid avatar") @@ -117,6 +117,10 @@ public abstract class UserOrCurrentUserControllerBase : ManagementApiControllerB .WithTitle("Invalid user state") .WithDetail("The target user is not in the invite state.") .Build()), + UserOperationStatus.SelfPasswordResetNotAllowed => BadRequest(problemDetailsBuilder + .WithTitle("Self password reset not allowed") + .WithDetail("It is not allowed to reset the password for the account you are logged in to.") + .Build()), UserOperationStatus.Forbidden => Forbidden(), _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder .WithTitle("Unknown user operation status.") diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/VerifyInviteUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/VerifyInviteUserController.cs index e1ba251974..1c193e1780 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/VerifyInviteUserController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/VerifyInviteUserController.cs @@ -23,6 +23,7 @@ public class VerifyInviteUserController : UserControllerBase [MapToApiVersion("1.0")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] public async Task Invite(VerifyInviteUserRequestModel model) { Attempt result = await _userService.VerifyInviteAsync(model.User.Id, model.Token); diff --git a/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs index 80cc6a3372..d95488c728 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs @@ -78,6 +78,7 @@ public class UserPresentationFactory : IUserPresentationFactory { var createModel = new UserCreateModel { + Id = requestModel.Id, Email = requestModel.Email, Name = requestModel.Name, UserName = requestModel.UserName, diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Users/UsersViewModelsMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Users/UsersViewModelsMapDefinition.cs index 5841473881..c33d9666a0 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Users/UsersViewModelsMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Users/UsersViewModelsMapDefinition.cs @@ -11,7 +11,7 @@ public class UsersViewModelsMapDefinition : IMapDefinition { public void DefineMaps(IUmbracoMapper mapper) { - mapper.Define((_, _) => new ChangePasswordUserResponseModel(), Map); + mapper.Define((_, _) => new ResetPasswordUserResponseModel(), Map); mapper.Define((_, _) => new CreateUserResponseModel { User = new() }, Map); mapper.Define((_, _) => new LinkedLoginViewModel { ProviderKey = string.Empty, ProviderName = string.Empty }, Map); } @@ -34,7 +34,7 @@ public class UsersViewModelsMapDefinition : IMapDefinition } // Umbraco.Code.MapAll - private void Map(PasswordChangedModel source, ChangePasswordUserResponseModel target, MapperContext context) + private void Map(PasswordChangedModel source, ResetPasswordUserResponseModel target, MapperContext context) { target.ResetPassword = source.ResetPassword; } diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index fa5c523723..0f7eef1fb8 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -27589,8 +27589,38 @@ } }, "responses": { - "200": { - "description": "Success", + "201": { + "description": "Created", + "headers": { + "Umb-Generated-Resource": { + "description": "Identifier of the newly created resource", + "schema": { + "type": "string", + "description": "Identifier of the newly created resource" + } + }, + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + }, + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -27606,35 +27636,23 @@ "content": { "application/json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateUserResponseModel" - } - ] + "$ref": "#/components/schemas/ProblemDetails" } }, "text/json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateUserResponseModel" - } - ] + "$ref": "#/components/schemas/ProblemDetails" } }, "text/plain": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateUserResponseModel" - } - ] + "$ref": "#/components/schemas/ProblemDetails" } } } }, - "400": { - "description": "Bad Request", + "404": { + "description": "Not Found", "headers": { "Umb-Notifications": { "description": "The list of notifications produced during the request.", @@ -27830,6 +27848,26 @@ } } }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" } @@ -27956,6 +27994,70 @@ } } }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -28044,8 +28146,87 @@ } } }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user do not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } } }, "security": [ @@ -28279,6 +28460,321 @@ ] } }, + "/umbraco/management/api/v1/user/{id}/change-password": { + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserByIdChangePassword", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ChangePasswordUserRequestModel" + }, + { + "$ref": "#/components/schemas/ChangePasswordCurrentUserRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ChangePasswordUserRequestModel" + }, + { + "$ref": "#/components/schemas/ChangePasswordCurrentUserRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ChangePasswordUserRequestModel" + }, + { + "$ref": "#/components/schemas/ChangePasswordCurrentUserRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user do not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1/user/{id}/reset-password": { + "post": { + "tags": [ + "User" + ], + "operationId": "PostUserByIdResetPassword", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResetPasswordUserResponseModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResetPasswordUserResponseModel" + } + ] + } + }, + "text/plain": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResetPasswordUserResponseModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user do not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/user/avatar/{id}": { "delete": { "tags": [ @@ -28312,6 +28808,70 @@ } } }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -28432,6 +28992,38 @@ } } }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -28458,81 +29050,6 @@ ] } }, - "/umbraco/management/api/v1/user/change-password/{id}": { - "post": { - "tags": [ - "User" - ], - "operationId": "PostUserChangePasswordById", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ChangePasswordUserRequestModel" - } - ] - } - }, - "text/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ChangePasswordUserRequestModel" - } - ] - } - }, - "application/*+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ChangePasswordUserRequestModel" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "Success", - "headers": { - "Umb-Notifications": { - "description": "The list of notifications produced during the request.", - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationHeaderModel" - }, - "nullable": true - } - } - } - }, - "401": { - "description": "The resource is protected and requires an authentication token" - } - }, - "security": [ - { - "Backoffice User": [ ] - } - ] - } - }, "/umbraco/management/api/v1/user/configuration": { "get": { "tags": [ @@ -29125,6 +29642,38 @@ } } }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -29163,7 +29712,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ChangePasswordUserRequestModel" + "$ref": "#/components/schemas/ChangePasswordCurrentUserRequestModel" } ] } @@ -29172,7 +29721,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ChangePasswordUserRequestModel" + "$ref": "#/components/schemas/ChangePasswordCurrentUserRequestModel" } ] } @@ -29181,7 +29730,7 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ChangePasswordUserRequestModel" + "$ref": "#/components/schemas/ChangePasswordCurrentUserRequestModel" } ] } @@ -29204,6 +29753,38 @@ } } }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" } @@ -29341,38 +29922,29 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/UserPermissionsResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/UserPermissionsResponseModel" + } + ] } }, "text/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/UserPermissionsResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/UserPermissionsResponseModel" + } + ] } }, "text/plain": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/UserPermissionsResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/UserPermissionsResponseModel" + } + ] } } } @@ -29450,6 +30022,26 @@ } } }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" } @@ -29487,38 +30079,49 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/UserPermissionsResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/UserPermissionsResponseModel" + } + ] } }, "text/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/UserPermissionsResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/UserPermissionsResponseModel" + } + ] } }, "text/plain": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/UserPermissionsResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/UserPermissionsResponseModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" } } } @@ -29619,6 +30222,38 @@ } } }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -29730,6 +30365,38 @@ } } }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -29824,6 +30491,70 @@ } } }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" } @@ -29920,6 +30651,38 @@ } } }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" } @@ -30016,6 +30779,38 @@ } } }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" } @@ -30121,6 +30916,38 @@ } } }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" } @@ -31085,6 +31912,21 @@ ], "additionalProperties": false }, + "ChangePasswordCurrentUserRequestModel": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/ChangePasswordUserRequestModel" + } + ], + "properties": { + "oldPassword": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "ChangePasswordUserRequestModel": { "required": [ "newPassword" @@ -31093,10 +31935,6 @@ "properties": { "newPassword": { "type": "string" - }, - "oldPassword": { - "type": "string", - "nullable": true } }, "additionalProperties": false @@ -32670,23 +33508,10 @@ "$ref": "#/components/schemas/UserPresentationBaseModel" } ], - "additionalProperties": false - }, - "CreateUserResponseModel": { - "required": [ - "user" - ], - "type": "object", "properties": { - "user": { - "oneOf": [ - { - "$ref": "#/components/schemas/ReferenceByIdModel" - } - ] - }, - "initialPassword": { + "id": { "type": "string", + "format": "uuid", "nullable": true } }, @@ -37923,6 +38748,16 @@ }, "additionalProperties": false }, + "ResetPasswordUserResponseModel": { + "type": "object", + "properties": { + "resetPassword": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "RuntimeLevelModel": { "enum": [ "Unknown", @@ -40274,4 +41109,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/User/ChangePasswordUserRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/ChangePasswordUserRequestModel.cs index 1e7e638b36..9c2ab978c9 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/User/ChangePasswordUserRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/ChangePasswordUserRequestModel.cs @@ -6,9 +6,4 @@ 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/User/CreateUserRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/CreateUserRequestModel.cs index 6867a39655..fa9b1b856a 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/User/CreateUserRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/CreateUserRequestModel.cs @@ -2,5 +2,5 @@ public class CreateUserRequestModel : UserPresentationBase { - + public Guid? Id { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/User/Current/ChangePasswordCurrentUserRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/Current/ChangePasswordCurrentUserRequestModel.cs new file mode 100644 index 0000000000..f413d1b2c5 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/Current/ChangePasswordCurrentUserRequestModel.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.User.Current; + +public class ChangePasswordCurrentUserRequestModel : ChangePasswordUserRequestModel +{ + /// + /// The old password. + /// + public string? OldPassword { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/User/ChangePasswordUserResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/ResetPasswordUserResponseModel.cs similarity index 70% rename from src/Umbraco.Cms.Api.Management/ViewModels/User/ChangePasswordUserResponseModel.cs rename to src/Umbraco.Cms.Api.Management/ViewModels/User/ResetPasswordUserResponseModel.cs index c47c1834a6..974986cb79 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/User/ChangePasswordUserResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/ResetPasswordUserResponseModel.cs @@ -1,6 +1,6 @@ namespace Umbraco.Cms.Api.Management.ViewModels.User; -public class ChangePasswordUserResponseModel +public class ResetPasswordUserResponseModel { public string? ResetPassword { get; set; } } diff --git a/src/Umbraco.Core/Models/UserCreateModel.cs b/src/Umbraco.Core/Models/UserCreateModel.cs index fafd47946c..2df55df358 100644 --- a/src/Umbraco.Core/Models/UserCreateModel.cs +++ b/src/Umbraco.Core/Models/UserCreateModel.cs @@ -4,6 +4,8 @@ namespace Umbraco.Cms.Core.Models; public class UserCreateModel { + public Guid? Id { get; set; } + public string Email { get; set; } = string.Empty; public string UserName { get; set; } = string.Empty; diff --git a/src/Umbraco.Core/Security/ICoreBackOfficeUserManager.cs b/src/Umbraco.Core/Security/ICoreBackOfficeUserManager.cs index d9225e372a..4456d7b6c0 100644 --- a/src/Umbraco.Core/Security/ICoreBackOfficeUserManager.cs +++ b/src/Umbraco.Core/Security/ICoreBackOfficeUserManager.cs @@ -17,6 +17,7 @@ public interface ICoreBackOfficeUserManager Task CreateForInvite(UserCreateModel createModel); Task> GenerateEmailConfirmationTokenAsync(IUser user); + Task> GeneratePasswordResetTokenAsync(IUser user); Task> UnlockUser(IUser user); @@ -24,6 +25,10 @@ public interface ICoreBackOfficeUserManager Task, UserOperationStatus>> GetLoginsAsync(IUser user); Task IsEmailConfirmationTokenValidAsync(IUser user, string token); + Task IsResetPasswordTokenValidAsync(IUser user, string token); + void NotifyForgotPasswordRequested(IPrincipal user, string toString); + + public string GeneratePassword(); } diff --git a/src/Umbraco.Core/Services/IUserService.cs b/src/Umbraco.Core/Services/IUserService.cs index b95a53d99b..8d6259b171 100644 --- a/src/Umbraco.Core/Services/IUserService.cs +++ b/src/Umbraco.Core/Services/IUserService.cs @@ -51,33 +51,33 @@ public interface IUserService : IMembershipUserService /// /// This creates both the Umbraco user and the identity user. /// - /// The key of the user performing the operation. + /// 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 userKey, UserCreateModel model, bool approveUser = false); + Task> CreateAsync(Guid performingUserKey, UserCreateModel model, bool approveUser = false); - Task> InviteAsync(Guid userKey, UserInviteModel model); + Task> InviteAsync(Guid performingUserKey, UserInviteModel model); Task> VerifyInviteAsync(Guid userKey, string token); Task> CreateInitialPasswordAsync(Guid userKey, string token, string password); - Task> UpdateAsync(Guid userKey, UserUpdateModel model); + Task> UpdateAsync(Guid performingUserKey, UserUpdateModel model); Task SetAvatarAsync(Guid userKey, Guid temporaryFileKey); - Task DeleteAsync(Guid userKey, ISet keys); + Task DeleteAsync(Guid performingUserKey, ISet keys); - Task DeleteAsync(Guid userKey, Guid key) => DeleteAsync(userKey, new HashSet { key }); + Task DeleteAsync(Guid performingUserKey, Guid key) => DeleteAsync(performingUserKey, new HashSet { key }); - Task DisableAsync(Guid userKey, ISet keys); + Task DisableAsync(Guid performingUserKey, ISet keys); - Task EnableAsync(Guid userKey, ISet keys); + Task EnableAsync(Guid performingUserKey, ISet keys); - Task> UnlockAsync(Guid userKey, params Guid[] keys); + Task> UnlockAsync(Guid performingUserKey, params Guid[] keys); - Task> ChangePasswordAsync(Guid userKey, ChangeUserPasswordModel model); + Task> ChangePasswordAsync(Guid performingUserKey, ChangeUserPasswordModel model); Task ClearAvatarAsync(Guid userKey); @@ -86,11 +86,11 @@ public interface IUserService : IMembershipUserService /// /// Gets all users that the requesting user is allowed to see. /// - /// The Key of the user requesting the users. + /// The Key of the user requesting the users. /// Amount to skip. /// Amount to take. /// All users that the user is allowed to see. - Task?, UserOperationStatus>> GetAllAsync(Guid userKey, int skip, int take) => throw new NotImplementedException(); + Task?, UserOperationStatus>> GetAllAsync(Guid performingUserKey, int skip, int take) => throw new NotImplementedException(); public Task, UserOperationStatus>> FilterAsync( Guid userKey, @@ -406,4 +406,6 @@ public interface IUserService : IMembershipUserService Task> SendResetPasswordEmailAsync(string userEmail); Task> ResendInvitationAsync(Guid performingUserKey, UserResendInviteModel model); + + Task> ResetPasswordAsync(Guid performingUserKey, Guid userKey); } diff --git a/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs index 8cacd6d2a3..122f7fa792 100644 --- a/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs @@ -24,7 +24,7 @@ public enum UserOperationStatus CannotDisableSelf, CannotDeleteSelf, CannotDisableInvitedUser, - OldPasswordRequired, + SelfOldPasswordRequired, InvalidAvatar, InvalidIsoCode, InvalidInviteToken, @@ -35,5 +35,7 @@ public enum UserOperationStatus MediaNodeNotFound, UnknownFailure, CannotPasswordReset, - NotInInviteState + NotInInviteState, + SelfPasswordResetNotAllowed, + DuplicateId, } diff --git a/src/Umbraco.Core/Services/TwoFactorLoginServiceBase.cs b/src/Umbraco.Core/Services/TwoFactorLoginServiceBase.cs index d8d69bfb26..28becea848 100644 --- a/src/Umbraco.Core/Services/TwoFactorLoginServiceBase.cs +++ b/src/Umbraco.Core/Services/TwoFactorLoginServiceBase.cs @@ -37,7 +37,7 @@ internal abstract class TwoFactorLoginServiceBase public virtual async Task, TwoFactorOperationStatus>> GetProviderNamesAsync(Guid userKey) { IEnumerable allProviders = _twoFactorLoginService.GetAllProviderNames(); - var userProviders =(await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(userKey)).ToHashSet(); + var userProviders = (await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(userKey)).ToHashSet(); IEnumerable result = allProviders.Select(x => new UserTwoFactorProviderModel(x, userProviders.Contains(x))); return Attempt.SucceedWithStatus(TwoFactorOperationStatus.Success, result); diff --git a/src/Umbraco.Core/Services/UserService.cs b/src/Umbraco.Core/Services/UserService.cs index 027f34d0fc..b0e0828b58 100644 --- a/src/Umbraco.Core/Services/UserService.cs +++ b/src/Umbraco.Core/Services/UserService.cs @@ -603,12 +603,12 @@ internal class UserService : RepositoryService, IUserService } /// - public async Task> CreateAsync(Guid userKey, UserCreateModel model, bool approveUser = false) + 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(userKey); + IUser? performingUser = await GetAsync(performingUserKey); if (performingUser is null) { @@ -622,7 +622,7 @@ internal class UserService : RepositoryService, IUserService return Attempt.FailWithStatus(UserOperationStatus.MissingUserGroup, new UserCreationResult()); } - UserOperationStatus result = ValidateUserCreateModel(model); + UserOperationStatus result = await ValidateUserCreateModel(model); if (result != UserOperationStatus.Success) { return Attempt.FailWithStatus(result, new UserCreationResult()); @@ -724,12 +724,12 @@ internal class UserService : RepositoryService, IUserService return Attempt.Succeed(UserOperationStatus.Success); } - public async Task> InviteAsync(Guid userKey, UserInviteModel model) + public async Task> InviteAsync(Guid performingUserKey, UserInviteModel model) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); using IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); - IUser? performingUser = await GetAsync(userKey); + IUser? performingUser = await GetAsync(performingUserKey); if (performingUser is null) { @@ -743,7 +743,7 @@ internal class UserService : RepositoryService, IUserService return Attempt.FailWithStatus(UserOperationStatus.MissingUserGroup, new UserInvitationResult()); } - UserOperationStatus validationResult = ValidateUserCreateModel(model); + UserOperationStatus validationResult = await ValidateUserCreateModel(model); if (validationResult is not UserOperationStatus.Success) { @@ -858,7 +858,7 @@ internal class UserService : RepositoryService, IUserService return Attempt.SucceedWithStatus(UserOperationStatus.Success, new UserInvitationResult { InvitedUser = invitedUser }); } - private UserOperationStatus ValidateUserCreateModel(UserCreateModel model) + private async Task ValidateUserCreateModel(UserCreateModel model) { if (_securitySettings.UsernameIsEmail && model.UserName != model.Email) { @@ -869,6 +869,11 @@ internal class UserService : RepositoryService, IUserService return UserOperationStatus.InvalidEmail; } + if (model.Id is not null && await GetAsync(model.Id.Value) is not null) + { + return UserOperationStatus.DuplicateId; + } + if (GetByEmail(model.Email) is not null) { return UserOperationStatus.DuplicateEmail; @@ -887,7 +892,7 @@ internal class UserService : RepositoryService, IUserService return UserOperationStatus.Success; } - public async Task> UpdateAsync(Guid userKey, UserUpdateModel model) + public async Task> UpdateAsync(Guid performingUserKey, UserUpdateModel model) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); using IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); @@ -897,10 +902,10 @@ internal class UserService : RepositoryService, IUserService if (existingUser is null) { - return Attempt.FailWithStatus(UserOperationStatus.MissingUser, existingUser); + return Attempt.FailWithStatus(UserOperationStatus.UserNotFound, existingUser); } - IUser? performingUser = await userStore.GetAsync(userKey); + IUser? performingUser = await userStore.GetAsync(performingUserKey); if (performingUser is null) { @@ -1091,7 +1096,7 @@ internal class UserService : RepositoryService, IUserService return keys; } - public async Task> ChangePasswordAsync(Guid userKey, ChangeUserPasswordModel model) + public async Task> ChangePasswordAsync(Guid performingUserKey, ChangeUserPasswordModel model) { IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); using ICoreScope scope = ScopeProvider.CreateCoreScope(); @@ -1103,15 +1108,16 @@ internal class UserService : RepositoryService, IUserService return Attempt.FailWithStatus(UserOperationStatus.UserNotFound, new PasswordChangedModel()); } - IUser? performingUser = await userStore.GetAsync(userKey); + IUser? performingUser = await userStore.GetAsync(performingUserKey); if (performingUser is null) { return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new PasswordChangedModel()); } + // require old password for self change when outside of invite or resetByToken flows if (performingUser.UserState != UserState.Invited && performingUser.Username == user.Username && string.IsNullOrEmpty(model.OldPassword) && string.IsNullOrEmpty(model.ResetPasswordToken)) { - return Attempt.FailWithStatus(UserOperationStatus.OldPasswordRequired, new PasswordChangedModel()); + return Attempt.FailWithStatus(UserOperationStatus.SelfOldPasswordRequired, new PasswordChangedModel()); } if (performingUser.IsAdmin() is false && user.IsAdmin()) @@ -1121,7 +1127,7 @@ internal class UserService : RepositoryService, IUserService if (string.IsNullOrEmpty(model.ResetPasswordToken) is false) { - Attempt verifyPasswordResetAsync = await VerifyPasswordResetAsync(userKey, model.ResetPasswordToken); + Attempt verifyPasswordResetAsync = await VerifyPasswordResetAsync(model.UserKey, model.ResetPasswordToken); if (verifyPasswordResetAsync.Result != UserOperationStatus.Success) { return Attempt.FailWithStatus(verifyPasswordResetAsync.Result, new PasswordChangedModel()); @@ -1147,11 +1153,11 @@ internal class UserService : RepositoryService, IUserService return Attempt.SucceedWithStatus(UserOperationStatus.Success, result.Result ?? new PasswordChangedModel()); } - public async Task?, UserOperationStatus>> GetAllAsync(Guid userKey, int skip, int take) + public async Task?, UserOperationStatus>> GetAllAsync(Guid performingUserKey, int skip, int take) { using ICoreScope scope = ScopeProvider.CreateCoreScope(); - IUser? requestingUser = await GetAsync(userKey); + IUser? requestingUser = await GetAsync(performingUserKey); if (requestingUser is null) { @@ -1364,7 +1370,7 @@ internal class UserService : RepositoryService, IUserService }; } - public async Task DeleteAsync(Guid userKey, ISet keys) + public async Task DeleteAsync(Guid performingUserKey, ISet keys) { if(keys.Any() is false) { @@ -1372,7 +1378,7 @@ internal class UserService : RepositoryService, IUserService } using ICoreScope scope = ScopeProvider.CreateCoreScope(); - IUser? performingUser = await GetAsync(userKey); + IUser? performingUser = await GetAsync(performingUserKey); if (performingUser is null) { @@ -1412,7 +1418,7 @@ internal class UserService : RepositoryService, IUserService return UserOperationStatus.Success; } - public async Task DisableAsync(Guid userKey, ISet keys) + public async Task DisableAsync(Guid performingUserKey, ISet keys) { if(keys.Any() is false) { @@ -1420,7 +1426,7 @@ internal class UserService : RepositoryService, IUserService } using ICoreScope scope = ScopeProvider.CreateCoreScope(); - IUser? performingUser = await GetAsync(userKey); + IUser? performingUser = await GetAsync(performingUserKey); if (performingUser is null) { @@ -1458,7 +1464,7 @@ internal class UserService : RepositoryService, IUserService return UserOperationStatus.Success; } - public async Task EnableAsync(Guid userKey, ISet keys) + public async Task EnableAsync(Guid performingUserKey, ISet keys) { if(keys.Any() is false) { @@ -1466,7 +1472,7 @@ internal class UserService : RepositoryService, IUserService } using ICoreScope scope = ScopeProvider.CreateCoreScope(); - IUser? performingUser = await GetAsync(userKey); + IUser? performingUser = await GetAsync(performingUserKey); if (performingUser is null) { @@ -1528,7 +1534,7 @@ internal class UserService : RepositoryService, IUserService return UserOperationStatus.Success; } - public async Task> UnlockAsync(Guid userKey, params Guid[] keys) + public async Task> UnlockAsync(Guid performingUserKey, params Guid[] keys) { if (keys.Length == 0) { @@ -1536,7 +1542,7 @@ internal class UserService : RepositoryService, IUserService } using ICoreScope scope = ScopeProvider.CreateCoreScope(); - IUser? performingUser = await GetAsync(userKey); + IUser? performingUser = await GetAsync(performingUserKey); if (performingUser is null) { @@ -2102,6 +2108,40 @@ internal class UserService : RepositoryService, IUserService return changePasswordAttempt; } + public async Task> ResetPasswordAsync(Guid performingUserKey, Guid userKey) + { + if (performingUserKey.Equals(userKey)) + { + return Attempt.FailWithStatus(UserOperationStatus.SelfPasswordResetNotAllowed, new PasswordChangedModel()); + } + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + using IServiceScope serviceScope = _serviceScopeFactory.CreateScope(); + + ICoreBackOfficeUserManager backOfficeUserManager = serviceScope.ServiceProvider.GetRequiredService(); + + var generatedPassword = backOfficeUserManager.GeneratePassword(); + + Attempt changePasswordAttempt = + await ChangePasswordAsync(performingUserKey, new ChangeUserPasswordModel + { + NewPassword = generatedPassword, + UserKey = userKey, + }); + + scope.Complete(); + + // todo tidy this up + // this should be part of the result of the ChangePasswordAsync() method + // but the model requires NewPassword + // and the passwordChanger does not have a codePath that deals with generating + if (changePasswordAttempt.Success) + { + changePasswordAttempt.Result.ResetPassword = generatedPassword; + } + + return changePasswordAttempt; + } /// @@ -2234,7 +2274,7 @@ internal class UserService : RepositoryService, IUserService results.Add(new NodePermissions { NodeKey = idKeyMap[nodeId], Permissions = permissions }); } - return Attempt.SucceedWithStatus, UserOperationStatus>(UserOperationStatus.UserNotFound, results); + return Attempt.SucceedWithStatus, UserOperationStatus>(UserOperationStatus.Success, results); } /// diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs b/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs index 39d6831316..4c7943c69c 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeIdentityUser.cs @@ -123,7 +123,7 @@ public class BackOfficeIdentityUser : UmbracoIdentityUser /// This is allowed to be null (but would need to be filled in if trying to persist this instance) /// /// - public static BackOfficeIdentityUser CreateNew(GlobalSettings globalSettings, string? username, string email, string culture, string? name = null) + public static BackOfficeIdentityUser CreateNew(GlobalSettings globalSettings, string? username, string email, string culture, string? name = null, Guid? id = null) { if (string.IsNullOrWhiteSpace(username)) { @@ -139,8 +139,13 @@ public class BackOfficeIdentityUser : UmbracoIdentityUser user.DisableChangeTracking(); user.UserName = username; user.Email = email; - user.Id = string.Empty; + + if (id is not null) + { + user.Key = id.Value; + } + user.HasIdentity = false; user._culture = culture; user.Name = name; diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs index bc284b6b8f..9a639926d5 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs @@ -140,8 +140,10 @@ public class BackOfficeUserStore : StartContentIds = user.StartContentIds ?? new int[] { }, StartMediaIds = user.StartMediaIds ?? new int[] { }, IsLockedOut = user.IsLockedOut, + Key = user.Key, }; + // we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it. var isLoginsPropertyDirty = user.IsPropertyDirty(nameof(BackOfficeIdentityUser.Logins)); var isTokensPropertyDirty = user.IsPropertyDirty(nameof(BackOfficeIdentityUser.LoginTokens)); diff --git a/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs b/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs index 25002432af..ecffa6b130 100644 --- a/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs +++ b/src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs @@ -296,9 +296,9 @@ public class BackOfficeUserManager : UmbracoUserManager