Skip to content

Commit

Permalink
Verify user invite token (#14491)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
bergmania and nikolajlauridsen committed Jul 5, 2023
1 parent 8bccab3 commit 59df743
Show file tree
Hide file tree
Showing 12 changed files with 185 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ protected IActionResult UserOperationStatusResult(UserOperationStatus status, Er
.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()
Expand All @@ -95,6 +95,10 @@ protected IActionResult UserOperationStatusResult(UserOperationStatus status, Er
.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.")
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Api.Management.ViewModels.User;

public class CreateInitialPasswordUserRequestModel : VerifyInviteUserRequestModel
{
public string Password { get; set; } = string.Empty;
}
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions src/Umbraco.Core/Security/ICoreBackOfficeUserManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
5 changes: 5 additions & 0 deletions src/Umbraco.Core/Services/IUserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -375,4 +379,5 @@ IEnumerable<IUser> GetAll(
void DeleteUserGroup(IUserGroup userGroup);

#endregion

}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public enum UserOperationStatus
OldPasswordRequired,
InvalidAvatar,
InvalidIsoCode,
InvalidVerificationToken,
ContentStartNodeNotFound,
MediaStartNodeNotFound,
ContentNodeNotFound,
Expand Down
51 changes: 50 additions & 1 deletion src/Umbraco.Core/Services/UserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1025,7 +1025,7 @@ public async Task<Attempt<PasswordChangedModel, UserOperationStatus>> ChangePass
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());
}
Expand Down Expand Up @@ -1899,6 +1899,55 @@ public void DeleteUserGroup(IUserGroup userGroup)
}
}

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>
Expand Down
3 changes: 2 additions & 1 deletion src/Umbraco.Infrastructure/Security/UmbracoUserManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,8 @@ public async Task<bool> ValidateCredentialsAsync(string username, string passwor
{
TUser? user = await FindByNameAsync(username);

if (user is null)

if (user is null || user.IsApproved is false)
{
return false;
}
Expand Down
11 changes: 11 additions & 0 deletions src/Umbraco.Web.Common/Security/BackOfficeUserManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -342,4 +342,15 @@ public async Task<Attempt<ICollection<IIdentityUserLogin>, UserOperationStatus>>
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ public async Task GivenICreateANewUser_AndTheUserIsPopulatedCorrectly_ThenIShoul
}

[Test]
public async Task GivenAUserExists_AndTheCorrectCredentialsAreProvided_ThenACheckOfCredentialsShouldSucceed()
public async Task GivenAApprovedUserExists_AndTheCorrectCredentialsAreProvided_ThenACheckOfCredentialsShouldSucceed()
{
// arrange
var password = "password";
Expand All @@ -168,6 +168,34 @@ public async Task GivenAUserExists_AndTheCorrectCredentialsAreProvided_ThenAChec
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()
{
Expand Down Expand Up @@ -220,6 +248,7 @@ private static MemberIdentityUser CreateValidUser() =>
MemberTypeAlias = "Anything",
PasswordConfig = "testConfig",
PasswordHash = "hashedPassword",
IsApproved = true
};

private static IMember CreateMember(MemberIdentityUser fakeUser)
Expand Down

0 comments on commit 59df743

Please sign in to comment.