diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/CreateInitialPasswordUserController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/CreateInitialPasswordUserController.cs new file mode 100644 index 0000000000..d94a567071 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/CreateInitialPasswordUserController.cs @@ -0,0 +1,33 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.User; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.User; + +[ApiVersion("1.0")] +public class CreateInitialPasswordUserController : UserControllerBase +{ + private readonly IUserService _userService; + + public CreateInitialPasswordUserController(IUserService userService) => _userService = userService; + + [AllowAnonymous] + [HttpPost("invite/create-password")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task CreateInitialPassword(CreateInitialPasswordUserRequestModel model) + { + Attempt response = await _userService.CreateInitialPasswordAsync(model.UserId, model.Token, model.Password); + + return response.Success + ? Ok() + : UserOperationStatusResult(response.Status, response.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/UsersControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/UsersControllerBase.cs index 8bc6b387e9..0ca74fa942 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/User/UsersControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/UsersControllerBase.cs @@ -80,7 +80,7 @@ public abstract class UserControllerBase : ManagementApiControllerBase .WithDetail("Some of the provided media start nodes was not found.") .Build()), UserOperationStatus.UserNotFound => NotFound(new ProblemDetailsBuilder() - .WithTitle("The was not found") + .WithTitle("The user was not found") .WithDetail("The specified user was not found.") .Build()), UserOperationStatus.CannotDisableInvitedUser => BadRequest(new ProblemDetailsBuilder() @@ -95,6 +95,10 @@ public abstract class UserControllerBase : ManagementApiControllerBase .WithTitle("Invalid ISO code") .WithDetail("The specified ISO code is invalid.") .Build()), + UserOperationStatus.InvalidVerificationToken => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid verification token") + .WithDetail("The specified verification token is invalid.") + .Build()), UserOperationStatus.MediaNodeNotFound => NotFound(new ProblemDetailsBuilder() .WithTitle("Media node not found") .WithDetail("The specified media node was not found.") diff --git a/src/Umbraco.Cms.Api.Management/Controllers/User/VerifyInviteUsersController.cs b/src/Umbraco.Cms.Api.Management/Controllers/User/VerifyInviteUsersController.cs new file mode 100644 index 0000000000..c7ba7bc06b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/User/VerifyInviteUsersController.cs @@ -0,0 +1,32 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.User; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.User; + +[ApiVersion("1.0")] +public class VerifyInviteUserController : UserControllerBase +{ + private readonly IUserService _userService; + + public VerifyInviteUserController(IUserService userService) => _userService = userService; + + [AllowAnonymous] + [HttpPost("invite/verify")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Invite(VerifyInviteUserRequestModel model) + { + Attempt result = await _userService.VerifyInviteAsync(model.UserId, model.Token); + + return result.Success + ? Ok() + : UserOperationStatusResult(result.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/User/CreateInitialPasswordUserRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/CreateInitialPasswordUserRequestModel.cs new file mode 100644 index 0000000000..1e8a3bdd7e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/CreateInitialPasswordUserRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.User; + +public class CreateInitialPasswordUserRequestModel : VerifyInviteUserRequestModel +{ + public string Password { get; set; } = string.Empty; +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/User/VerifyInviteUserRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/User/VerifyInviteUserRequestModel.cs new file mode 100644 index 0000000000..c9d80602e1 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/User/VerifyInviteUserRequestModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.User; + +public class VerifyInviteUserRequestModel +{ + public Guid UserId { get; set; } = Guid.Empty; + + public string Token { get; set; } = string.Empty; +} diff --git a/src/Umbraco.Core/Security/ICoreBackOfficeUserManager.cs b/src/Umbraco.Core/Security/ICoreBackOfficeUserManager.cs index 70f5a42181..04efcaecdc 100644 --- a/src/Umbraco.Core/Security/ICoreBackOfficeUserManager.cs +++ b/src/Umbraco.Core/Security/ICoreBackOfficeUserManager.cs @@ -20,4 +20,6 @@ public interface ICoreBackOfficeUserManager Task> UnlockUser(IUser user); Task, UserOperationStatus>> GetLoginsAsync(IUser user); + + Task IsEmailConfirmationTokenValidAsync(IUser user, string token); } diff --git a/src/Umbraco.Core/Services/IUserService.cs b/src/Umbraco.Core/Services/IUserService.cs index 393c536d27..8727af6aab 100644 --- a/src/Umbraco.Core/Services/IUserService.cs +++ b/src/Umbraco.Core/Services/IUserService.cs @@ -59,6 +59,10 @@ public interface IUserService : IMembershipUserService Task> InviteAsync(Guid performingUserKey, UserInviteModel model); + Task> VerifyInviteAsync(Guid userKey, string token); + + Task> CreateInitialPasswordAsync(Guid userKey, string token, string password); + Task> UpdateAsync(Guid performingUserKey, UserUpdateModel model); Task SetAvatarAsync(Guid userKey, Guid temporaryFileKey); @@ -375,4 +379,5 @@ public interface IUserService : IMembershipUserService void DeleteUserGroup(IUserGroup userGroup); #endregion + } diff --git a/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs index 6f32e0474b..17275d5b70 100644 --- a/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/UserOperationStatus.cs @@ -23,6 +23,7 @@ public enum UserOperationStatus OldPasswordRequired, InvalidAvatar, InvalidIsoCode, + InvalidVerificationToken, ContentStartNodeNotFound, MediaStartNodeNotFound, ContentNodeNotFound, diff --git a/src/Umbraco.Core/Services/UserService.cs b/src/Umbraco.Core/Services/UserService.cs index 16fa9f52ad..0c0db6c9c9 100644 --- a/src/Umbraco.Core/Services/UserService.cs +++ b/src/Umbraco.Core/Services/UserService.cs @@ -1025,7 +1025,7 @@ internal class UserService : RepositoryService, IUserService return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new PasswordChangedModel()); } - if (performingUser.Username == user.Username && string.IsNullOrEmpty(model.OldPassword)) + if (performingUser.UserState != UserState.Invited && performingUser.Username == user.Username && string.IsNullOrEmpty(model.OldPassword)) { return Attempt.FailWithStatus(UserOperationStatus.OldPasswordRequired, new PasswordChangedModel()); } @@ -1899,6 +1899,55 @@ internal class UserService : RepositoryService, IUserService } } + public async Task> VerifyInviteAsync(Guid userKey, string token) + { + var decoded = token.FromUrlBase64(); + + if (decoded is null) + { + return Attempt.Fail(UserOperationStatus.InvalidVerificationToken); + } + + IUser? user = await GetAsync(userKey); + + if (user is null) + { + return Attempt.Fail(UserOperationStatus.UserNotFound); + } + + using IServiceScope scope = _serviceScopeFactory.CreateScope(); + ICoreBackOfficeUserManager backOfficeUserManager = scope.ServiceProvider.GetRequiredService(); + + var isValid = await backOfficeUserManager.IsEmailConfirmationTokenValidAsync(user, decoded); + + return isValid + ? Attempt.Succeed(UserOperationStatus.Success) + : Attempt.Fail(UserOperationStatus.InvalidVerificationToken); + } + + public async Task> CreateInitialPasswordAsync(Guid userKey, string token, string password) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + Attempt verifyInviteAttempt = await VerifyInviteAsync(userKey, token); + if (verifyInviteAttempt.Result != UserOperationStatus.Success) + { + return Attempt.FailWithStatus(verifyInviteAttempt.Result, new PasswordChangedModel()); + } + + Attempt changePasswordAttempt = await ChangePasswordAsync(userKey, new ChangeUserPasswordModel() { NewPassword = password, UserKey = userKey }); + + Task enableAttempt = EnableAsync(userKey, new HashSet() { userKey }); + + if (enableAttempt.Result != UserOperationStatus.Success) + { + return Attempt.FailWithStatus(enableAttempt.Result, new PasswordChangedModel()); + } + + scope.Complete(); + return changePasswordAttempt; + } + /// /// Removes a specific section from all users /// diff --git a/src/Umbraco.Infrastructure/Security/UmbracoUserManager.cs b/src/Umbraco.Infrastructure/Security/UmbracoUserManager.cs index 860c1ed7f6..2b4f88adc5 100644 --- a/src/Umbraco.Infrastructure/Security/UmbracoUserManager.cs +++ b/src/Umbraco.Infrastructure/Security/UmbracoUserManager.cs @@ -253,7 +253,8 @@ public abstract class UmbracoUserManager : UserManager IsEmailConfirmationTokenValidAsync(IUser user, string token) + { + BackOfficeIdentityUser? identityUser = await FindByIdAsync(user.Id.ToString()); + + if (identityUser != null && await VerifyUserTokenAsync(identityUser, Options.Tokens.EmailConfirmationTokenProvider, ConfirmEmailTokenPurpose, token).ConfigureAwait(false)) + { + return true; + } + + return false; + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs index f745c06ceb..ef74846db6 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberManagerTests.cs @@ -142,7 +142,7 @@ public class MemberManagerTests } [Test] - public async Task GivenAUserExists_AndTheCorrectCredentialsAreProvided_ThenACheckOfCredentialsShouldSucceed() + public async Task GivenAApprovedUserExists_AndTheCorrectCredentialsAreProvided_ThenACheckOfCredentialsShouldSucceed() { // arrange var password = "password"; @@ -168,6 +168,34 @@ public class MemberManagerTests Assert.IsTrue(result); } + [Test] + public async Task GivenAnUnapprovedUserExists_AndTheCorrectCredentialsAreProvided_ThenACheckOfCredentialsShouldFail() + { + // arrange + var password = "password"; + var sut = CreateSut(); + + var fakeUser = CreateValidUser(); + fakeUser.IsApproved = false; + + var fakeMember = CreateMember(fakeUser); + + MockMemberServiceForCreateMember(fakeMember); + + _mockMemberService.Setup(x => x.GetByUsername(It.Is(y => y == fakeUser.UserName))).Returns(fakeMember); + + _mockPasswordHasher + .Setup(x => x.VerifyHashedPassword(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(PasswordVerificationResult.Success); + + // act + await sut.CreateAsync(fakeUser); + var result = await sut.ValidateCredentialsAsync(fakeUser.UserName, password); + + // assert + Assert.IsFalse(result); + } + [Test] public async Task GivenAUserExists_AndIncorrectCredentialsAreProvided_ThenACheckOfCredentialsShouldFail() { @@ -220,6 +248,7 @@ public class MemberManagerTests MemberTypeAlias = "Anything", PasswordConfig = "testConfig", PasswordHash = "hashedPassword", + IsApproved = true }; private static IMember CreateMember(MemberIdentityUser fakeUser)