From 5b4b948543be137adebdeccd737d4a362d15908b Mon Sep 17 00:00:00 2001 From: emmagarland Date: Mon, 30 Nov 2020 00:45:38 +0000 Subject: [PATCH] Added more layers to have a vertical slice through Umbraco for CreateMember via backoffice. Still lots to implement and test, not complete and needs early review. --- .../BackOffice/IdentityMapDefinition.cs | 31 +++ .../IUmbracoMembersUserPasswordChecker.cs | 19 ++ .../Members/UmbracoMembersIdentityUser.cs | 52 ++++- ...UmbracoMembersUserPasswordCheckerResult.cs | 12 + .../Members/IUmbracoMembersUserManager.cs | 44 +++- .../Members/UmbracoMembersIdentityBuilder.cs | 40 ++++ .../Members/UmbracoMembersIdentityOptions.cs | 11 + .../Members/UmbracoMembersUserManager.cs | 167 +++++++++++++- .../Members/UmbracoMembersUserStore.cs | 139 +++++++++--- .../UmbracoTestServerTestBase.cs | 1 + ...kOfficeServiceCollectionExtensionsTests.cs | 2 +- ...MembersServiceCollectionExtensionsTests.cs | 43 ++++ .../UmbracoMemberIdentityUserManagerTests.cs | 153 +++++++++++++ .../UmbracoMemberIdentityUserStoreTests.cs | 38 +++- .../Controllers/MemberController.cs | 214 ++++++++++++------ .../Extensions/IdentityBuilderExtensions.cs | 3 +- .../Extensions/UmbracoBuilderExtensions.cs | 4 + .../UmbracoMemberIdentityBuilderExtensions.cs | 23 ++ ...oMembersUserServiceCollectionExtensions.cs | 35 +++ .../Trees/MemberTreeController.cs | 1 + 20 files changed, 905 insertions(+), 127 deletions(-) create mode 100644 src/Umbraco.Core/Members/IUmbracoMembersUserPasswordChecker.cs create mode 100644 src/Umbraco.Core/Members/UmbracoMembersUserPasswordCheckerResult.cs create mode 100644 src/Umbraco.Infrastructure/Members/UmbracoMembersIdentityBuilder.cs create mode 100644 src/Umbraco.Infrastructure/Members/UmbracoMembersIdentityOptions.cs create mode 100644 src/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UmbracoMembersServiceCollectionExtensionsTests.cs create mode 100644 src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Members/UmbracoMemberIdentityUserManagerTests.cs create mode 100644 src/Umbraco.Web.BackOffice/Extensions/UmbracoMemberIdentityBuilderExtensions.cs create mode 100644 src/Umbraco.Web.BackOffice/Extensions/UmbracoMembersUserServiceCollectionExtensions.cs diff --git a/src/Umbraco.Core/BackOffice/IdentityMapDefinition.cs b/src/Umbraco.Core/BackOffice/IdentityMapDefinition.cs index 61fdf82d19..caa8f41ea5 100644 --- a/src/Umbraco.Core/BackOffice/IdentityMapDefinition.cs +++ b/src/Umbraco.Core/BackOffice/IdentityMapDefinition.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Options; using Umbraco.Core.Configuration; using Umbraco.Core.Configuration.Models; using Umbraco.Core.Mapping; +using Umbraco.Core.Members; using Umbraco.Core.Models; using Umbraco.Core.Models.Membership; using Umbraco.Core.Services; @@ -37,6 +38,20 @@ namespace Umbraco.Core.BackOffice target.ResetDirtyProperties(true); target.EnableChangeTracking(); }); + + mapper.Define( + (source, context) => + { + var target = new UmbracoMembersIdentityUser(); + //target.DisableChangeTracking(); + return target; + }, + (source, target, context) => + { + Map(source, target); + //target.ResetDirtyProperties(true); + //target.EnableChangeTracking(); + }); } // Umbraco.Code.MapAll -Id -Groups -LockoutEnabled -PhoneNumber -PhoneNumberConfirmed -TwoFactorEnabled @@ -76,6 +91,22 @@ namespace Umbraco.Core.BackOffice //target.Roles =; } + private void Map(IMember source, UmbracoMembersIdentityUser target) + { + target.Email = source.Email; + target.UserName = source.Username; + target.LastPasswordChangeDateUtc = source.LastPasswordChangeDate.ToUniversalTime(); + //target.LastLoginDateUtc = source.LastLoginDate.ToUniversalTime(); + //target.EmailConfirmed = source.EmailConfirmedDate.HasValue; + target.Name = source.Name; + //target.AccessFailedCount = source.FailedPasswordAttempts; + target.PasswordHash = GetPasswordHash(source.RawPasswordValue); + target.PasswordConfig = source.PasswordConfiguration; + target.IsApproved = source.IsApproved; + //target.SecurityStamp = source.SecurityStamp; + //target.LockoutEndDateUtc = source.IsLockedOut ? DateTime.MaxValue.ToUniversalTime() : (DateTime?)null; + } + private static string GetPasswordHash(string storedPass) { return storedPass.StartsWith(Constants.Security.EmptyPasswordPrefix) ? null : storedPass; diff --git a/src/Umbraco.Core/Members/IUmbracoMembersUserPasswordChecker.cs b/src/Umbraco.Core/Members/IUmbracoMembersUserPasswordChecker.cs new file mode 100644 index 0000000000..b361ca3121 --- /dev/null +++ b/src/Umbraco.Core/Members/IUmbracoMembersUserPasswordChecker.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; + +namespace Umbraco.Core.Members +{ + /// + /// Used by the UmbracoMembersUserManager to check the username/password which allows for developers to more easily + /// set the logic for this procedure. + /// + public interface IUmbracoMembersUserPasswordChecker + { + /// + /// Checks a password for a member + /// + /// + /// + /// + Task CheckPasswordAsync(UmbracoMembersIdentityUser member, string password); + } +} diff --git a/src/Umbraco.Core/Members/UmbracoMembersIdentityUser.cs b/src/Umbraco.Core/Members/UmbracoMembersIdentityUser.cs index c561767024..f3d5661926 100644 --- a/src/Umbraco.Core/Members/UmbracoMembersIdentityUser.cs +++ b/src/Umbraco.Core/Members/UmbracoMembersIdentityUser.cs @@ -12,23 +12,59 @@ namespace Umbraco.Core.Members //TODO: use of identity classes //: IdentityUser, IdentityUserClaim>, { - public int Id; - public string Name; - public string Email; - public string UserName; - public string MemberTypeAlias; - public bool IsLockedOut; + private bool _hasIdentity; + + public int Id { get; set; } + public string Name { get; set; } + public string Email { get; set; } + public string UserName { get; set; } + public string MemberTypeAlias { get; set; } + public bool IsLockedOut { get; set; } + + public string RawPasswordValue { get; set; } + public DateTime LastPasswordChangeDateUtc { get; set; } + + /// + /// Returns true if an Id has been set on this object + /// This will be false if the object is new and not persisted to the database + /// + public bool HasIdentity => _hasIdentity; + + //TODO: track + public string PasswordHash { get; set; } + + //TODO: config + public string PasswordConfig { get; set; } string Comment; - bool IsApproved; + internal bool IsApproved; DateTime LastLockoutDate; DateTime CreationDate; DateTime LastLoginDate; DateTime LastActivityDate; - DateTime LastPasswordChangedDate; //TODO: needed? //public bool LoginsChanged; //public bool RolesChanged; + + + public static UmbracoMembersIdentityUser CreateNew(string username, string email, string name = null) + { + if (string.IsNullOrWhiteSpace(username)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(username)); + + //no groups/roles yet + var member = new UmbracoMembersIdentityUser + { + UserName = username, + Email = email, + Name = name, + Id = 0, //TODO + _hasIdentity = false + }; + + //TODO: do we use this? + //member.EnableChangeTracking(); + return member; + } } } diff --git a/src/Umbraco.Core/Members/UmbracoMembersUserPasswordCheckerResult.cs b/src/Umbraco.Core/Members/UmbracoMembersUserPasswordCheckerResult.cs new file mode 100644 index 0000000000..8432b7b0bf --- /dev/null +++ b/src/Umbraco.Core/Members/UmbracoMembersUserPasswordCheckerResult.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Core.Members +{ + /// + /// The result returned from the IUmbracoMembersUserPasswordChecker + /// + public enum UmbracoMembersUserPasswordCheckerResult + { + ValidCredentials, + InvalidCredentials, + FallbackToDefaultChecker + } +} diff --git a/src/Umbraco.Infrastructure/Members/IUmbracoMembersUserManager.cs b/src/Umbraco.Infrastructure/Members/IUmbracoMembersUserManager.cs index 646ce367e9..81b11f5141 100644 --- a/src/Umbraco.Infrastructure/Members/IUmbracoMembersUserManager.cs +++ b/src/Umbraco.Infrastructure/Members/IUmbracoMembersUserManager.cs @@ -1,4 +1,6 @@ using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; using Umbraco.Core.Members; namespace Umbraco.Infrastructure.Members @@ -7,8 +9,46 @@ namespace Umbraco.Infrastructure.Members { } - public interface IUmbracoMembersUserManager : IDisposable - where TUser : UmbracoMembersIdentityUser + public interface IUmbracoMembersUserManager : IDisposable where TUser : UmbracoMembersIdentityUser { + /// + /// Creates the specified in the backing store with no password, + /// as an asynchronous operation. + /// + /// The member to create. + /// + /// The that represents the asynchronous operation, containing the + /// of the operation. + /// + Task CreateAsync(TUser memberUser); + + /// + /// Helper method to generate a password for a user based on the current password validator + /// + /// + string GeneratePassword(); + + /// + /// Adds the to the specified only if the user + /// does not already have a password. + /// + /// The member whose password should be set. + /// The password to set. + /// + /// The that represents the asynchronous operation, containing the + /// of the operation. + /// + Task AddPasswordAsync(TUser memberUser, string password); + + /// + /// Returns a flag indicating whether the given is valid for the + /// specified . + /// + /// The user whose password should be validated. + /// The password to validate + /// The that represents the asynchronous operation, containing true if + /// the specified matches the one store for the , + /// otherwise false. + Task CheckPasswordAsync(TUser memberUser, string password); } } diff --git a/src/Umbraco.Infrastructure/Members/UmbracoMembersIdentityBuilder.cs b/src/Umbraco.Infrastructure/Members/UmbracoMembersIdentityBuilder.cs new file mode 100644 index 0000000000..e195dc925c --- /dev/null +++ b/src/Umbraco.Infrastructure/Members/UmbracoMembersIdentityBuilder.cs @@ -0,0 +1,40 @@ +using System; +using System.Reflection; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Core.Members; + +namespace Umbraco.Infrastructure.Members +{ + public class UmbracoMembersIdentityBuilder : IdentityBuilder + { + + public UmbracoMembersIdentityBuilder(IServiceCollection services) : base(typeof(UmbracoMembersIdentityUser), services) + { + } + + public UmbracoMembersIdentityBuilder(Type role, IServiceCollection services) : base(typeof(UmbracoMembersIdentityUser), role, services) + { + } + + /// + /// Adds a token provider for the . + /// + /// The name of the provider to add. + /// The type of the to add. + /// The current instance. + public override IdentityBuilder AddTokenProvider(string providerName, Type provider) + { + if (!typeof(IUserTwoFactorTokenProvider<>).MakeGenericType(UserType).GetTypeInfo().IsAssignableFrom(provider.GetTypeInfo())) + { + throw new InvalidOperationException($"Invalid Type for TokenProvider: {provider.FullName}"); + } + Services.Configure(options => + { + options.Tokens.ProviderMap[providerName] = new TokenProviderDescriptor(provider); + }); + Services.AddTransient(provider); + return this; + } + } +} diff --git a/src/Umbraco.Infrastructure/Members/UmbracoMembersIdentityOptions.cs b/src/Umbraco.Infrastructure/Members/UmbracoMembersIdentityOptions.cs new file mode 100644 index 0000000000..e72b2e3aba --- /dev/null +++ b/src/Umbraco.Infrastructure/Members/UmbracoMembersIdentityOptions.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Identity; + +namespace Umbraco.Infrastructure.Members +{ + /// + /// Identity options specifically for the Umbraco members identity implementation + /// + public class UmbracoMembersIdentityOptions : IdentityOptions + { + } +} diff --git a/src/Umbraco.Infrastructure/Members/UmbracoMembersUserManager.cs b/src/Umbraco.Infrastructure/Members/UmbracoMembersUserManager.cs index d298039484..717d054120 100644 --- a/src/Umbraco.Infrastructure/Members/UmbracoMembersUserManager.cs +++ b/src/Umbraco.Infrastructure/Members/UmbracoMembersUserManager.cs @@ -3,7 +3,16 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Umbraco.Core.Configuration; using Umbraco.Core.Members; +using Umbraco.Core.Security; +using System.Threading; +using Umbraco.Core; +using Umbraco.Core.Configuration.Models; +using Umbraco.Web.Models.ContentEditing; + namespace Umbraco.Infrastructure.Members { @@ -14,15 +23,16 @@ namespace Umbraco.Infrastructure.Members { public UmbracoMembersUserManager( IUserStore store, - IOptions optionsAccessor, + IOptions optionsAccessor, IPasswordHasher passwordHasher, IEnumerable> userValidators, IEnumerable> passwordValidators, ILookupNormalizer keyNormalizer, IdentityErrorDescriber errors, IServiceProvider services, - ILogger> logger) : - base(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger) + ILogger> logger, + IOptions passwordConfiguration) : + base(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger, passwordConfiguration) { } } @@ -30,6 +40,10 @@ namespace Umbraco.Infrastructure.Members public class UmbracoMembersUserManager : UserManager where T : UmbracoMembersIdentityUser { + public IPasswordConfiguration PasswordConfiguration { get; protected set; } + + private PasswordGenerator _passwordGenerator; + public UmbracoMembersUserManager( IUserStore store, IOptions optionsAccessor, @@ -39,9 +53,154 @@ namespace Umbraco.Infrastructure.Members ILookupNormalizer keyNormalizer, IdentityErrorDescriber errors, IServiceProvider services, - ILogger> logger) : + ILogger> logger, + IOptions passwordConfiguration) : base(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger) { + PasswordConfiguration = passwordConfiguration.Value ?? throw new ArgumentNullException(nameof(passwordConfiguration)); + } + + /// + /// Replace the underlying options property with our own strongly typed version + /// + public new UmbracoMembersIdentityOptions Options + { + get => (UmbracoMembersIdentityOptions)base.Options; + set => base.Options = value; + } + + /// + /// Gets/sets the default Umbraco member user password checker + /// + public IUmbracoMembersUserPasswordChecker UmbracoMembersUserPasswordChecker { get; set; } + + /// + /// [TODO: from BackOfficeUserManager duplicated, could be shared] + /// Override to determine how to hash the password + /// + /// + /// + /// + /// + /// + /// This method is called anytime the password needs to be hashed for storage (i.e. including when reset password is used) + /// + protected override async Task UpdatePasswordHash(T memberUser, string newPassword, bool validatePassword) + { + memberUser.LastPasswordChangeDateUtc = DateTime.UtcNow; + + if (validatePassword) + { + IdentityResult validate = await ValidatePasswordAsync(memberUser, newPassword); + if (!validate.Succeeded) + { + return validate; + } + } + + var passwordStore = Store as IUserPasswordStore; + if (passwordStore == null) throw new NotSupportedException("The current user store does not implement " + typeof(IUserPasswordStore<>)); + + var hash = newPassword != null ? PasswordHasher.HashPassword(memberUser, newPassword) : null; + await passwordStore.SetPasswordHashAsync(memberUser, hash, CancellationToken); + await UpdateSecurityStampInternal(memberUser); + return IdentityResult.Success; + } + + ///TODO: duplicated code + /// + /// Logic used to validate a username and password + /// + /// + /// + /// + /// + /// By default this uses the standard ASP.Net Identity approach which is: + /// * Get password store + /// * Call VerifyPasswordAsync with the password store + user + password + /// * Uses the PasswordHasher.VerifyHashedPassword to compare the stored password + /// + /// In some cases people want simple custom control over the username/password check, for simplicity + /// sake, developers would like the users to simply validate against an LDAP directory but the user + /// data remains stored inside of Umbraco. + /// See: http://issues.umbraco.org/issue/U4-7032 for the use cases. + /// + /// We've allowed this check to be overridden with a simple callback so that developers don't actually + /// have to implement/override this class. + /// + public override async Task CheckPasswordAsync(T member, string password) + { + if (UmbracoMembersUserPasswordChecker != null) + { + UmbracoMembersUserPasswordCheckerResult result = await UmbracoMembersUserPasswordChecker.CheckPasswordAsync(member, password); + + if (member.HasIdentity == false) + { + return false; + } + + //if the result indicates to not fallback to the default, then return true if the credentials are valid + if (result != UmbracoMembersUserPasswordCheckerResult.FallbackToDefaultChecker) + { + return result == UmbracoMembersUserPasswordCheckerResult.ValidCredentials; + } + } + + //we cannot proceed if the user passed in does not have an identity + if (member.HasIdentity == false) + return false; + + //use the default behavior + return await base.CheckPasswordAsync(member, password); + } + + ///[TODO: from BackOfficeUserManager duplicated, could be shared] + /// + /// This is copied from the underlying .NET base class since they decided to not expose it + /// + /// + /// + private async Task UpdateSecurityStampInternal(T user) + { + if (SupportsUserSecurityStamp == false) return; + await GetSecurityStore().SetSecurityStampAsync(user, NewSecurityStamp(), CancellationToken.None); + } + + ///[TODO: from BackOfficeUserManager duplicated, could be shared] + /// + /// This is copied from the underlying .NET base class since they decided to not expose it + /// + /// + private IUserSecurityStampStore GetSecurityStore() + { + var store = Store as IUserSecurityStampStore; + if (store == null) throw new NotSupportedException("The current user store does not implement " + typeof(IUserSecurityStampStore<>)); + return store; + } + + ///[TODO: from BackOfficeUserManager duplicated, could be shared] + /// + /// This is copied from the underlying .NET base class since they decided to not expose it + /// + /// + private static string NewSecurityStamp() + { + return Guid.NewGuid().ToString(); + } + + ///[TODO: from BackOfficeUserManager duplicated, could be shared] + /// + /// Helper method to generate a password for a member based on the current password validator + /// + /// + public string GeneratePassword() + { + if (_passwordGenerator == null) + { + _passwordGenerator = new PasswordGenerator(PasswordConfiguration); + } + string password = _passwordGenerator.GeneratePassword(); + return password; } } } diff --git a/src/Umbraco.Infrastructure/Members/UmbracoMembersUserStore.cs b/src/Umbraco.Infrastructure/Members/UmbracoMembersUserStore.cs index 10c85a9def..482d154a2e 100644 --- a/src/Umbraco.Infrastructure/Members/UmbracoMembersUserStore.cs +++ b/src/Umbraco.Infrastructure/Members/UmbracoMembersUserStore.cs @@ -1,16 +1,12 @@ using Microsoft.AspNetCore.Identity; using System; -using System.Collections.Generic; using System.Data; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Umbraco.Core; -using Umbraco.Core.BackOffice; +using Umbraco.Core.Mapping; using Umbraco.Core.Members; using Umbraco.Core.Models; -using Umbraco.Core.Models.Identity; -using Umbraco.Core.Models.Membership; using Umbraco.Core.Services; namespace Umbraco.Infrastructure.Members @@ -19,8 +15,8 @@ namespace Umbraco.Infrastructure.Members /// A custom user store that uses Umbraco member data /// public class UmbracoMembersUserStore : DisposableObjectSlim, - IUserStore - //IUserPasswordStore + IUserStore, + IUserPasswordStore //IUserEmailStore //IUserLoginStore //IUserRoleStore, @@ -30,32 +26,37 @@ namespace Umbraco.Infrastructure.Members //IUserSessionStore { private bool _disposed = false; - private IMemberService _memberService; + private readonly IMemberService _memberService; + private readonly UmbracoMapper _mapper; - public UmbracoMembersUserStore(IMemberService memberService) + public UmbracoMembersUserStore(IMemberService memberService, UmbracoMapper mapper) { _memberService = memberService; + _mapper = mapper; } - public Task CreateAsync(UmbracoMembersIdentityUser memberUser, CancellationToken cancellationToken) { - //TODO: cancellationToken.ThrowIfCancellationRequested(); - //TODO: ThrowIfDisposed(); + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); if (memberUser == null) throw new ArgumentNullException(nameof(memberUser)); - // [Comments from Identity package and BackOfficeUser] + // [Comments from Identity package and BackOfficeUser - do we need this?] // the password must be 'something' it could be empty if authenticating // with an external provider so we'll just generate one and prefix it, the // prefix will help us determine if the password hasn't actually been specified yet. //this will hash the guid with a salt so should be nicely random - var aspHasher = new PasswordHasher(); var emptyPasswordValue = Constants.Security.EmptyPasswordPrefix + aspHasher.HashPassword(memberUser, Guid.NewGuid().ToString("N")); + if (memberUser.RawPasswordValue.IsNullOrWhiteSpace()) + { + memberUser.RawPasswordValue = emptyPasswordValue; + } + //create member //TODO: are we keeping this method, e.g. the Member Service? IMember memberEntity = _memberService.CreateMember( @@ -65,8 +66,7 @@ namespace Umbraco.Infrastructure.Members memberUser.MemberTypeAlias.IsNullOrWhiteSpace() ? Constants.Security.DefaultMemberTypeAlias : memberUser.MemberTypeAlias); - - UpdateMemberProperties(memberEntity, memberUser); + bool anythingChanged = UpdateMemberProperties(memberEntity, memberUser); _memberService.Save(memberEntity); @@ -115,7 +115,6 @@ namespace Umbraco.Infrastructure.Members //} } - private bool UpdateMemberProperties(IMember member, UmbracoMembersIdentityUser memberIdentityUser) { //[Comments as per BackOfficeUserStore & identity package] @@ -191,13 +190,15 @@ namespace Umbraco.Infrastructure.Members } //TODO: PasswordHash and PasswordConfig - //if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.PasswordHash)) - // && member.RawPasswordValue != identityUser.PasswordHash && identityUser.PasswordHash.IsNullOrWhiteSpace() == false) - //{ - // anythingChanged = true; - // member.RawPasswordValue = identityUser.PasswordHash; - // member.PasswordConfiguration = identityUser.PasswordConfig; - //} + if ( + //member.IsPropertyDirty(nameof(BackOfficeIdentityUser.PasswordHash))&& + member.RawPasswordValue != memberIdentityUser.PasswordHash + && memberIdentityUser.PasswordHash.IsNullOrWhiteSpace() == false) + { + anythingChanged = true; + member.RawPasswordValue = memberIdentityUser.PasswordHash; + member.PasswordConfiguration = memberIdentityUser.PasswordConfig; + } //TODO: SecurityStamp //if (member.SecurityStamp != identityUser.SecurityStamp) @@ -245,7 +246,6 @@ namespace Umbraco.Infrastructure.Members return anythingChanged; } - public Task DeleteAsync(UmbracoMembersIdentityUser user, CancellationToken cancellationToken) { throw new NotImplementedException(); @@ -256,9 +256,20 @@ namespace Umbraco.Infrastructure.Members throw new NotImplementedException(); } - public Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken) + public async Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken) { - throw new NotImplementedException(); + //TODO: confirm logic + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + var member = _memberService.GetByUsername(normalizedUserName); + if (member == null) + { + return null; + } + + var result = _mapper.Map(member); + + return await Task.FromResult(result); } public Task GetNormalizedUserNameAsync(UmbracoMembersIdentityUser user, CancellationToken cancellationToken) @@ -268,22 +279,36 @@ namespace Umbraco.Infrastructure.Members public Task GetUserIdAsync(UmbracoMembersIdentityUser user, CancellationToken cancellationToken) { - throw new NotImplementedException(); + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + return Task.FromResult(user.Id.ToString()); } public Task GetUserNameAsync(UmbracoMembersIdentityUser user, CancellationToken cancellationToken) { - throw new NotImplementedException(); + //TODO: unit tests for and implement all bodies + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + return Task.FromResult(user.UserName); } public Task SetNormalizedUserNameAsync(UmbracoMembersIdentityUser user, string normalizedName, CancellationToken cancellationToken) { - throw new NotImplementedException(); + return SetUserNameAsync(user, normalizedName, cancellationToken); throw new NotImplementedException(); } public Task SetUserNameAsync(UmbracoMembersIdentityUser user, string userName, CancellationToken cancellationToken) { - throw new NotImplementedException(); + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + user.UserName = userName; + return Task.CompletedTask; } public Task UpdateAsync(UmbracoMembersIdentityUser user, CancellationToken cancellationToken) @@ -296,5 +321,57 @@ namespace Umbraco.Infrastructure.Members if (_disposed) throw new ObjectDisposedException(GetType().Name); } + + ///TODO: All from BackOfficeUserStore - same. Can we share? + /// + /// Set the user password hash + /// + /// + /// + /// + public Task SetPasswordHashAsync(UmbracoMembersIdentityUser user, string passwordHash, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + if (passwordHash == null) throw new ArgumentNullException(nameof(passwordHash)); + if (string.IsNullOrEmpty(passwordHash)) throw new ArgumentException("Value can't be empty.", nameof(passwordHash)); + + user.PasswordHash = passwordHash; + user.PasswordConfig = null; // Clear this so that it's reset at the repository level + + return Task.CompletedTask; + } + + /// + /// Get the user password hash + /// + /// + /// + /// + public Task GetPasswordHashAsync(UmbracoMembersIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + return Task.FromResult(user.PasswordHash); + } + + /// + /// Returns true if a user has a password set + /// + /// + /// + /// + public Task HasPasswordAsync(UmbracoMembersIdentityUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + return Task.FromResult(string.IsNullOrEmpty(user.PasswordHash) == false); + } + } } diff --git a/src/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs b/src/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs index 792b5cd5c1..0c3adae74b 100644 --- a/src/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs +++ b/src/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs @@ -134,6 +134,7 @@ namespace Umbraco.Tests.Integration.TestServerTest .WithRuntimeMinifier() .WithBackOffice() .WithBackOfficeIdentity() + .WithUmbracoMembersIdentity() .WithPreview() //.WithMiniProfiler() // we don't want this running in tests .WithMvcAndRazor(mvcBuilding: mvcBuilder => diff --git a/src/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UmbracoBackOfficeServiceCollectionExtensionsTests.cs b/src/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UmbracoBackOfficeServiceCollectionExtensionsTests.cs index 450b3a341a..35d70d0915 100644 --- a/src/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UmbracoBackOfficeServiceCollectionExtensionsTests.cs +++ b/src/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UmbracoBackOfficeServiceCollectionExtensionsTests.cs @@ -37,6 +37,6 @@ namespace Umbraco.Tests.Integration.Umbraco.Web.BackOffice Assert.NotNull(userManager); } - protected override Action CustomTestSetup => (services) => services.AddUmbracoBackOfficeIdentity(); + protected override Action CustomTestSetup => (services) => services.AddUmbracoMembersIdentity(); } } diff --git a/src/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UmbracoMembersServiceCollectionExtensionsTests.cs b/src/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UmbracoMembersServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..0432ffa7db --- /dev/null +++ b/src/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UmbracoMembersServiceCollectionExtensionsTests.cs @@ -0,0 +1,43 @@ +using System; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Core.Members; +using Umbraco.Extensions; +using Umbraco.Infrastructure.Members; +using Umbraco.Tests.Integration.Testing; + +namespace Umbraco.Tests.Integration.Umbraco.Web.BackOffice +{ + [TestFixture] + public class UmbracoMembersServiceCollectionExtensionsTests : UmbracoIntegrationTest + { + [Test] + public void AddUmbracoMembersIdentity_ExpectUmbracoMembersUserStoreResolvable() + { + var userStore = Services.GetService>(); + + Assert.IsNotNull(userStore); + Assert.AreEqual(typeof(UmbracoMembersUserStore), userStore.GetType()); + } + + //[Test] + //public void AddUmbracoMembersIdentity_ExpectUmbracoMembersClaimsPrincipalFactoryResolvable() + //{ + // var principalFactory = Services.GetService>(); + + // Assert.IsNotNull(principalFactory); + // Assert.AreEqual(typeof(UmbracoMembersClaimsPrincipalFactory), principalFactory.GetType()); + //} + + [Test] + public void AddUmbracoMembersIdentity_ExpectUmbracomMembersUserManagerResolvable() + { + var userManager = Services.GetService(); + + Assert.NotNull(userManager); + } + + protected override Action CustomTestSetup => (services) => services.AddUmbracoMembersIdentity(); + } +} diff --git a/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Members/UmbracoMemberIdentityUserManagerTests.cs b/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Members/UmbracoMemberIdentityUserManagerTests.cs new file mode 100644 index 0000000000..ca498bb70f --- /dev/null +++ b/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Members/UmbracoMemberIdentityUserManagerTests.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Core.Configuration.Models; +using Umbraco.Core.Members; +using Umbraco.Infrastructure.Members; + +namespace Umbraco.Tests.UnitTests.Umbraco.Infrastructure.Members +{ + [TestFixture] + public class UmbracoMemberIdentityUserManagerTests + { + private Mock> _mockMemberStore; + private Mock> _mockIdentityOptions; + private Mock> _mockPasswordHasher; + private Mock> _mockUserValidators; + private Mock>> _mockPasswordValidators; + private Mock _mockNormalizer; + private IdentityErrorDescriber _mockErrorDescriber; + private Mock _mockServiceProviders; + private Mock>> _mockLogger; + + public UmbracoMembersUserManager CreateSut() + { + _mockMemberStore = new Mock>(); + _mockIdentityOptions = new Mock>(); + + var idOptions = new UmbracoMembersIdentityOptions { Lockout = { AllowedForNewUsers = false } }; + _mockIdentityOptions.Setup(o => o.Value).Returns(idOptions); + _mockPasswordHasher = new Mock>(); + + var userValidators = new List>(); + _mockUserValidators = new Mock>(); + var validator = new Mock>(); + userValidators.Add(validator.Object); + + _mockPasswordValidators = new Mock>>(); + _mockNormalizer = new Mock(); + _mockErrorDescriber = new IdentityErrorDescriber(); + _mockServiceProviders = new Mock(); + _mockLogger = new Mock>>(); + + var pwdValidators = new List> + { + new PasswordValidator() + }; + + var userManager = new UmbracoMembersUserManager( + _mockMemberStore.Object, + _mockIdentityOptions.Object, + _mockPasswordHasher.Object, + userValidators, + pwdValidators, + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + _mockServiceProviders.Object, + new Mock>>().Object, + new Mock>().Object); + + validator.Setup(v => v.ValidateAsync( + userManager, + It.IsAny())) + .Returns(Task.FromResult(IdentityResult.Success)).Verifiable(); + + return userManager; + } + + [Test] + public async Task GivenICreateUser_AndTheIdentityResultFailed_ThenIShouldGetAFailedResultAsync() + { + //arrange + UmbracoMembersUserManager sut = CreateSut(); + UmbracoMembersIdentityUser fakeUser = new UmbracoMembersIdentityUser() { }; + CancellationToken fakeCancellationToken = new CancellationToken() { }; + IdentityError[] identityErrors = + { + new IdentityError() + { + Code = "IdentityError1", + Description = "There was an identity error when creating a user" + } + }; + + _mockMemberStore.Setup(x => + x.CreateAsync(fakeUser, fakeCancellationToken)) + .ReturnsAsync(IdentityResult.Failed(identityErrors)); + + //act + IdentityResult identityResult = await sut.CreateAsync(fakeUser); + + //assert + Assert.IsFalse(identityResult.Succeeded); + Assert.IsFalse(!identityResult.Errors.Any()); + + } + + + [Test] + public async Task GivenICreateUser_AndTheUserIsNull_ThenIShouldGetAFailedResultAsync() + { + //arrange + UmbracoMembersUserManager sut = CreateSut(); + UmbracoMembersIdentityUser fakeUser = new UmbracoMembersIdentityUser() { }; + CancellationToken fakeCancellationToken = new CancellationToken() { }; + IdentityError[] identityErrors = + { + new IdentityError() + { + Code = "IdentityError1", + Description = "There was an identity error when creating a user" + } + }; + + _mockMemberStore.Setup(x => + x.CreateAsync(null, fakeCancellationToken)) + .ReturnsAsync(IdentityResult.Failed(identityErrors)); + + //act + var identityResult = new Func>(() => sut.CreateAsync(null)); + + + //assert + Assert.That(identityResult, Throws.ArgumentNullException); + } + + + [Test] + public async Task GivenICreateANewUser_AndTheUserIsPopulatedCorrectly_ThenIShouldGetASuccessResultAsync() + { + //arrange + UmbracoMembersUserManager sut = CreateSut(); + UmbracoMembersIdentityUser fakeUser = new UmbracoMembersIdentityUser() { }; + CancellationToken fakeCancellationToken = new CancellationToken() { }; + _mockMemberStore.Setup(x => + x.CreateAsync(fakeUser, fakeCancellationToken)) + .ReturnsAsync(IdentityResult.Success); + + //act + IdentityResult identityResult = await sut.CreateAsync(fakeUser); + + //assert + Assert.IsTrue(identityResult.Succeeded); + Assert.IsTrue(!identityResult.Errors.Any()); + } + } +} diff --git a/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Members/UmbracoMemberIdentityUserStoreTests.cs b/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Members/UmbracoMemberIdentityUserStoreTests.cs index 830ff4f14d..c2274d0132 100644 --- a/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Members/UmbracoMemberIdentityUserStoreTests.cs +++ b/src/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Members/UmbracoMemberIdentityUserStoreTests.cs @@ -1,11 +1,13 @@ using System; +using System.Collections; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Castle.Core.Internal; using Microsoft.AspNetCore.Identity; using Moq; using NUnit.Framework; +using Umbraco.Core.Mapping; using Umbraco.Core.Members; using Umbraco.Core.Models; using Umbraco.Core.Services; @@ -22,7 +24,10 @@ namespace Umbraco.Tests.UnitTests.Umbraco.Infrastructure.Members public UmbracoMembersUserStore CreateSut() { _mockMemberService = new Mock(); - return new UmbracoMembersUserStore(_mockMemberService.Object); + return new UmbracoMembersUserStore( + _mockMemberService.Object, + new UmbracoMapper(new MapDefinitionCollection( + new Mock>().Object))); } [Test] @@ -41,20 +46,15 @@ namespace Umbraco.Tests.UnitTests.Umbraco.Infrastructure.Members [Test] - public async Task GivenICreateUser_AndTheUserIsPopulatedCorrectly_ThenIShouldGetASuccessResult() + public async Task GivenICreateANewUser_AndTheUserIsPopulatedCorrectly_ThenIShouldGetASuccessResultAsync() { //arrange UmbracoMembersUserStore sut = CreateSut(); - UmbracoMembersIdentityUser fakeUser = new UmbracoMembersIdentityUser() - { - - }; - + UmbracoMembersIdentityUser fakeUser = new UmbracoMembersIdentityUser() { }; CancellationToken fakeCancellationToken = new CancellationToken() { }; IMemberType fakeMemberType = new MemberType(new MockShortStringHelper(), 77); - - var mockMember = Mock.Of(m => + IMember mockMember = Mock.Of(m => m.Name == "fakeName" && m.Email == "fakeemail@umbraco.com" && m.Username == "fakeUsername" && @@ -74,5 +74,23 @@ namespace Umbraco.Tests.UnitTests.Umbraco.Infrastructure.Members Assert.IsTrue(identityResult.Succeeded); Assert.IsTrue(!identityResult.Errors.Any()); } + + //FindByNameAsync + [Test] + public async Task GivenIGetUserNameAsync() + { + } + + [Test] + public async Task GivenIFindByNameAsync() + { + } + + //SetNormalizedUserNameAsync + //SetUserNameAsync + //HasPasswordAsync + //GetPasswordHashAsync + //SetPasswordHashAsync + //GetUserIdAsync } } diff --git a/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs b/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs index 55042d458b..92ff630023 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs @@ -7,6 +7,7 @@ using System.Net.Http; using System.Net.Mime; using System.Text; using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -15,8 +16,10 @@ 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; @@ -24,6 +27,7 @@ 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; @@ -31,7 +35,6 @@ using Umbraco.Web.Common.Exceptions; using Umbraco.Web.Common.Filters; using Umbraco.Web.ContentApps; using Umbraco.Web.Models.ContentEditing; -using Umbraco.Web.Security; using Constants = Umbraco.Core.Constants; namespace Umbraco.Web.BackOffice.Controllers @@ -45,12 +48,11 @@ namespace Umbraco.Web.BackOffice.Controllers [OutgoingNoHyphenGuidFormat] public class MemberController : ContentControllerBase { - private readonly MemberPasswordConfigurationSettings _passwordConfig; private readonly PropertyEditorCollection _propertyEditors; - private readonly LegacyPasswordSecurity _passwordSecurity; 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; @@ -62,23 +64,21 @@ namespace Umbraco.Web.BackOffice.Controllers IShortStringHelper shortStringHelper, IEventMessagesFactory eventMessages, ILocalizedTextService localizedTextService, - IOptions passwordConfig, PropertyEditorCollection propertyEditors, - LegacyPasswordSecurity passwordSecurity, UmbracoMapper umbracoMapper, IMemberService memberService, IMemberTypeService memberTypeService, + IUmbracoMembersUserManager memberManager, IDataTypeService dataTypeService, IBackOfficeSecurityAccessor backofficeSecurityAccessor, IJsonSerializer jsonSerializer) : base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, jsonSerializer) { - _passwordConfig = passwordConfig.Value; _propertyEditors = propertyEditors; - _passwordSecurity = passwordSecurity; _umbracoMapper = umbracoMapper; _memberService = memberService; _memberTypeService = memberTypeService; + _memberManager = memberManager; _dataTypeService = dataTypeService; _localizedTextService = localizedTextService; _backofficeSecurityAccessor = backofficeSecurityAccessor; @@ -100,8 +100,15 @@ namespace Umbraco.Web.BackOffice.Controllers throw new NotSupportedException("Both pageNumber and pageSize must be greater than zero"); } - var members = _memberService - .GetAll((pageNumber - 1), pageSize, out var totalRecords, orderBy, orderDirection, orderBySystemField, memberTypeAlias, filter).ToArray(); + 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); @@ -109,8 +116,7 @@ namespace Umbraco.Web.BackOffice.Controllers var pagedResult = new PagedResult(totalRecords, pageNumber, pageSize) { - Items = members - .Select(x => _umbracoMapper.Map(x)) + Items = members.Select(x => _umbracoMapper.Map(x)) }; return pagedResult; } @@ -125,8 +131,15 @@ namespace Umbraco.Web.BackOffice.Controllers var foundType = _memberTypeService.Get(listName); var name = foundType != null ? foundType.Name : listName; - var apps = new List(); - apps.Add(ListViewContentAppFactory.CreateContentApp(_dataTypeService, _propertyEditors, listName, "member", Core.Constants.DataTypes.DefaultMembersListView)); + var apps = new List + { + ListViewContentAppFactory.CreateContentApp( + _dataTypeService, + _propertyEditors, + listName, + Constants.Security.DefaultMemberTypeAlias.ToLower(), + Constants.DataTypes.DefaultMembersListView) + }; apps[0].Active = true; var display = new MemberListDisplay @@ -152,7 +165,7 @@ namespace Umbraco.Web.BackOffice.Controllers [TypeFilter(typeof(OutgoingEditorModelEventAttribute))] public MemberDisplay GetByKey(Guid key) { - var foundMember = _memberService.GetByKey(key); + IMember foundMember = _memberService.GetByKey(key); if (foundMember == null) { HandleContentNotFound(key); @@ -168,22 +181,21 @@ namespace Umbraco.Web.BackOffice.Controllers [TypeFilter(typeof(OutgoingEditorModelEventAttribute))] public MemberDisplay GetEmpty(string contentTypeAlias = null) { - IMember emptyContent; if (contentTypeAlias == null) { throw new HttpResponseException(HttpStatusCode.NotFound); } - var contentType = _memberTypeService.Get(contentTypeAlias); + IMemberType contentType = _memberTypeService.Get(contentTypeAlias); if (contentType == null) { throw new HttpResponseException(HttpStatusCode.NotFound); } - var passwordGenerator = new PasswordGenerator(_passwordConfig); + IMember emptyContent = new Member(contentType); - emptyContent = new Member(contentType); - emptyContent.AdditionalData["NewPassword"] = passwordGenerator.GeneratePassword(); + string newPassword = _memberManager.GeneratePassword(); + emptyContent.AdditionalData["NewPassword"] = newPassword; return _umbracoMapper.Map(emptyContent); } @@ -224,8 +236,10 @@ namespace Umbraco.Web.BackOffice.Controllers // 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 - var rolesToRemove = currRoles.Except(contentItem.Groups).ToArray(); + 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. @@ -235,7 +249,7 @@ namespace Umbraco.Web.BackOffice.Controllers UpdateMemberData(contentItem); break; case ContentSaveAction.SaveNew: - contentItem.PersistedContent = CreateMemberData(contentItem); + contentItem.PersistedContent = await CreateMemberData(contentItem); break; default: //we don't support anything else for members @@ -255,7 +269,7 @@ namespace Umbraco.Web.BackOffice.Controllers _memberService.DissociateRoles(new[] { contentItem.PersistedContent.Username }, rolesToRemove); } //find the ones to add and add them - var toAdd = contentItem.Groups.Except(currRoles).ToArray(); + string[] toAdd = contentItem.Groups.Except(roles).ToArray(); if (toAdd.Any()) { //add the ones submitted @@ -263,18 +277,20 @@ namespace Umbraco.Web.BackOffice.Controllers } //return the updated model - var display = _umbracoMapper.Map(contentItem.PersistedContent); + 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); - var localizedTextService = _localizedTextService; + 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")); + display.AddSuccessNotification( + localizedTextService.Localize("speechBubbles/editMemberSaved"), + localizedTextService.Localize("speechBubbles/editMemberSaved")); break; } @@ -302,36 +318,98 @@ namespace Umbraco.Web.BackOffice.Controllers null); // member are all invariant } - private IMember CreateMemberData(MemberSave contentItem) + /// + /// 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) { - throw new NotImplementedException("Members have not been migrated to netcore"); + if (memberSave == null) throw new ArgumentNullException("memberSave"); - // TODO: all member password processing and creation needs to be done with a new aspnet identity MemberUserManager that hasn't been created yet. + if (ModelState.IsValid == false) + { + throw new HttpResponseException(HttpStatusCode.BadRequest, ModelState); + } + //TODO: check if unique - //var memberType = _memberTypeService.Get(contentItem.ContentTypeAlias); - //if (memberType == null) - // throw new InvalidOperationException($"No member type found with alias {contentItem.ContentTypeAlias}"); - //var member = new Member(contentItem.Name, contentItem.Email, contentItem.Username, memberType, true) + 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 = _passwordSecurity.HashPasswordForStorage(contentItem.Password.NewPassword), - // Comments = contentItem.Comments, - // IsApproved = contentItem.IsApproved + // CreatorId = _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Id, + // RawPasswordValue = _memberManager.GeneratePassword(), + // Comments = memberSave.Comments, + // IsApproved = memberSave.IsApproved //}; - //return member; + + //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); + + _memberService.Save(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 contentItem) + private void UpdateMemberData(MemberSave memberSave) { - contentItem.PersistedContent.WriterId = _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Id; + //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. @@ -339,32 +417,32 @@ namespace Umbraco.Web.BackOffice.Controllers // 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(contentItem.PersistedContent.ContentTypeId); + 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 = contentItem.Properties.FirstOrDefault(x => x.Alias == sensitiveProperty.Alias); + 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 = contentItem.PersistedContent.GetValue(sensitiveProperty.Alias); + var origValue = memberSave.PersistedContent.GetValue(sensitiveProperty.Alias); destProp.Value = origValue; } } } - var isLockedOut = contentItem.IsLockedOut; + var isLockedOut = memberSave.IsLockedOut; //if they were locked but now they are trying to be unlocked - if (contentItem.PersistedContent.IsLockedOut && isLockedOut == false) + if (memberSave.PersistedContent.IsLockedOut && isLockedOut == false) { - contentItem.PersistedContent.IsLockedOut = false; - contentItem.PersistedContent.FailedPasswordAttempts = 0; + memberSave.PersistedContent.IsLockedOut = false; + memberSave.PersistedContent.FailedPasswordAttempts = 0; } - else if (!contentItem.PersistedContent.IsLockedOut && isLockedOut) + 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 @@ -372,13 +450,11 @@ namespace Umbraco.Web.BackOffice.Controllers } //no password changes then exit ? - if (contentItem.Password == null) + if (memberSave.Password == null) return; - throw new NotImplementedException("Members have not been migrated to netcore"); - // TODO: all member password processing and creation needs to be done with a new aspnet identity MemberUserManager that hasn't been created yet. // set the password - //contentItem.PersistedContent.RawPasswordValue = _passwordSecurity.HashPasswordForStorage(contentItem.Password.NewPassword); + memberSave.PersistedContent.RawPasswordValue = _memberManager.GeneratePassword(); } private static void UpdateName(MemberSave memberSave) @@ -396,23 +472,23 @@ namespace Umbraco.Web.BackOffice.Controllers if (contentItem.Name.IsNullOrWhiteSpace()) { ModelState.AddPropertyError( - new ValidationResult("Invalid user name", new[] { "value" }), - string.Format("{0}login", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); + new ValidationResult("Invalid user name", new[] { "value" }), + $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login"); return false; } if (contentItem.Password != null && !contentItem.Password.NewPassword.IsNullOrWhiteSpace()) { - //TODO implement when NETCORE members are implemented - throw new NotImplementedException("TODO implement when members are implemented"); - // var validPassword = await _passwordValidator.ValidateAsync(_passwordConfig, contentItem.Password.NewPassword); - // if (!validPassword) - // { - // ModelState.AddPropertyError( - // new ValidationResult("Invalid password: " + string.Join(", ", validPassword.Result), new[] { "value" }), - // string.Format("{0}password", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); - // return false; - // } + //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); @@ -420,7 +496,7 @@ namespace Umbraco.Web.BackOffice.Controllers { ModelState.AddPropertyError( new ValidationResult("Username is already in use", new[] { "value" }), - string.Format("{0}login", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); + $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login"); return false; } @@ -429,7 +505,7 @@ namespace Umbraco.Web.BackOffice.Controllers { ModelState.AddPropertyError( new ValidationResult("Email address is already in use", new[] { "value" }), - string.Format("{0}email", Constants.PropertyEditors.InternalGenericPropertiesPrefix)); + $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email"); return false; } @@ -470,7 +546,7 @@ namespace Umbraco.Web.BackOffice.Controllers return Forbid(); } - var member = ((MemberService)_memberService).ExportMember(key); + MemberExportModel member = ((MemberService)_memberService).ExportMember(key); if (member is null) throw new NullReferenceException("No member found with key " + key); var json = _jsonSerializer.Serialize(member); @@ -479,9 +555,7 @@ namespace Umbraco.Web.BackOffice.Controllers // 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); + return File(Encoding.UTF8.GetBytes(json), MediaTypeNames.Application.Octet, fileName); } } - - } diff --git a/src/Umbraco.Web.BackOffice/Extensions/IdentityBuilderExtensions.cs b/src/Umbraco.Web.BackOffice/Extensions/IdentityBuilderExtensions.cs index eab7142665..04930180d9 100644 --- a/src/Umbraco.Web.BackOffice/Extensions/IdentityBuilderExtensions.cs +++ b/src/Umbraco.Web.BackOffice/Extensions/IdentityBuilderExtensions.cs @@ -12,7 +12,8 @@ namespace Umbraco.Extensions /// The type of the user manager to add. /// /// The current instance. - public static IdentityBuilder AddUserManager(this IdentityBuilder identityBuilder) where TUserManager : UserManager, TInterface + public static IdentityBuilder AddUserManager(this IdentityBuilder identityBuilder) + where TUserManager : UserManager, TInterface { identityBuilder.AddUserManager(); identityBuilder.Services.AddScoped(typeof(TInterface), typeof(TUserManager)); diff --git a/src/Umbraco.Web.BackOffice/Extensions/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.BackOffice/Extensions/UmbracoBuilderExtensions.cs index c494425274..0bb5e922e4 100644 --- a/src/Umbraco.Web.BackOffice/Extensions/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.BackOffice/Extensions/UmbracoBuilderExtensions.cs @@ -13,6 +13,7 @@ namespace Umbraco.Extensions .WithRuntimeMinifier() .WithBackOffice() .WithBackOfficeIdentity() + .WithUmbracoMembersIdentity() .WithMiniProfiler() .WithMvcAndRazor() .WithWebServer() @@ -27,6 +28,9 @@ namespace Umbraco.Extensions public static IUmbracoBuilder WithBackOfficeIdentity(this IUmbracoBuilder builder) => builder.AddWith(nameof(WithBackOfficeIdentity), () => builder.Services.AddUmbracoBackOfficeIdentity()); + public static IUmbracoBuilder WithUmbracoMembersIdentity(this IUmbracoBuilder builder) + => builder.AddWith(nameof(WithUmbracoMembersIdentity), () => builder.Services.AddUmbracoMembersIdentity()); + public static IUmbracoBuilder WithPreview(this IUmbracoBuilder builder) => builder.AddWith(nameof(WithPreview), () => builder.Services.AddUmbracoPreview()); } diff --git a/src/Umbraco.Web.BackOffice/Extensions/UmbracoMemberIdentityBuilderExtensions.cs b/src/Umbraco.Web.BackOffice/Extensions/UmbracoMemberIdentityBuilderExtensions.cs new file mode 100644 index 0000000000..bc8d2bcbfb --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Extensions/UmbracoMemberIdentityBuilderExtensions.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Core.Members; + +namespace Umbraco.Extensions +{ + public static class UmbracoMemberIdentityBuilderExtensions + { + + /// + /// Adds a for the . + /// + /// The type of the user manager to add. + /// + /// The current instance. + public static IdentityBuilder AddUserManager(this IdentityBuilder identityBuilder) where TUserManager : UserManager, TInterface + { + identityBuilder.AddUserManager(); + identityBuilder.Services.AddScoped(typeof(TInterface), typeof(TUserManager)); + return identityBuilder; + } + } +} diff --git a/src/Umbraco.Web.BackOffice/Extensions/UmbracoMembersUserServiceCollectionExtensions.cs b/src/Umbraco.Web.BackOffice/Extensions/UmbracoMembersUserServiceCollectionExtensions.cs new file mode 100644 index 0000000000..6687c9e5be --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Extensions/UmbracoMembersUserServiceCollectionExtensions.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Umbraco.Core.Members; +using Umbraco.Core.Security; +using Umbraco.Core.Serialization; +using Umbraco.Infrastructure.Members; +using Umbraco.Web.BackOffice.Security; + +namespace Umbraco.Extensions +{ + public static class UmbracoMembersUserServiceCollectionExtensions + { + /// + /// Adds the services required for using Umbraco Members Identity + /// + /// + public static void AddUmbracoMembersIdentity(this IServiceCollection services) + { + services.BuildUmbracoMembersIdentity() + .AddDefaultTokenProviders() + .AddUserStore() + .AddUserManager(); + } + + private static UmbracoMembersIdentityBuilder BuildUmbracoMembersIdentity(this IServiceCollection services) + { + // Services used by Umbraco members identity + services.TryAddScoped, UserValidator>(); + services.TryAddScoped, PasswordValidator>(); + services.TryAddScoped, PasswordHasher>(); + return new UmbracoMembersIdentityBuilder(services); + } + } +} diff --git a/src/Umbraco.Web.BackOffice/Trees/MemberTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/MemberTreeController.cs index 31ab66908c..da77f041aa 100644 --- a/src/Umbraco.Web.BackOffice/Trees/MemberTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/MemberTreeController.cs @@ -69,6 +69,7 @@ namespace Umbraco.Web.BackOffice.Trees var node = GetSingleTreeNode(id, queryStrings); //add the tree alias to the node since it is standalone (has no root for which this normally belongs) + //TODO: ID is null since new member created node.Value.AdditionalData["treeAlias"] = TreeAlias; return node; }