Verify user invite token (#14491)
* Added functionality to verify user invite tokens and create the initial password * Add response types * Fail ValidateCredentialsAsync when user is not approved * Enable user as part of initial password creating using validation token * Adds documentation to badrequest and changed nocontent to ok, to align with other APIs * Fixed tests and added a new one --------- Co-authored-by: nikolajlauridsen <nikolajlauridsen@protonmail.ch>
This commit is contained in:
@@ -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<IActionResult> CreateInitialPassword(CreateInitialPasswordUserRequestModel model)
|
||||
{
|
||||
Attempt<PasswordChangedModel, UserOperationStatus> response = await _userService.CreateInitialPasswordAsync(model.UserId, model.Token, model.Password);
|
||||
|
||||
return response.Success
|
||||
? Ok()
|
||||
: UserOperationStatusResult(response.Status, response.Result);
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
|
||||
@@ -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<IActionResult> Invite(VerifyInviteUserRequestModel model)
|
||||
{
|
||||
Attempt<UserOperationStatus> result = await _userService.VerifyInviteAsync(model.UserId, model.Token);
|
||||
|
||||
return result.Success
|
||||
? Ok()
|
||||
: UserOperationStatusResult(result.Result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Umbraco.Cms.Api.Management.ViewModels.User;
|
||||
|
||||
public class CreateInitialPasswordUserRequestModel : VerifyInviteUserRequestModel
|
||||
{
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -20,4 +20,6 @@ public interface ICoreBackOfficeUserManager
|
||||
Task<Attempt<UserUnlockResult, UserOperationStatus>> UnlockUser(IUser user);
|
||||
|
||||
Task<Attempt<ICollection<IIdentityUserLogin>, UserOperationStatus>> GetLoginsAsync(IUser user);
|
||||
|
||||
Task<bool> IsEmailConfirmationTokenValidAsync(IUser user, string token);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ public interface IUserService : IMembershipUserService
|
||||
|
||||
Task<Attempt<UserInvitationResult, UserOperationStatus>> InviteAsync(Guid performingUserKey, UserInviteModel model);
|
||||
|
||||
Task<Attempt<UserOperationStatus>> VerifyInviteAsync(Guid userKey, string token);
|
||||
|
||||
Task<Attempt<PasswordChangedModel, UserOperationStatus>> CreateInitialPasswordAsync(Guid userKey, string token, string password);
|
||||
|
||||
Task<Attempt<IUser?, UserOperationStatus>> UpdateAsync(Guid performingUserKey, UserUpdateModel model);
|
||||
|
||||
Task<UserOperationStatus> SetAvatarAsync(Guid userKey, Guid temporaryFileKey);
|
||||
@@ -375,4 +379,5 @@ public interface IUserService : IMembershipUserService
|
||||
void DeleteUserGroup(IUserGroup userGroup);
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ public enum UserOperationStatus
|
||||
OldPasswordRequired,
|
||||
InvalidAvatar,
|
||||
InvalidIsoCode,
|
||||
InvalidVerificationToken,
|
||||
ContentStartNodeNotFound,
|
||||
MediaStartNodeNotFound,
|
||||
ContentNodeNotFound,
|
||||
|
||||
@@ -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<Attempt<UserOperationStatus>> 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<ICoreBackOfficeUserManager>();
|
||||
|
||||
var isValid = await backOfficeUserManager.IsEmailConfirmationTokenValidAsync(user, decoded);
|
||||
|
||||
return isValid
|
||||
? Attempt.Succeed(UserOperationStatus.Success)
|
||||
: Attempt.Fail(UserOperationStatus.InvalidVerificationToken);
|
||||
}
|
||||
|
||||
public async Task<Attempt<PasswordChangedModel, UserOperationStatus>> CreateInitialPasswordAsync(Guid userKey, string token, string password)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
|
||||
Attempt<UserOperationStatus> verifyInviteAttempt = await VerifyInviteAsync(userKey, token);
|
||||
if (verifyInviteAttempt.Result != UserOperationStatus.Success)
|
||||
{
|
||||
return Attempt.FailWithStatus(verifyInviteAttempt.Result, new PasswordChangedModel());
|
||||
}
|
||||
|
||||
Attempt<PasswordChangedModel, UserOperationStatus> changePasswordAttempt = await ChangePasswordAsync(userKey, new ChangeUserPasswordModel() { NewPassword = password, UserKey = userKey });
|
||||
|
||||
Task<UserOperationStatus> enableAttempt = EnableAsync(userKey, new HashSet<Guid>() { userKey });
|
||||
|
||||
if (enableAttempt.Result != UserOperationStatus.Success)
|
||||
{
|
||||
return Attempt.FailWithStatus(enableAttempt.Result, new PasswordChangedModel());
|
||||
}
|
||||
|
||||
scope.Complete();
|
||||
return changePasswordAttempt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a specific section from all users
|
||||
/// </summary>
|
||||
|
||||
@@ -253,7 +253,8 @@ public abstract class UmbracoUserManager<TUser, TPasswordConfig> : UserManager<T
|
||||
{
|
||||
TUser? user = await FindByNameAsync(username);
|
||||
|
||||
if (user is null)
|
||||
|
||||
if (user is null || user.IsApproved is false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -342,4 +342,15 @@ public class BackOfficeUserManager : UmbracoUserManager<BackOfficeIdentityUser,
|
||||
return Attempt.SucceedWithStatus(UserOperationStatus.Success, identityUser.Logins);
|
||||
}
|
||||
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>(y => y == fakeUser.UserName))).Returns(fakeMember);
|
||||
|
||||
_mockPasswordHasher
|
||||
.Setup(x => x.VerifyHashedPassword(It.IsAny<MemberIdentityUser>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.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)
|
||||
|
||||
Reference in New Issue
Block a user