Files
Umbraco-CMS/src/Umbraco.Infrastructure/Services/MemberEditingService.cs
Andy Butland 7dc9cbd9b1 Merge branch 'v15/dev' into v16/dev
# Conflicts:
#	src/Umbraco.Cms.Api.Management/Factories/RelationTypePresentationFactory.cs
#	src/Umbraco.Core/Services/PropertyValidationService.cs
#	src/Umbraco.Web.UI.Client/utils/all-umb-consts/imports.ts
2025-03-25 17:56:50 +01:00

418 lines
18 KiB
C#

using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Services;
internal sealed class MemberEditingService : IMemberEditingService
{
private readonly IMemberService _memberService;
private readonly IMemberTypeService _memberTypeService;
private readonly IMemberContentEditingService _memberContentEditingService;
private readonly IMemberManager _memberManager;
private readonly ITwoFactorLoginService _twoFactorLoginService;
private readonly IPasswordChanger<MemberIdentityUser> _passwordChanger;
private readonly ILogger<MemberEditingService> _logger;
private readonly IMemberGroupService _memberGroupService;
private readonly SecuritySettings _securitySettings;
public MemberEditingService(
IMemberService memberService,
IMemberTypeService memberTypeService,
IMemberContentEditingService memberContentEditingService,
IMemberManager memberManager,
ITwoFactorLoginService twoFactorLoginService,
IPasswordChanger<MemberIdentityUser> passwordChanger,
ILogger<MemberEditingService> logger,
IMemberGroupService memberGroupService,
IOptions<SecuritySettings> securitySettings)
{
_memberService = memberService;
_memberTypeService = memberTypeService;
_memberContentEditingService = memberContentEditingService;
_memberManager = memberManager;
_twoFactorLoginService = twoFactorLoginService;
_passwordChanger = passwordChanger;
_logger = logger;
_memberGroupService = memberGroupService;
_securitySettings = securitySettings.Value;
}
public Task<IMember?> GetAsync(Guid key)
=> Task.FromResult(_memberService.GetById(key));
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(MemberCreateModel createModel)
=> await _memberContentEditingService.ValidateAsync(createModel, createModel.ContentTypeKey);
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateUpdateAsync(Guid key, MemberUpdateModel updateModel)
{
IMember? member = _memberService.GetById(key);
return member is not null
? await _memberContentEditingService.ValidateAsync(updateModel, member.ContentType.Key)
: Attempt.FailWithStatus(ContentEditingOperationStatus.NotFound, new ContentValidationResult());
}
public async Task<Attempt<MemberCreateResult, MemberEditingStatus>> CreateAsync(MemberCreateModel createModel, IUser user)
{
var status = new MemberEditingStatus();
MemberEditingOperationStatus validationStatus = await ValidateMemberDataAsync(createModel, null, createModel.Password);
if (validationStatus is not MemberEditingOperationStatus.Success)
{
status.MemberEditingOperationStatus = validationStatus;
return Attempt.FailWithStatus(status, new MemberCreateResult());
}
IMemberType? memberType = await _memberTypeService.GetAsync(createModel.ContentTypeKey);
if (memberType is null)
{
status.MemberEditingOperationStatus = MemberEditingOperationStatus.MemberTypeNotFound;
return Attempt.FailWithStatus(status, new MemberCreateResult());
}
var identityMember = MemberIdentityUser.CreateNew(
createModel.Username,
createModel.Email,
memberType.Alias,
createModel.IsApproved,
createModel.InvariantName,
createModel.Key);
IdentityResult createResult = await _memberManager.CreateAsync(identityMember, createModel.Password);
if (createResult.Succeeded is false)
{
return IdentityMemberCreationFailed(createResult, status);
}
IMember member = _memberService.GetByUsername(createModel.Username)
?? throw new InvalidOperationException("Member creation succeeded, but member could not be found by username.");
var updateRolesResult = await UpdateRoles(createModel.Roles, identityMember);
if (updateRolesResult is false)
{
status.MemberEditingOperationStatus = MemberEditingOperationStatus.RoleAssignmentFailed;
return Attempt.FailWithStatus(status, new MemberCreateResult { Content = member });
}
Attempt<MemberUpdateResult, ContentEditingOperationStatus> contentUpdateResult = await _memberContentEditingService.UpdateAsync(member, createModel, user.Key);
status.MemberEditingOperationStatus = MemberEditingOperationStatus.Success;
status.ContentEditingOperationStatus = contentUpdateResult.Status;
return contentUpdateResult.Success
? Attempt.SucceedWithStatus(status, new MemberCreateResult { Content = member, ValidationResult = contentUpdateResult.Result.ValidationResult })
: Attempt.FailWithStatus(status, new MemberCreateResult { Content = member });
}
public async Task<Attempt<MemberUpdateResult, MemberEditingStatus>> UpdateAsync(Guid key, MemberUpdateModel updateModel, IUser user)
{
var status = new MemberEditingStatus();
IMember? member = _memberService.GetById(key);
if (member is null)
{
status.ContentEditingOperationStatus = ContentEditingOperationStatus.NotFound;
return Attempt.FailWithStatus(status, new MemberUpdateResult());
}
if (user.HasAccessToSensitiveData() is false)
{
// handle sensitive data. certain member properties (IsApproved, IsLockedOut) are subject to "sensitive data" rules.
if (member.IsLockedOut != updateModel.IsLockedOut || member.IsApproved != updateModel.IsApproved)
{
status.ContentEditingOperationStatus = ContentEditingOperationStatus.NotAllowed;
return Attempt.FailWithStatus(status, new MemberUpdateResult());
}
}
MemberIdentityUser? identityMember = await _memberManager.FindByIdAsync(member.Id.ToString());
if (identityMember is null)
{
status.MemberEditingOperationStatus = MemberEditingOperationStatus.MemberNotFound;
return Attempt.FailWithStatus(status, new MemberUpdateResult { Content = member });
}
MemberEditingOperationStatus validationStatus = await ValidateMemberDataAsync(updateModel, member.Key, updateModel.NewPassword);
if (validationStatus is not MemberEditingOperationStatus.Success)
{
status.MemberEditingOperationStatus = validationStatus;
return Attempt.FailWithStatus(status, new MemberUpdateResult { Content = member });
}
if (identityMember.IsLockedOut && updateModel.IsLockedOut is false)
{
var unlockResult = await UnlockMember(identityMember);
if (unlockResult is false)
{
status.MemberEditingOperationStatus = MemberEditingOperationStatus.UnlockFailed;
return Attempt.FailWithStatus(status, new MemberUpdateResult { Content = member });
}
}
if (updateModel.IsTwoFactorEnabled is false)
{
var disableTwoFactorResult = await DisableTwoFactor(member);
if (disableTwoFactorResult is false)
{
status.MemberEditingOperationStatus = MemberEditingOperationStatus.DisableTwoFactorFailed;
return Attempt.FailWithStatus(status, new MemberUpdateResult { Content = member });
}
}
if (updateModel.NewPassword.IsNullOrWhiteSpace() is false)
{
var changePasswordResult = await ChangePassword(member, updateModel.OldPassword, updateModel.NewPassword, user);
if (changePasswordResult is false)
{
status.MemberEditingOperationStatus = MemberEditingOperationStatus.PasswordChangeFailed;
return Attempt.FailWithStatus(status, new MemberUpdateResult { Content = member });
}
}
var updateRolesResult = await UpdateRoles(updateModel.Roles, identityMember);
if (updateRolesResult is false)
{
status.MemberEditingOperationStatus = MemberEditingOperationStatus.RoleAssignmentFailed;
return Attempt.FailWithStatus(status, new MemberUpdateResult { Content = member });
}
member.IsLockedOut = updateModel.IsLockedOut;
member.IsApproved = updateModel.IsApproved;
member.Email = updateModel.Email;
member.Username = updateModel.Username;
Attempt<MemberUpdateResult, ContentEditingOperationStatus> contentUpdateResult = await _memberContentEditingService.UpdateAsync(member, updateModel, user.Key);
status.MemberEditingOperationStatus = MemberEditingOperationStatus.Success;
status.ContentEditingOperationStatus = contentUpdateResult.Status;
return contentUpdateResult.Success
? Attempt.SucceedWithStatus(status, new MemberUpdateResult { Content = member, ValidationResult = contentUpdateResult.Result.ValidationResult })
: Attempt.FailWithStatus(status, new MemberUpdateResult { Content = member });
}
public async Task<Attempt<IMember?, MemberEditingStatus>> DeleteAsync(Guid key, Guid userKey)
{
Attempt<IMember?, ContentEditingOperationStatus> contentDeleteResult = await _memberContentEditingService.DeleteAsync(key, userKey);
return contentDeleteResult.Success
? Attempt.SucceedWithStatus(
new MemberEditingStatus
{
MemberEditingOperationStatus = MemberEditingOperationStatus.Success,
ContentEditingOperationStatus = contentDeleteResult.Status
},
contentDeleteResult.Result)
: Attempt.FailWithStatus(
new MemberEditingStatus
{
MemberEditingOperationStatus = MemberEditingOperationStatus.Unknown,
ContentEditingOperationStatus = contentDeleteResult.Status
},
contentDeleteResult.Result);
}
private async Task<MemberEditingOperationStatus> ValidateMemberDataAsync(MemberEditingModelBase model, Guid? memberKey, string? password)
{
if (model.InvariantName.IsNullOrWhiteSpace())
{
return MemberEditingOperationStatus.InvalidName;
}
if (model.Username.IsNullOrWhiteSpace())
{
return MemberEditingOperationStatus.InvalidUsername;
}
// User names can only contain the configured allowed characters. This is validated by ASP.NET Identity on create
// as the setting is applied to the BackOfficeIdentityOptions, but we need to check ourselves for updates.
var allowedUserNameCharacters = _securitySettings.AllowedUserNameCharacters;
if (model.Username.Any(c => allowedUserNameCharacters.Contains(c) == false))
{
return MemberEditingOperationStatus.InvalidUsername;
}
if (model.Email.IsEmail() is false)
{
return MemberEditingOperationStatus.InvalidEmail;
}
if (password is not null)
{
IdentityResult validatePassword = await _memberManager.ValidatePasswordAsync(password);
if (validatePassword.Succeeded == false)
{
return MemberEditingOperationStatus.InvalidPassword;
}
}
IMember? byUsername = _memberService.GetByUsername(model.Username);
if (byUsername is not null && byUsername.Key != memberKey)
{
return MemberEditingOperationStatus.DuplicateUsername;
}
if (_securitySettings.MemberRequireUniqueEmail)
{
IMember? byEmail = _memberService.GetByEmail(model.Email);
if (byEmail is not null && byEmail.Key != memberKey)
{
return MemberEditingOperationStatus.DuplicateEmail;
}
}
return MemberEditingOperationStatus.Success;
}
private async Task<bool> UpdateRoles(IEnumerable<Guid>? roles, MemberIdentityUser identityMember)
{
// We have to convert the GUIDS to names here, as roles on a member are stored by name, not key.
var memberGroups = new List<IMemberGroup>();
foreach (Guid key in roles ?? Enumerable.Empty<Guid>())
{
IMemberGroup? group = await _memberGroupService.GetAsync(key);
if (group is not null)
{
memberGroups.Add(group);
}
}
// We're gonna look up the current roles now because the below code can cause
// events to be raised and developers could be manually adding roles to members in
// their handlers. If we don't look this up now there's a chance we'll just end up
// removing the roles they've assigned.
IEnumerable<string> currentRoles = (await _memberManager.GetRolesAsync(identityMember)).ToList();
// find the ones to remove and remove them
IEnumerable<string> memberGroupNames = memberGroups.Select(x => x.Name).WhereNotNull().ToArray();
var rolesToRemove = currentRoles.Except(memberGroupNames).ToArray();
// Now let's do the role provider stuff - now that we've saved the content item (that is important since
// if we are changing the username, it must be persisted before looking up the member roles).
if (rolesToRemove.Any())
{
IdentityResult identityResult = await _memberManager.RemoveFromRolesAsync(identityMember, rolesToRemove);
if (!identityResult.Succeeded)
{
_logger.LogError("Could not remove roles from member: {errorMessage}", identityResult.Errors.ToErrorMessage());
return false;
}
}
// find the ones to add and add them
var rolesToAdd = memberGroupNames.Except(currentRoles).ToArray();
if (rolesToAdd.Any())
{
// add the ones submitted
IdentityResult identityResult = await _memberManager.AddToRolesAsync(identityMember, rolesToAdd);
if (!identityResult.Succeeded)
{
_logger.LogError("Could not add roles to member: {errorMessage}", identityResult.Errors.ToErrorMessage());
return false;
}
}
return true;
}
private static Attempt<MemberCreateResult, MemberEditingStatus> IdentityMemberCreationFailed(IdentityResult created, MemberEditingStatus status)
{
MemberEditingOperationStatus createStatus = MemberEditingOperationStatus.Unknown;
foreach (IdentityError error in created.Errors)
{
switch (error.Code)
{
case nameof(IdentityErrorDescriber.InvalidUserName):
createStatus = MemberEditingOperationStatus.InvalidUsername;
break;
case nameof(IdentityErrorDescriber.PasswordMismatch):
case nameof(IdentityErrorDescriber.PasswordRequiresDigit):
case nameof(IdentityErrorDescriber.PasswordRequiresLower):
case nameof(IdentityErrorDescriber.PasswordRequiresNonAlphanumeric):
case nameof(IdentityErrorDescriber.PasswordRequiresUniqueChars):
case nameof(IdentityErrorDescriber.PasswordRequiresUpper):
case nameof(IdentityErrorDescriber.PasswordTooShort):
createStatus = MemberEditingOperationStatus.InvalidPassword;
break;
case nameof(IdentityErrorDescriber.InvalidEmail):
createStatus = MemberEditingOperationStatus.InvalidEmail;
break;
case nameof(IdentityErrorDescriber.DuplicateUserName):
createStatus = MemberEditingOperationStatus.DuplicateUsername;
break;
case nameof(IdentityErrorDescriber.DuplicateEmail):
createStatus = MemberEditingOperationStatus.DuplicateEmail;
break;
case MemberUserStore.CancelledIdentityErrorCode:
createStatus = MemberEditingOperationStatus.CancelledByNotificationHandler;
break;
}
if (createStatus is not MemberEditingOperationStatus.Unknown)
{
break;
}
}
status.MemberEditingOperationStatus = createStatus;
return Attempt.FailWithStatus(status, new MemberCreateResult());
}
private async Task<bool> UnlockMember(MemberIdentityUser identityMember)
{
// Handle unlocking with the member manager (takes care of other nuances)
IdentityResult unlockResult = await _memberManager.SetLockoutEndDateAsync(identityMember, DateTimeOffset.Now.AddMinutes(-1));
if (unlockResult.Succeeded is false)
{
_logger.LogError("Could not unlock member: {errorMessage}", unlockResult.Errors.ToErrorMessage());
}
return unlockResult.Succeeded;
}
private async Task<bool> DisableTwoFactor(IMember member)
{
IEnumerable<string> providers = await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(member.Key);
foreach (var provider in providers)
{
var disableResult = await _twoFactorLoginService.DisableAsync(member.Key, provider);
if (disableResult is false)
{
_logger.LogError("2FA provider \"{provider}\" could not disable member", provider);
return false;
}
}
return true;
}
private async Task<bool> ChangePassword(IMember member, string? oldPassword, string newPassword, IUser user)
{
var changingPasswordModel = new ChangingPasswordModel
{
Id = member.Id,
OldPassword = oldPassword,
NewPassword = newPassword
};
// change and persist the password
Attempt<PasswordChangedModel?> passwordChangeResult =
await _passwordChanger.ChangePasswordWithIdentityAsync(changingPasswordModel, _memberManager, user);
if (passwordChangeResult.Success is false)
{
_logger.LogError("Could not change member password: {errorMessage}", passwordChangeResult.Result?.Error?.ErrorMessage ?? "no error details available");
return false;
}
return true;
}
}