using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Web; using System.Web.Security; using Umbraco.Core; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Web.Models; using Umbraco.Web.PublishedCache; using Umbraco.Core.Cache; using Umbraco.Web.Composing; using Umbraco.Core.Models.PublishedContent; using Umbraco.Core.Services; using Umbraco.Core.Strings; using Umbraco.Web.Editors; using Umbraco.Web.Security.Providers; using System.ComponentModel.DataAnnotations; namespace Umbraco.Web.Security { /// /// A helper class for handling Members /// public class MembershipHelper { private readonly MembersMembershipProvider _membershipProvider; private readonly RoleProvider _roleProvider; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IMemberService _memberService; private readonly IMemberTypeService _memberTypeService; private readonly IPublicAccessService _publicAccessService; private readonly AppCaches _appCaches; private readonly ILogger _logger; private readonly IShortStringHelper _shortStringHelper; private readonly IEntityService _entityService; #region Constructors public MembershipHelper ( IHttpContextAccessor httpContextAccessor, IPublishedMemberCache memberCache, MembersMembershipProvider membershipProvider, RoleProvider roleProvider, IMemberService memberService, IMemberTypeService memberTypeService, IPublicAccessService publicAccessService, AppCaches appCaches, ILogger logger, IShortStringHelper shortStringHelper, IEntityService entityService ) { MemberCache = memberCache; _httpContextAccessor = httpContextAccessor; _memberService = memberService; _memberTypeService = memberTypeService; _publicAccessService = publicAccessService; _appCaches = appCaches; _logger = logger; _shortStringHelper = shortStringHelper; _membershipProvider = membershipProvider ?? throw new ArgumentNullException(nameof(membershipProvider)); _roleProvider = roleProvider ?? throw new ArgumentNullException(nameof(roleProvider)); _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); } #endregion protected IPublishedMemberCache MemberCache { get; } /// /// Check if a document object is protected by the "Protect Pages" functionality in umbraco /// /// The full path of the document object to check /// True if the document object is protected public virtual bool IsProtected(string path) { //this is a cached call return _publicAccessService.IsProtected(path); } /// /// Check if the current user has access to a document /// /// The full path of the document object to check /// True if the current user has access or if the current document isn't protected public virtual bool MemberHasAccess(string path) { //cache this in the request cache return _appCaches.RequestCache.GetCacheItem($"{typeof(MembershipHelper)}.MemberHasAccess-{path}", () => { if (IsProtected(path)) { return IsLoggedIn() && HasAccess(path, Roles.Provider); } return true; }); } /// /// This will check if the member has access to this path /// /// /// /// private bool HasAccess(string path, RoleProvider roleProvider) { return _publicAccessService.HasAccess(path, CurrentUserName, roleProvider.GetRolesForUser); } /// /// Updates the currently logged in members profile /// /// /// /// The updated MembershipUser object /// public virtual Attempt UpdateMemberProfile(ProfileModel model) { if (IsLoggedIn() == false) { throw new NotSupportedException("No member is currently logged in"); } //get the current membership user var provider = _membershipProvider; var membershipUser = provider.GetCurrentUser(); //NOTE: This should never happen since they are logged in if (membershipUser == null) throw new InvalidOperationException("Could not find member with username " + _httpContextAccessor.GetRequiredHttpContext().User.Identity.Name); try { //check if the email needs to change if (model.Email.InvariantEquals(membershipUser.Email) == false) { //Use the membership provider to change the email since that is configured to do the checks to check for unique emails if that is configured. var requiresUpdating = UpdateMember(membershipUser, provider, model.Email); membershipUser = requiresUpdating.Result; } } catch (Exception ex) { //This will occur if an email already exists! return Attempt.Fail(ex); } var member = GetCurrentPersistedMember(); //NOTE: If changing the username is a requirement, than that needs to be done via the IMember directly since MembershipProvider's natively do // not support changing a username! if (model.Name != null && member.Name != model.Name) { member.Name = model.Name; } var memberType = _memberTypeService.Get(member.ContentTypeId); if (model.MemberProperties != null) { foreach (var property in model.MemberProperties //ensure the property they are posting exists .Where(p => memberType.PropertyTypeExists(p.Alias)) .Where(property => member.Properties.Contains(property.Alias)) //needs to be editable .Where(p => memberType.MemberCanEditProperty(p.Alias))) { member.Properties[property.Alias].SetValue(property.Value); } } _memberService.Save(member); //reset the FormsAuth cookie since the username might have changed FormsAuthentication.SetAuthCookie(member.Username, true); return Attempt.Succeed(membershipUser); } /// /// Registers a new member /// /// /// /// /// true to log the member in upon successful registration /// /// public virtual MembershipUser RegisterMember(RegisterModel model, out MembershipCreateStatus status, bool logMemberIn = true) { model.Username = (model.UsernameIsEmail || model.Username == null) ? model.Email : model.Username; MembershipUser membershipUser; var provider = _membershipProvider; membershipUser = ((UmbracoMembershipProviderBase)provider).CreateUser( model.MemberTypeAlias, model.Username, model.Password, model.Email, // TODO: Support q/a http://issues.umbraco.org/issue/U4-3213 null, null, true, null, out status); if (status != MembershipCreateStatus.Success) return null; var member = _memberService.GetByUsername(membershipUser.UserName); member.Name = model.Name; if (model.MemberProperties != null) { foreach (var property in model.MemberProperties.Where(p => p.Value != null) .Where(property => member.Properties.Contains(property.Alias))) { member.Properties[property.Alias].SetValue(property.Value); } } _memberService.Save(member); if (logMemberIn) { //Set member online provider.GetUser(model.Username, true); //Log them in FormsAuthentication.SetAuthCookie(membershipUser.UserName, model.CreatePersistentLoginCookie); } return membershipUser; } /// /// A helper method to perform the validation and logging in of a member - this is simply wrapping standard membership provider and asp.net forms auth logic. /// /// /// /// public virtual bool Login(string username, string password) { var provider = _membershipProvider; //Validate credentials if (provider.ValidateUser(username, password) == false) { return false; } //Set member online var member = provider.GetUser(username, true); if (member == null) { //this should not happen Current.Logger.Warn("The member validated but then no member was returned with the username {Username}", username); return false; } //Log them in FormsAuthentication.SetAuthCookie(member.UserName, true); return true; } /// /// Logs out the current member /// public virtual void Logout() { FormsAuthentication.SignOut(); } #region Querying for front-end public virtual IPublishedContent GetByProviderKey(object key) { return MemberCache.GetByProviderKey(key); } public virtual IEnumerable GetByProviderKeys(IEnumerable keys) { return keys?.Select(GetByProviderKey).WhereNotNull() ?? Enumerable.Empty(); } public virtual IPublishedContent GetById(int memberId) { return MemberCache.GetById(memberId); } public virtual IEnumerable GetByIds(IEnumerable memberIds) { return memberIds?.Select(GetById).WhereNotNull() ?? Enumerable.Empty(); } public virtual IPublishedContent GetById(Guid memberId) { return GetByProviderKey(memberId); } public virtual IEnumerable GetByIds(IEnumerable memberIds) { return GetByProviderKeys(memberIds.OfType()); } public virtual IPublishedContent GetByUsername(string username) { return MemberCache.GetByUsername(username); } public virtual IPublishedContent GetByEmail(string email) { return MemberCache.GetByEmail(email); } public virtual IPublishedContent Get(Udi udi) { var guidUdi = udi as GuidUdi; if (guidUdi == null) return null; var umbracoType = UdiEntityTypeHelper.ToUmbracoObjectType(udi.EntityType); switch (umbracoType) { case UmbracoObjectTypes.Member: // TODO: need to implement Get(guid)! var memberAttempt = _entityService.GetId(guidUdi.Guid, umbracoType); if (memberAttempt.Success) return GetById(memberAttempt.Result); break; } return null; } /// /// Returns the currently logged in member as IPublishedContent /// /// public virtual IPublishedContent GetCurrentMember() { if (IsLoggedIn() == false) { return null; } var result = GetCurrentPersistedMember(); return result == null ? null : MemberCache.GetByMember(result); } /// /// Returns the currently logged in member id, -1 if they are not logged in /// /// public int GetCurrentMemberId() { if (IsLoggedIn() == false) { return -1; } var result = GetCurrentMember(); return result?.Id ?? -1; } #endregion #region Model Creation methods for member data editing on the front-end /// /// Creates a new profile model filled in with the current members details if they are logged in which allows for editing /// profile properties /// /// public virtual ProfileModel GetCurrentMemberProfileModel() { if (IsLoggedIn() == false) { return null; } var provider = _membershipProvider; var membershipUser = provider.GetCurrentUserOnline(); var member = GetCurrentPersistedMember(); //this shouldn't happen but will if the member is deleted in the back office while the member is trying // to use the front-end! if (member == null) { //log them out since they've been removed FormsAuthentication.SignOut(); return null; } var model = ProfileModel.CreateModel(); model.Name = member.Name; model.MemberTypeAlias = member.ContentTypeAlias; model.Email = membershipUser.Email; model.UserName = membershipUser.UserName; model.Comment = membershipUser.Comment; model.IsApproved = membershipUser.IsApproved; model.IsLockedOut = membershipUser.IsLockedOut; model.LastLockoutDate = membershipUser.LastLockoutDate; model.CreationDate = membershipUser.CreationDate; model.LastLoginDate = membershipUser.LastLoginDate; model.LastActivityDate = membershipUser.LastActivityDate; model.LastPasswordChangedDate = membershipUser.LastPasswordChangedDate; var memberType = _memberTypeService.Get(member.ContentTypeId); var builtIns = ConventionsHelper.GetStandardPropertyTypeStubs(_shortStringHelper).Select(x => x.Key).ToArray(); model.MemberProperties = GetMemberPropertiesViewModel(memberType, builtIns, member).ToList(); return model; } /// /// Creates a model to use for registering new members with custom member properties /// /// /// public virtual RegisterModel CreateRegistrationModel(string memberTypeAlias = null) { var provider = _membershipProvider; memberTypeAlias = memberTypeAlias ?? Constants.Conventions.MemberTypes.DefaultAlias; var memberType = _memberTypeService.Get(memberTypeAlias); if (memberType == null) throw new InvalidOperationException("Could not find a member type with alias " + memberTypeAlias); var builtIns = ConventionsHelper.GetStandardPropertyTypeStubs(_shortStringHelper).Select(x => x.Key).ToArray(); var model = RegisterModel.CreateModel(); model.MemberTypeAlias = memberTypeAlias; model.MemberProperties = GetMemberPropertiesViewModel(memberType, builtIns).ToList(); return model; } private IEnumerable GetMemberPropertiesViewModel(IMemberType memberType, IEnumerable builtIns, IMember member = null) { var viewProperties = new List(); foreach (var prop in memberType.PropertyTypes .Where(x => builtIns.Contains(x.Alias) == false && memberType.MemberCanEditProperty(x.Alias)) .OrderBy(p => p.SortOrder)) { var value = string.Empty; if (member != null) { var propValue = member.Properties[prop.Alias]; if (propValue != null && propValue.GetValue() != null) { value = propValue.GetValue().ToString(); } } var viewProperty = new UmbracoProperty { Alias = prop.Alias, Name = prop.Name, Value = value }; // TODO: Perhaps one day we'll ship with our own EditorTempates but for now developers // can just render their own. ////This is a rudimentary check to see what data template we should render //// if developers want to change the template they can do so dynamically in their views or controllers //// for a given property. ////These are the default built-in MVC template types: “Boolean”, “Decimal”, “EmailAddress”, “HiddenInput”, “HTML”, “Object”, “String”, “Text”, and “Url” //// by default we'll render a text box since we've defined that metadata on the UmbracoProperty.Value property directly. //if (prop.DataTypeId == new Guid(Constants.PropertyEditors.TrueFalse)) //{ // viewProperty.EditorTemplate = "UmbracoBoolean"; //} //else //{ // switch (prop.DataTypeDatabaseType) // { // case DataTypeDatabaseType.Integer: // viewProperty.EditorTemplate = "Decimal"; // break; // case DataTypeDatabaseType.Ntext: // viewProperty.EditorTemplate = "Text"; // break; // case DataTypeDatabaseType.Date: // case DataTypeDatabaseType.Nvarchar: // break; // } //} viewProperties.Add(viewProperty); } return viewProperties; } #endregion /// /// Gets the current user's roles. /// /// Roles are cached per user name, at request level. public IEnumerable GetCurrentUserRoles() => GetUserRoles(CurrentUserName); /// /// Gets a user's roles. /// /// Roles are cached per user name, at request level. public IEnumerable GetUserRoles(string userName) { // optimize by caching per-request (v7 cached per PublishedRequest, in PublishedRouter) var key = "Umbraco.Web.Security.MembershipHelper__Roles__" + userName; return _appCaches.RequestCache.GetCacheItem(key, () => Roles.Provider.GetRolesForUser(userName)); } /// /// Returns the login status model of the currently logged in member. /// /// public virtual LoginStatusModel GetCurrentLoginStatus() { var model = LoginStatusModel.CreateModel(); if (IsLoggedIn() == false) { model.IsLoggedIn = false; return model; } var provider = _membershipProvider; var member = GetCurrentPersistedMember(); //this shouldn't happen but will if the member is deleted in the back office while the member is trying // to use the front-end! if (member == null) { //log them out since they've been removed FormsAuthentication.SignOut(); model.IsLoggedIn = false; return model; } model.Name = member.Name; model.Username = member.Username; model.Email = member.Email; model.IsLoggedIn = true; return model; } /// /// Check if a member is logged in /// /// public bool IsLoggedIn() { var httpContext = _httpContextAccessor.HttpContext; return httpContext?.User != null && httpContext.User.Identity.IsAuthenticated; } /// /// Returns the currently logged in username /// public string CurrentUserName => _httpContextAccessor.GetRequiredHttpContext().User.Identity.Name; /// /// Returns true or false if the currently logged in member is authorized based on the parameters provided /// /// /// /// /// public virtual bool IsMemberAuthorized( IEnumerable allowTypes = null, IEnumerable allowGroups = null, IEnumerable allowMembers = null) { if (allowTypes == null) allowTypes = Enumerable.Empty(); if (allowGroups == null) allowGroups = Enumerable.Empty(); if (allowMembers == null) allowMembers = Enumerable.Empty(); // Allow by default var allowAction = true; if (IsLoggedIn() == false) { // If not logged on, not allowed allowAction = false; } else { var provider = _membershipProvider; string username; var member = GetCurrentPersistedMember(); // If a member could not be resolved from the provider, we are clearly not authorized and can break right here if (member == null) return false; username = member.Username; // If types defined, check member is of one of those types var allowTypesList = allowTypes as IList ?? allowTypes.ToList(); if (allowTypesList.Any(allowType => allowType != string.Empty)) { // Allow only if member's type is in list allowAction = allowTypesList.Select(x => x.ToLowerInvariant()).Contains(member.ContentType.Alias.ToLowerInvariant()); } // If specific members defined, check member is of one of those if (allowAction && allowMembers.Any()) { // Allow only if member's Id is in the list allowAction = allowMembers.Contains(member.Id); } // If groups defined, check member is of one of those groups var allowGroupsList = allowGroups as IList ?? allowGroups.ToList(); if (allowAction && allowGroupsList.Any(allowGroup => allowGroup != string.Empty)) { // Allow only if member is assigned to a group in the list var groups = _roleProvider.GetRolesForUser(username); allowAction = allowGroupsList.Select(s => s.ToLowerInvariant()).Intersect(groups.Select(myGroup => myGroup.ToLowerInvariant())).Any(); } } return allowAction; } /// /// Changes password for a member/user given the membership provider name and the password change model /// /// /// /// /// public virtual Attempt ChangePassword(string username, ChangingPasswordModel passwordModel, string membershipProviderName) { var provider = Membership.Providers[membershipProviderName]; if (provider == null) { throw new InvalidOperationException("Could not find provider with name " + membershipProviderName); } return ChangePassword(username, passwordModel, provider); } /// /// Changes password for a member/user given the membership provider and the password change model /// /// /// /// /// public virtual Attempt ChangePassword(string username, ChangingPasswordModel passwordModel, MembershipProvider membershipProvider) { var passwordChanger = new PasswordChanger(_logger); return ChangePasswordWithMembershipProvider(username, passwordModel, membershipProvider); } /// /// Updates a membership user with all of it's writable properties /// /// /// /// /// /// /// /// /// /// Returns successful if the membership user required updating, otherwise returns failed if it didn't require updating. /// internal Attempt UpdateMember(MembershipUser member, MembershipProvider provider, string email = null, bool? isApproved = null, DateTime? lastLoginDate = null, DateTime? lastActivityDate = null, string comment = null) { var update = false; if (email != null) { if (member.Email != email) update = true; member.Email = email; } if (isApproved.HasValue) { if (member.IsApproved != isApproved.Value) update = true; member.IsApproved = isApproved.Value; } if (lastLoginDate.HasValue) { if (member.LastLoginDate != lastLoginDate.Value) update = true; member.LastLoginDate = lastLoginDate.Value; } if (lastActivityDate.HasValue) { if (member.LastActivityDate != lastActivityDate.Value) update = true; member.LastActivityDate = lastActivityDate.Value; } if (comment != null) { if (member.Comment != comment) update = true; member.Comment = comment; } if (update == false) return Attempt.Fail(member); provider.UpdateUser(member); return Attempt.Succeed(member); } /// /// Returns the currently logged in IMember object - this should never be exposed to the front-end since it's returning a business logic entity! /// /// private IMember GetCurrentPersistedMember() { return _appCaches.RequestCache.GetCacheItem( GetCacheKey("GetCurrentPersistedMember"), () => { var provider = _membershipProvider; var username = provider.GetCurrentUserName(); var member = _memberService.GetByUsername(username); return member; }); } private static string GetCacheKey(string key, params object[] additional) { var sb = new StringBuilder(); sb.Append(typeof (MembershipHelper).Name); sb.Append("-"); sb.Append(key); foreach (var s in additional) { sb.Append("-"); sb.Append(s); } return sb.ToString(); } /// /// Changes password for a member/user given the membership provider and the password change model /// /// The username of the user having their password changed /// /// /// private Attempt ChangePasswordWithMembershipProvider( string username, ChangingPasswordModel passwordModel, MembershipProvider membershipProvider) { var umbracoBaseProvider = membershipProvider as MembershipProviderBase; // YES! It is completely insane how many options you have to take into account based on the membership provider. yikes! if (passwordModel == null) throw new ArgumentNullException(nameof(passwordModel)); if (membershipProvider == null) throw new ArgumentNullException(nameof(membershipProvider)); var userId = -1; //we're not resetting it so we need to try to change it. if (passwordModel.NewPassword.IsNullOrWhiteSpace()) { return Attempt.Fail(new PasswordChangedModel { ChangeError = new ValidationResult("Cannot set an empty password", new[] { "value" }) }); } if (membershipProvider.EnablePasswordRetrieval) { return Attempt.Fail(new PasswordChangedModel { ChangeError = new ValidationResult("Membership providers using encrypted passwords and password retrieval are not supported", new[] { "value" }) }); } //without being able to retrieve the original password if (passwordModel.OldPassword.IsNullOrWhiteSpace()) { //if password retrieval is not enabled but there is no old password we cannot continue return Attempt.Fail(new PasswordChangedModel { ChangeError = new ValidationResult("Password cannot be changed without the old password", new[] { "oldPassword" }) }); } //if an old password is supplied try to change it try { var result = membershipProvider.ChangePassword(username, passwordModel.OldPassword, passwordModel.NewPassword); return result == false ? Attempt.Fail(new PasswordChangedModel { ChangeError = new ValidationResult("Could not change password, invalid username or password", new[] { "oldPassword" }) }) : Attempt.Succeed(new PasswordChangedModel()); } catch (Exception ex) { _logger.Warn(ex, "Could not change member password"); return Attempt.Fail(new PasswordChangedModel { ChangeError = new ValidationResult("Could not change password, error: " + ex.Message + " (see log for full details)", new[] { "value" }) }); } } } }