using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Net.Http; using System.Net.Mime; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core; using Umbraco.Cms.Core.ContentApps; using Umbraco.Cms.Core.Dictionary; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Implement; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Web.BackOffice.Extensions; using Umbraco.Cms.Web.BackOffice.Filters; using Umbraco.Cms.Web.BackOffice.ModelBinders; using Umbraco.Cms.Web.BackOffice.Security; using Umbraco.Cms.Web.Common.ActionsResults; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Cms.Web.Common.Filters; using Umbraco.Cms.Web.Common.Security; using Umbraco.Extensions; namespace Umbraco.Cms.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 IUmbracoMapper _umbracoMapper; private readonly IMemberService _memberService; private readonly IMemberTypeService _memberTypeService; private readonly IMemberManager _memberManager; private readonly IDataTypeService _dataTypeService; private readonly ILocalizedTextService _localizedTextService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; private readonly IJsonSerializer _jsonSerializer; private readonly IShortStringHelper _shortStringHelper; private readonly IPasswordChanger _passwordChanger; private readonly IScopeProvider _scopeProvider; /// /// 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 /// The password changer public MemberController( ICultureDictionary cultureDictionary, ILoggerFactory loggerFactory, IShortStringHelper shortStringHelper, IEventMessagesFactory eventMessages, ILocalizedTextService localizedTextService, PropertyEditorCollection propertyEditors, IUmbracoMapper umbracoMapper, IMemberService memberService, IMemberTypeService memberTypeService, IMemberManager memberManager, IDataTypeService dataTypeService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor, IJsonSerializer jsonSerializer, IPasswordChanger passwordChanger, IScopeProvider scopeProvider) : 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; _shortStringHelper = shortStringHelper; _passwordChanger = passwordChanger; _scopeProvider = scopeProvider; } /// /// 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); string name = foundType != null ? foundType.Name : listName; var apps = new List(); apps.Add(ListViewContentAppFactory.CreateContentApp(_dataTypeService, _propertyEditors, listName, "member", Core.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) { 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 ActionResult GetEmpty(string contentTypeAlias = null) { if (contentTypeAlias == null) { return NotFound(); } IMemberType contentType = _memberTypeService.Get(contentTypeAlias); if (contentType == null) { return 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("The member content item was null"); } // 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); await ValidateMemberDataAsync(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(); return new ValidationErrorResult(forDisplay); } // Create a scope here which will wrap all child data operations in a single transaction. // We'll complete this at the end of this method if everything succeeeds, else // all data operations will roll back. using IScope scope = _scopeProvider.CreateScope(); // 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: ActionResult updateSuccessful = await UpdateMemberAsync(contentItem); if (!(updateSuccessful.Result is null)) { return updateSuccessful.Result; } break; case ContentSaveAction.SaveNew: ActionResult createSuccessful = await CreateMemberAsync(contentItem); if (!(createSuccessful.Result is null)) { return createSuccessful.Result; } break; default: // we don't support anything else for members return NotFound(); } // 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 if (!ModelState.IsValid) { display.Errors = ModelState.ToErrorDictionary(); return new ValidationErrorResult(display, StatusCodes.Status403Forbidden); } // 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; } // Mark transaction to commit all changes scope.Complete(); 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.IsApproved = contentItem.IsApproved; contentItem.PersistedContent.Email = contentItem.Email.Trim(); 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 result of the created member private async Task> CreateMemberAsync(MemberSave contentItem) { IMemberType memberType = _memberTypeService.Get(contentItem.ContentTypeAlias); if (memberType == null) { throw new InvalidOperationException($"No member type found with alias {contentItem.ContentTypeAlias}"); } var identityMember = MemberIdentityUser.CreateNew( contentItem.Username, contentItem.Email, memberType.Alias, contentItem.IsApproved, contentItem.Name); IdentityResult created = await _memberManager.CreateAsync(identityMember, contentItem.Password.NewPassword); if (created.Succeeded == false) { return new ValidationErrorResult(created.Errors.ToErrorMessage()); } // now re-look up the member, which will now exist IMember member = _memberService.GetByEmail(contentItem.Email); // map the save info over onto the user member = _umbracoMapper.Map(contentItem, member); int creatorId = _backOfficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Id; member.CreatorId = creatorId; // assign the mapped property values that are not part of the identity properties string[] builtInAliases = ConventionsHelper.GetStandardPropertyTypeStubs(_shortStringHelper).Select(x => x.Key).ToArray(); foreach (ContentPropertyBasic property in contentItem.Properties) { if (builtInAliases.Contains(property.Alias) == false) { member.Properties[property.Alias].SetValue(property.Value); } } // now the member has been saved via identity, resave the member with mapped content properties _memberService.Save(member); contentItem.PersistedContent = member; ActionResult rolesChanged = await AddOrUpdateRoles(contentItem.Groups, identityMember); if (!rolesChanged.Value && rolesChanged.Result != null) { return rolesChanged.Result; } return true; } /// /// Update existing member data /// /// The member to save /// /// We need to use both IMemberService and ASP.NET Identity to do our updates because Identity is responsible for passwords/security. /// When this method is called, the IMember will already have updated/mapped values from the http POST. /// So then we do this in order: /// 1. Deal with sensitive property values on IMember /// 2. Use IMemberService to persist all changes /// 3. Use ASP.NET and MemberUserManager to deal with lockouts /// 4. Use ASP.NET, MemberUserManager and password changer to deal with passwords /// 5. Deal with groups/roles /// private async Task> UpdateMemberAsync(MemberSave contentItem) { contentItem.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(contentItem.PersistedContent.ContentTypeId); var sensitiveProperties = memberType .PropertyTypes.Where(x => memberType.IsSensitiveProperty(x.Alias)) .ToList(); foreach (IPropertyType sensitiveProperty in sensitiveProperties) { // TODO: This logic seems to deviate from the logic that is in v8 where we are explitly checking // against 3 properties: Comments, IsApproved, IsLockedOut, is the v8 version incorrect? ContentPropertyBasic destProp = contentItem.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 = contentItem.PersistedContent.GetValue(sensitiveProperty.Alias); destProp.Value = origValue; } } } // First save the IMember with mapped values before we start updating data with aspnet identity _memberService.Save(contentItem.PersistedContent); bool needsResync = false; MemberIdentityUser identityMember = await _memberManager.FindByIdAsync(contentItem.Id.ToString()); if (identityMember == null) { return new ValidationErrorResult("Member was not found"); } // Handle unlocking with the member manager (takes care of other nuances) if (identityMember.IsLockedOut && contentItem.IsLockedOut == false) { IdentityResult unlockResult = await _memberManager.SetLockoutEndDateAsync(identityMember, DateTimeOffset.Now.AddMinutes(-1)); if (unlockResult.Succeeded == false) { return new ValidationErrorResult( $"Could not unlock for member {contentItem.Id} - error {unlockResult.Errors.ToErrorMessage()}"); } needsResync = true; } else if (identityMember.IsLockedOut == false && contentItem.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 unlock them return new ValidationErrorResult("An admin cannot lock a member"); } // If we're changing the password... // Handle changing with the member manager & password changer (takes care of other nuances) if (contentItem.Password != null) { IdentityResult validatePassword = await _memberManager.ValidatePasswordAsync(contentItem.Password.NewPassword); if (validatePassword.Succeeded == false) { return new ValidationErrorResult(validatePassword.Errors.ToErrorMessage()); } Attempt intId = identityMember.Id.TryConvertTo(); if (intId.Success == false) { return new ValidationErrorResult("Member ID was not valid"); } var changingPasswordModel = new ChangingPasswordModel { Id = intId.Result, OldPassword = contentItem.Password.OldPassword, NewPassword = contentItem.Password.NewPassword, }; // Change and persist the password Attempt passwordChangeResult = await _passwordChanger.ChangePasswordWithIdentityAsync(changingPasswordModel, _memberManager); if (!passwordChangeResult.Success) { foreach (string memberName in passwordChangeResult.Result?.ChangeError?.MemberNames ?? Enumerable.Empty()) { ModelState.AddModelError(memberName, passwordChangeResult.Result.ChangeError?.ErrorMessage ?? string.Empty); } return new ValidationErrorResult(new SimpleValidationModel(ModelState.ToErrorDictionary())); } needsResync = true; } // Update the roles and check for changes ActionResult rolesChanged = await AddOrUpdateRoles(contentItem.Groups, identityMember); if (!rolesChanged.Value && rolesChanged.Result != null) { return rolesChanged.Result; } else { needsResync = true; } // If there have been underlying changes made by ASP.NET Identity, then we need to resync the // IMember on the PersistedContent with what is stored since it will be mapped to display. if (needsResync) { contentItem.PersistedContent = _memberService.GetById(contentItem.PersistedContent.Id); } return true; } private async Task ValidateMemberDataAsync(MemberSave contentItem) { if (contentItem.Name.IsNullOrWhiteSpace()) { ModelState.AddPropertyError( new ValidationResult("Invalid user name", new[] { "value" }), $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login"); return false; } if (contentItem.Password != null && !contentItem.Password.NewPassword.IsNullOrWhiteSpace()) { IdentityResult validPassword = await _memberManager.ValidatePasswordAsync(contentItem.Password.NewPassword); if (!validPassword.Succeeded) { ModelState.AddPropertyError( new ValidationResult("Invalid password: " + MapErrors(validPassword.Errors), new[] { "value" }), $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}password"); return false; } } 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"); return false; } 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"); return false; } return true; } private string MapErrors(IEnumerable result) { var sb = new StringBuilder(); IEnumerable identityErrors = result.ToList(); foreach (IdentityError error in identityErrors) { string errorString = $"{error.Description}"; sb.AppendLine(errorString); } return sb.ToString(); } /// /// Add or update the identity roles /// /// The groups to updates /// The member as an identity user private async Task> AddOrUpdateRoles(IEnumerable groups, MemberIdentityUser identityMember) { var hasChanges = false; // 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 = await _memberManager.GetRolesAsync(identityMember); // find the ones to remove and remove them IEnumerable roles = currentRoles.ToList(); string[] rolesToRemove = roles.Except(groups).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) { return ValidationErrorResult.CreateNotificationValidationErrorResult(identityResult.Errors.ToErrorMessage()); } hasChanges = true; } // find the ones to add and add them string[] toAdd = groups.Except(roles).ToArray(); if (toAdd.Any()) { // add the ones submitted IdentityResult identityResult = await _memberManager.AddToRolesAsync(identityMember, toAdd); if (!identityResult.Succeeded) { return ValidationErrorResult.CreateNotificationValidationErrorResult(identityResult.Errors.ToErrorMessage()); } hasChanges = true; } return hasChanges; } /// /// 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); } _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); } } }