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.Identity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Core;
using Umbraco.Core.Configuration.Models;
using Umbraco.Core.Dictionary;
using Umbraco.Core.Events;
using Umbraco.Core.Mapping;
using Umbraco.Core.Members;
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;
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;
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;
}
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
///
///
///
public MemberListDisplay GetListNodeDisplay(string listName)
{
var 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
///
///
///
[TypeFilter(typeof(OutgoingEditorModelEventAttribute))]
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
///
///
///
[TypeFilter(typeof(OutgoingEditorModelEventAttribute))]
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);
}
IMember emptyContent = new Member(contentType);
string newPassword = _memberManager.GeneratePassword();
emptyContent.AdditionalData["NewPassword"] = newPassword;
return _umbracoMapper.Map(emptyContent);
}
///
/// Saves member
///
///
[FileUploadCleanupFilter]
[TypeFilter(typeof(OutgoingEditorModelEventAttribute))]
[MemberSaveValidation]
public async Task> PostSave(
[ModelBinder(typeof(MemberBinder))]
MemberSave contentItem)
{
//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)
{
var forDisplay = _umbracoMapper.Map(contentItem.PersistedContent);
forDisplay.Errors = ModelState.ToErrorDictionary();
throw HttpResponseException.CreateValidationErrorResponse(forDisplay);
}
//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.
var currRoles = _memberService.GetAllRoles(contentItem.PersistedContent.Username);
//find the ones to remove and remove them
IEnumerable roles = currRoles.ToList();
var rolesToRemove = roles.Except(contentItem.Groups).ToArray();
//Depending on the action we need to first do a create or update using the membership provider
// 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:
contentItem.PersistedContent = await CreateMemberData(contentItem);
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
//TODO: create/save the IMember: this is now saved in CreateAsync, remove once logic is clarified
//_memberService.Save(contentItem.PersistedContent);
//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
///
///
private void MapPropertyValues(MemberSave contentItem)
{
UpdateName(contentItem);
//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
base.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 aspnet identity MemberUserManager
///
///
///
private async Task CreateMemberData(MemberSave memberSave)
{
if (memberSave == null) throw new ArgumentNullException("memberSave");
if (ModelState.IsValid == false)
{
throw new HttpResponseException(HttpStatusCode.BadRequest, ModelState);
}
//TODO: check if unique
IMemberType memberType = _memberTypeService.Get(memberSave.ContentTypeAlias);
if (memberType == null)
{
throw new InvalidOperationException($"No member type found with alias {memberSave.ContentTypeAlias}");
}
// Create the member with the UserManager
// The 'empty' (special) password format is applied without us having to duplicate that logic
UmbracoMembersIdentityUser identityMember = UmbracoMembersIdentityUser.CreateNew(
memberSave.Username,
memberSave.Email,
memberSave.Name);
//TODO: confirm
identityMember.MemberTypeAlias = memberType.Alias;
IdentityResult created = await _memberManager.CreateAsync(identityMember);
if (created.Succeeded == false)
{
throw HttpResponseException.CreateNotificationValidationErrorResponse(created.Errors.ToErrorMessage());
}
//string resetPassword;
//string password = _memberManager.GeneratePassword();
//IdentityResult result = await _memberManager.AddPasswordAsync(identityMember, password);
//if (result.Succeeded == false)
//{
// throw HttpResponseException.CreateNotificationValidationErrorResponse(created.Errors.ToErrorMessage());
//}
//resetPassword = password;
//now re-look the member back up which will now exist
IMember member = _memberService.GetByEmail(memberSave.Email);
//TODO: previous implementation
//IMember member = new Member(
// memberSave.Name,
// memberSave.Email,
// memberSave.Username,
// memberType,
// true)
//{
// CreatorId = _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Id,
// RawPasswordValue = _memberManager.GeneratePassword(),
// Comments = memberSave.Comments,
// IsApproved = memberSave.IsApproved
//};
//since the back office user is creating this member, they will be set to approved
member.IsApproved = true;
member.CreatorId = _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Id;
member.Comments = memberSave.Comments;
member.IsApproved = memberSave.IsApproved;
//map the save info over onto the user
member = _umbracoMapper.Map(memberSave, member);
return member;
}
///
/// Update the member security data
///
///
///
/// If the password has been reset then this method will return the reset/generated password, otherwise will return null.
///
private void UpdateMemberData(MemberSave memberSave)
{
//TODO: optimise based on new member manager
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())
{
var memberType = _memberTypeService.Get(memberSave.PersistedContent.ContentTypeId);
var sensitiveProperties = memberType
.PropertyTypes.Where(x => memberType.IsSensitiveProperty(x.Alias))
.ToList();
foreach (var sensitiveProperty in sensitiveProperties)
{
var 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
var 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)
return;
// set the password
memberSave.PersistedContent.RawPasswordValue = _memberManager.GeneratePassword();
}
private static void UpdateName(MemberSave memberSave)
{
//Don't update the name if it is empty
if (memberSave.Name.IsNullOrWhiteSpace() == false)
{
memberSave.PersistedContent.Name = memberSave.Name;
}
}
// TODO: This logic should be pulled into the service layer
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())
{
//TODO: implement as per backoffice user
//var validPassword = await _memberManager.CheckPasswordAsync(null, contentItem.Password.NewPassword);
//if (!validPassword)
//{
// ModelState.AddPropertyError(
// new ValidationResult("Invalid password: TODO", new[] { "value" }),
// $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}password");
// return false;
//}
return true;
}
var 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;
}
var 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;
}
///
/// Permanently deletes a member
///
///
///
///
[HttpPost]
public IActionResult DeleteByKey(Guid key)
{
var 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)
{
var 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);
}
}
}