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:
Bjarke Berg
2023-07-05 12:42:52 +02:00
committed by GitHub
parent 8bccab3b60
commit 59df7439db
12 changed files with 185 additions and 4 deletions

View File

@@ -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);
}
}

View File

@@ -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.")

View File

@@ -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);
}
}

View File

@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Api.Management.ViewModels.User;
public class CreateInitialPasswordUserRequestModel : VerifyInviteUserRequestModel
{
public string Password { get; set; } = string.Empty;
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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
}

View File

@@ -23,6 +23,7 @@ public enum UserOperationStatus
OldPasswordRequired,
InvalidAvatar,
InvalidIsoCode,
InvalidVerificationToken,
ContentStartNodeNotFound,
MediaStartNodeNotFound,
ContentNodeNotFound,

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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)