using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Mime; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Umbraco.Core; using Umbraco.Core.Dictionary; using Umbraco.Core.Events; using Umbraco.Core.Mapping; using Umbraco.Core.Models; using Umbraco.Core.Models.ContentEditing; using Umbraco.Core.Models.Membership; using Umbraco.Core.PropertyEditors; using Umbraco.Core.Security; using Umbraco.Core.Serialization; using Umbraco.Core.Services; using Umbraco.Core.Services.Implement; using Umbraco.Core.Strings; using Umbraco.Extensions; using Umbraco.Infrastructure.Members; using Umbraco.Web.BackOffice.Filters; using Umbraco.Web.BackOffice.ModelBinders; using Umbraco.Web.Common.Attributes; using Umbraco.Web.Common.Authorization; using Umbraco.Web.Common.Exceptions; using Umbraco.Web.Common.Filters; using Umbraco.Web.ContentApps; using Umbraco.Web.Models.ContentEditing; using Constants = Umbraco.Core.Constants; using UmbracoMembersIdentityUser = Umbraco.Core.Members.UmbracoMembersIdentityUser; namespace Umbraco.Web.BackOffice.Controllers { /// /// This controller is decorated with the UmbracoApplicationAuthorizeAttribute which means that any user requesting /// access to ALL of the methods on this controller will need access to the member application. /// [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] [Authorize(Policy = AuthorizationPolicies.SectionAccessMembers)] [OutgoingNoHyphenGuidFormat] public class MemberController : ContentControllerBase { private readonly PropertyEditorCollection _propertyEditors; private readonly UmbracoMapper _umbracoMapper; private readonly IMemberService _memberService; private readonly IMemberTypeService _memberTypeService; private readonly IUmbracoMembersUserManager _memberManager; private readonly IDataTypeService _dataTypeService; private readonly ILocalizedTextService _localizedTextService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; private readonly IJsonSerializer _jsonSerializer; /// /// Initializes a new instance of the class. /// /// The culture dictionary /// The logger factory /// The string helper /// The event messages factory /// The entry point for localizing key services /// The property editors /// The mapper /// The member service /// The member type service /// The member manager /// The data-type service /// The back office security accessor /// The JSON serializer public MemberController( ICultureDictionary cultureDictionary, ILoggerFactory loggerFactory, IShortStringHelper shortStringHelper, IEventMessagesFactory eventMessages, ILocalizedTextService localizedTextService, PropertyEditorCollection propertyEditors, UmbracoMapper umbracoMapper, IMemberService memberService, IMemberTypeService memberTypeService, IUmbracoMembersUserManager memberManager, IDataTypeService dataTypeService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor, IJsonSerializer jsonSerializer) : base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, jsonSerializer) { _propertyEditors = propertyEditors; _umbracoMapper = umbracoMapper; _memberService = memberService; _memberTypeService = memberTypeService; _memberManager = memberManager; _dataTypeService = dataTypeService; _localizedTextService = localizedTextService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; _jsonSerializer = jsonSerializer; } /// /// The paginated list of members /// /// The page number to display /// The size of the page /// The ordering of the member list /// The direction of the member list /// The system field to order by /// The current filter for the list /// The member type /// The paged result of members public PagedResult GetPagedResults( int pageNumber = 1, int pageSize = 100, string orderBy = "username", Direction orderDirection = Direction.Ascending, bool orderBySystemField = true, string filter = "", string memberTypeAlias = null) { if (pageNumber <= 0 || pageSize <= 0) { throw new NotSupportedException("Both pageNumber and pageSize must be greater than zero"); } IMember[] members = _memberService.GetAll( pageNumber - 1, pageSize, out var totalRecords, orderBy, orderDirection, orderBySystemField, memberTypeAlias, filter).ToArray(); if (totalRecords == 0) { return new PagedResult(0, 0, 0); } var pagedResult = new PagedResult(totalRecords, pageNumber, pageSize) { Items = members.Select(x => _umbracoMapper.Map(x)) }; return pagedResult; } /// /// Returns a display node with a list view to render members /// /// The member type to list /// The member list for display public MemberListDisplay GetListNodeDisplay(string listName) { IMemberType foundType = _memberTypeService.Get(listName); var name = foundType != null ? foundType.Name : listName; var apps = new List { ListViewContentAppFactory.CreateContentApp( _dataTypeService, _propertyEditors, listName, Constants.Security.DefaultMemberTypeAlias.ToLower(), Constants.DataTypes.DefaultMembersListView) }; apps[0].Active = true; var display = new MemberListDisplay { ContentTypeAlias = listName, ContentTypeName = name, Id = listName, IsContainer = true, Name = listName == Constants.Conventions.MemberTypes.AllMembersListId ? "All Members" : name, Path = "-1," + listName, ParentId = -1, ContentApps = apps }; return display; } /// /// Gets the content json for the member /// /// The Guid key of the member /// The member for display [OutgoingEditorModelEvent] public MemberDisplay GetByKey(Guid key) { // TODO: this is not finding the key currently IMember foundMember = _memberService.GetByKey(key); if (foundMember == null) { HandleContentNotFound(key); } return _umbracoMapper.Map(foundMember); } /// /// Gets an empty content item for the /// /// The content type /// The empty member for display [OutgoingEditorModelEvent] public MemberDisplay GetEmpty(string contentTypeAlias = null) { if (contentTypeAlias == null) { throw new HttpResponseException(HttpStatusCode.NotFound); } IMemberType contentType = _memberTypeService.Get(contentTypeAlias); if (contentType == null) { throw new HttpResponseException(HttpStatusCode.NotFound); } string newPassword = _memberManager.GeneratePassword(); IMember emptyContent = new Member(contentType); emptyContent.AdditionalData["NewPassword"] = newPassword; return _umbracoMapper.Map(emptyContent); } /// /// Saves member /// /// The content item to save as a member /// The resulting member display object [FileUploadCleanupFilter] [OutgoingEditorModelEvent] [MemberSaveValidation] public async Task> PostSave([ModelBinder(typeof(MemberBinder))] MemberSave contentItem) { if (contentItem == null) { throw new ArgumentNullException(nameof(contentItem)); } if (ModelState.IsValid == false) { throw new HttpResponseException(HttpStatusCode.BadRequest, ModelState); } // If we've reached here it means: // * Our model has been bound // * and validated // * any file attachments have been saved to their temporary location for us to use // * we have a reference to the DTO object and the persisted object // * Permissions are valid // map the properties to the persisted entity MapPropertyValues(contentItem); ValidateMemberData(contentItem); // Unlike content/media - if there are errors for a member, we do NOT proceed to save them, we cannot so return the errors if (ModelState.IsValid == false) { MemberDisplay forDisplay = _umbracoMapper.Map(contentItem.PersistedContent); forDisplay.Errors = ModelState.ToErrorDictionary(); throw HttpResponseException.CreateValidationErrorResponse(forDisplay); } IMemberType memberType = _memberTypeService.Get(contentItem.ContentTypeAlias); if (memberType == null) { throw new InvalidOperationException($"No member type found with alias {contentItem.ContentTypeAlias}"); } // Create the member with the MemberManager var identityMember = UmbracoMembersIdentityUser.CreateNew( contentItem.Username, contentItem.Email, memberType.Alias, contentItem.Name); // 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 currentRoles = _memberService.GetAllRoles(contentItem.PersistedContent.Username); // find the ones to remove and remove them IEnumerable roles = currentRoles.ToList(); string[] rolesToRemove = roles.Except(contentItem.Groups).ToArray(); // Depending on the action we need to first do a create or update using the membership manager // this ensures that passwords are formatted correctly and also performs the validation on the provider itself. switch (contentItem.Action) { case ContentSaveAction.Save: UpdateMemberData(contentItem); break; case ContentSaveAction.SaveNew: IdentityResult identityResult = await CreateMemberAsync(contentItem, identityMember); break; default: // we don't support anything else for members throw new HttpResponseException(HttpStatusCode.NotFound); } // TODO: There's 3 things saved here and we should do this all in one transaction, // which we can do here by wrapping in a scope // but it would be nicer to have this taken care of within the Save method itself // 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()) { _memberService.DissociateRoles(new[] { contentItem.PersistedContent.Username }, rolesToRemove); } // find the ones to add and add them string[] toAdd = contentItem.Groups.Except(roles).ToArray(); if (toAdd.Any()) { // add the ones submitted _memberService.AssignRoles(new[] { contentItem.PersistedContent.Username }, toAdd); } // return the updated model MemberDisplay display = _umbracoMapper.Map(contentItem.PersistedContent); // lastly, if it is not valid, add the model state to the outgoing object and throw a 403 HandleInvalidModelState(display); ILocalizedTextService localizedTextService = _localizedTextService; // put the correct messages in switch (contentItem.Action) { case ContentSaveAction.Save: case ContentSaveAction.SaveNew: display.AddSuccessNotification( localizedTextService.Localize("speechBubbles/editMemberSaved"), localizedTextService.Localize("speechBubbles/editMemberSaved")); break; } return display; } /// /// Maps the property values to the persisted entity /// /// The member content item to map properties from private void MapPropertyValues(MemberSave contentItem) { // Don't update the name if it is empty if (contentItem.Name.IsNullOrWhiteSpace() == false) { contentItem.PersistedContent.Name = contentItem.Name; } // map the custom properties - this will already be set for new entities in our member binder contentItem.PersistedContent.Email = contentItem.Email; contentItem.PersistedContent.Username = contentItem.Username; // use the base method to map the rest of the properties MapPropertyValuesForPersistence( contentItem, contentItem.PropertyCollectionDto, (save, property) => property.GetValue(), // get prop val (save, property, v) => property.SetValue(v), // set prop val null); // member are all invariant } /// /// Create a member from the supplied member content data /// /// All member password processing and creation is done via the identity manager /// /// Member content data /// The identity member to update /// The identity result of the created member private async Task CreateMemberAsync(MemberSave contentItem, UmbracoMembersIdentityUser identityMember) { IdentityResult created = await _memberManager.CreateAsync(identityMember, contentItem.Password.NewPassword); if (created.Succeeded == false) { throw HttpResponseException.CreateNotificationValidationErrorResponse(created.Errors.ToErrorMessage()); } // now re-look the member back up which will now exist IMember member = _memberService.GetByEmail(contentItem.Email); member.CreatorId = _backOfficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Id; // should this be removed since we've moved passwords out? member.RawPasswordValue = identityMember.PasswordHash; member.Comments = contentItem.Comments; // since the back office user is creating this member, they will be set to approved member.IsApproved = true; // map the save info over onto the user member = _umbracoMapper.Map(contentItem, member); contentItem.PersistedContent = member; return created; } private void ValidateMemberData(MemberSave contentItem) { if (contentItem.Name.IsNullOrWhiteSpace()) { ModelState.AddPropertyError( new ValidationResult("Invalid user name", new[] { "value" }), $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login"); } IMember byUsername = _memberService.GetByUsername(contentItem.Username); if (byUsername != null && byUsername.Key != contentItem.Key) { ModelState.AddPropertyError( new ValidationResult("Username is already in use", new[] { "value" }), $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login"); } IMember byEmail = _memberService.GetByEmail(contentItem.Email); if (byEmail != null && byEmail.Key != contentItem.Key) { ModelState.AddPropertyError( new ValidationResult("Email address is already in use", new[] { "value" }), $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email"); } if (contentItem.Password != null && !contentItem.Password.NewPassword.IsNullOrWhiteSpace()) { Task> result = _memberManager.ValidatePassword(contentItem.Password.NewPassword); if (result.Result.Exists(x => x.Succeeded == false)) { ModelState.AddPropertyError( new ValidationResult($"Invalid password: {MapErrors(result.Result)}", new[] { "value" }), $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}password"); } } else { ModelState.AddPropertyError( new ValidationResult("Password cannot be empty", new[] { "value" }), $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}password"); } } private string MapErrors(List result) { var sb = new StringBuilder(); IEnumerable errors = result.Where(x => x.Succeeded == false); foreach (IdentityResult error in errors) { sb.AppendLine(error.Errors.ToErrorMessage()); } return sb.ToString(); } /// /// Update the member security data /// If the password has been reset then this method will return the reset/generated password, otherwise will return null. /// /// The member to save private void UpdateMemberData(MemberSave memberSave) { memberSave.PersistedContent.WriterId = _backOfficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Id; // If the user doesn't have access to sensitive values, then we need to check if any of the built in member property types // have been marked as sensitive. If that is the case we cannot change these persisted values no matter what value has been posted. // There's only 3 special ones we need to deal with that are part of the MemberSave instance: Comments, IsApproved, IsLockedOut // but we will take care of this in a generic way below so that it works for all props. if (!_backOfficeSecurityAccessor.BackOfficeSecurity.CurrentUser.HasAccessToSensitiveData()) { IMemberType memberType = _memberTypeService.Get(memberSave.PersistedContent.ContentTypeId); var sensitiveProperties = memberType .PropertyTypes.Where(x => memberType.IsSensitiveProperty(x.Alias)) .ToList(); foreach (IPropertyType sensitiveProperty in sensitiveProperties) { ContentPropertyBasic destProp = memberSave.Properties.FirstOrDefault(x => x.Alias == sensitiveProperty.Alias); if (destProp != null) { // if found, change the value of the contentItem model to the persisted value so it remains unchanged object origValue = memberSave.PersistedContent.GetValue(sensitiveProperty.Alias); destProp.Value = origValue; } } } var isLockedOut = memberSave.IsLockedOut; // if they were locked but now they are trying to be unlocked if (memberSave.PersistedContent.IsLockedOut && isLockedOut == false) { memberSave.PersistedContent.IsLockedOut = false; memberSave.PersistedContent.FailedPasswordAttempts = 0; } else if (!memberSave.PersistedContent.IsLockedOut && isLockedOut) { // NOTE: This should not ever happen unless someone is mucking around with the request data. // An admin cannot simply lock a user, they get locked out by password attempts, but an admin can un-approve them ModelState.AddModelError("custom", "An admin cannot lock a user"); } // no password changes then exit ? if (memberSave.Password != null) { // set the password memberSave.PersistedContent.RawPasswordValue = _memberManager.GeneratePassword(); } } /// /// Permanently deletes a member /// /// Guid of the member to delete /// The result of the deletion /// [HttpPost] public IActionResult DeleteByKey(Guid key) { IMember foundMember = _memberService.GetByKey(key); if (foundMember == null) { return HandleContentNotFound(key, false); } _memberService.Delete(foundMember); return Ok(); } /// /// Exports member data based on their unique Id /// /// The unique member identifier /// [HttpGet] public IActionResult ExportMemberData(Guid key) { IUser currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity.CurrentUser; if (currentUser.HasAccessToSensitiveData() == false) { return Forbid(); } MemberExportModel member = ((MemberService)_memberService).ExportMember(key); if (member is null) { throw new NullReferenceException("No member found with key " + key); } var json = _jsonSerializer.Serialize(member); var fileName = $"{member.Name}_{member.Email}.txt"; // Set custom header so umbRequestHelper.downloadFile can save the correct filename HttpContext.Response.Headers.Add("x-filename", fileName); return File(Encoding.UTF8.GetBytes(json), MediaTypeNames.Application.Octet, fileName); } } }