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.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.Strings;
using Umbraco.Extensions;
using Umbraco.Infrastructure.Security;
using Umbraco.Infrastructure.Services.Implement;
using Umbraco.Web.BackOffice.Filters;
using Umbraco.Web.BackOffice.ModelBinders;
using Umbraco.Web.Common.ActionsResults;
using Umbraco.Web.Common.Attributes;
using Umbraco.Web.Common.Authorization;
using Umbraco.Web.Common.Filters;
using Umbraco.Web.ContentApps;
using Umbraco.Web.Models.ContentEditing;
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 IMembersUserManager _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,
IMembersUserManager 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)
{
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);
}
// 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:
Task> updateSuccessful = UpdateMemberAsync(contentItem);
break;
case ContentSaveAction.SaveNew:
Task> createSuccessful = CreateMemberAsync(contentItem);
break;
default:
// we don't support anything else for members
return 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
// 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);
}
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 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 = MembersIdentityUser.CreateNew(
contentItem.Username,
contentItem.Email,
memberType.Alias,
contentItem.Name);
IdentityResult created = await _memberManager.CreateAsync(identityMember, contentItem.Password.NewPassword);
if (created.Succeeded == false)
{
return new ValidationErrorResult(created.Errors.ToErrorMessage());
}
// now re-look the member back up which will now exist
IMember member = _memberService.GetByEmail(contentItem.Email);
int creatorId = _backOfficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Id;
member.CreatorId = creatorId;
// map the save info over onto the user
member = _umbracoMapper.Map(contentItem, member);
// now the member has been saved via identity, resave the member with mapped content properties
_memberService.Save(member);
contentItem.PersistedContent = member;
AddOrUpdateRoles(contentItem);
return true;
}
///
/// 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 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)
{
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;
}
}
}
bool isLockedOut = contentItem.IsLockedOut;
// if they were locked but now they are trying to be unlocked
if (contentItem.PersistedContent.IsLockedOut && isLockedOut == false)
{
contentItem.PersistedContent.IsLockedOut = false;
contentItem.PersistedContent.FailedPasswordAttempts = 0;
}
else if (!contentItem.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");
}
MembersIdentityUser identityMember = await _memberManager.FindByIdAsync(contentItem.Id.ToString());
if (identityMember == null)
{
return new ValidationErrorResult("Member was not found");
}
if (contentItem.Password != null)
{
IdentityResult validatePassword = await _memberManager.ValidatePasswordAsync(contentItem.Password.NewPassword);
if (validatePassword.Succeeded == false)
{
return new ValidationErrorResult(validatePassword.Errors.ToErrorMessage());
}
string newPassword = _memberManager.HashPassword(contentItem.Password.NewPassword);
identityMember.PasswordHash = newPassword;
}
IdentityResult updatedResult = await _memberManager.UpdateAsync(identityMember);
if (updatedResult.Succeeded == false)
{
return new ValidationErrorResult(updatedResult.Errors.ToErrorMessage());
}
contentItem.PersistedContent.RawPasswordValue = identityMember.PasswordHash;
if (identityMember.LastPasswordChangeDateUtc != null)
{
contentItem.PersistedContent.LastPasswordChangeDate = DateTime.Now;
}
_memberService.Save(contentItem.PersistedContent);
AddOrUpdateRoles(contentItem);
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();
}
///
/// TODO: refactor using identity roles
///
///
private void AddOrUpdateRoles(MemberSave contentItem)
{
// 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();
// 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);
}
}
///
/// 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);
}
}
}